diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/debugger/src/utils | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/utils')
166 files changed, 16804 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/DevToolsUtils.js b/devtools/client/debugger/src/utils/DevToolsUtils.js new file mode 100644 index 0000000000..b5babdde21 --- /dev/null +++ b/devtools/client/debugger/src/utils/DevToolsUtils.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import assert from "./assert"; + +export function reportException(who: string, exception: any[]): void { + const msg = `${who} threw an exception: `; + console.error(msg, exception); +} + +export function executeSoon(fn: Function): void { + setTimeout(fn, 0); +} + +export default assert; diff --git a/devtools/client/debugger/src/utils/assert.js b/devtools/client/debugger/src/utils/assert.js new file mode 100644 index 0000000000..cdd05366b0 --- /dev/null +++ b/devtools/client/debugger/src/utils/assert.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { isNodeTest } from "./environment"; + +export default function assert(condition: boolean, message: string): void { + if (isNodeTest() && !condition) { + throw new Error(`Assertion failure: ${message}`); + } +} diff --git a/devtools/client/debugger/src/utils/ast.js b/devtools/client/debugger/src/utils/ast.js new file mode 100644 index 0000000000..05a43ae6b1 --- /dev/null +++ b/devtools/client/debugger/src/utils/ast.js @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { SourceLocation, Position, PartialPosition } from "../types"; +import type { Symbols } from "../reducers/ast"; + +import type { + AstLocation, + FunctionDeclaration, + ClassDeclaration, +} from "../workers/parser"; + +export function findBestMatchExpression(symbols: Symbols, tokenPos: Position) { + if (symbols.loading) { + return null; + } + + const { line, column } = tokenPos; + const { memberExpressions, identifiers, literals } = symbols; + const members = memberExpressions.filter(({ computed }) => !computed); + + return [] + .concat(identifiers, members, literals) + .reduce((found, expression) => { + const overlaps = + expression.location.start.line == line && + expression.location.start.column <= column && + expression.location.end.column >= column; + + if (overlaps) { + return expression; + } + + return found; + }, null); +} + +// Check whether location A starts after location B +export function positionAfter(a: AstLocation, b: AstLocation): boolean { + return ( + a.start.line > b.start.line || + (a.start.line === b.start.line && a.start.column > b.start.column) + ); +} + +export function containsPosition(a: AstLocation, b: PartialPosition): boolean { + const bColumn = b.column || 0; + const startsBefore = + a.start.line < b.line || + (a.start.line === b.line && a.start.column <= bColumn); + const endsAfter = + a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn); + + return startsBefore && endsAfter; +} + +function findClosestofSymbol(declarations: any[], location: SourceLocation) { + if (!declarations) { + return null; + } + + return declarations.reduce((found, currNode) => { + if ( + currNode.name === "anonymous" || + !containsPosition(currNode.location, { + line: location.line, + column: location.column || 0, + }) + ) { + return found; + } + + if (!found) { + return currNode; + } + + if (found.location.start.line > currNode.location.start.line) { + return found; + } + if ( + found.location.start.line === currNode.location.start.line && + found.location.start.column > currNode.location.start.column + ) { + return found; + } + + return currNode; + }, null); +} + +export function findClosestFunction( + symbols: ?Symbols, + location: SourceLocation +): FunctionDeclaration | null { + if (!symbols || symbols.loading) { + return null; + } + + return findClosestofSymbol(symbols.functions, location); +} + +export function findClosestClass( + symbols: Symbols, + location: SourceLocation +): ClassDeclaration | null { + if (!symbols || symbols.loading) { + return null; + } + + return findClosestofSymbol(symbols.classes, location); +} diff --git a/devtools/client/debugger/src/utils/async-value.js b/devtools/client/debugger/src/utils/async-value.js new file mode 100644 index 0000000000..f90b16c5ca --- /dev/null +++ b/devtools/client/debugger/src/utils/async-value.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export type FulfilledValue<+T> = {| + state: "fulfilled", + +value: T, +|}; +export type RejectedValue = {| + state: "rejected", + value: mixed, +|}; +export type PendingValue = {| + state: "pending", +|}; +export type SettledValue<+T> = FulfilledValue<T> | RejectedValue; +export type AsyncValue<+T> = SettledValue<T> | PendingValue; + +export function pending(): PendingValue { + return { state: "pending" }; +} +export function fulfilled<+T>(value: T): FulfilledValue<T> { + return { state: "fulfilled", value }; +} +export function rejected(value: mixed): RejectedValue { + return { state: "rejected", value }; +} + +export function asSettled<T>( + value: AsyncValue<T> | null +): SettledValue<T> | null { + return value && value.state !== "pending" ? value : null; +} + +export function isPending(value: AsyncValue<mixed>): boolean %checks { + return value.state === "pending"; +} +export function isFulfilled(value: AsyncValue<mixed>): boolean %checks { + return value.state === "fulfilled"; +} +export function isRejected(value: AsyncValue<mixed>): boolean %checks { + return value.state === "rejected"; +} diff --git a/devtools/client/debugger/src/utils/bootstrap.js b/devtools/client/debugger/src/utils/bootstrap.js new file mode 100644 index 0000000000..52095de4cd --- /dev/null +++ b/devtools/client/debugger/src/utils/bootstrap.js @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import React from "react"; +import { bindActionCreators, combineReducers } from "redux"; +import ReactDOM from "react-dom"; +const { Provider } = require("react-redux"); + +import ToolboxProvider from "devtools/client/framework/store-provider"; +// $FlowIgnore +import flags from "devtools/shared/flags"; + +// $FlowIgnore +const { AppConstants } = require("resource://gre/modules/AppConstants.jsm"); + +import SourceMaps from "devtools-source-map"; +import * as search from "../workers/search"; +import * as prettyPrint from "../workers/pretty-print"; +import { ParserDispatcher } from "../workers/parser"; + +import configureStore from "../actions/utils/create-store"; +import reducers from "../reducers"; +import * as selectors from "../selectors"; +import App from "../components/App"; +import { asyncStore, prefs } from "./prefs"; +import { persistTabs } from "../utils/tabs"; + +import type { Panel } from "../client/firefox/types"; + +let parser; + +type Workers = { + sourceMaps: typeof SourceMaps, + evaluationsParser: typeof ParserDispatcher, +}; + +export function bootstrapStore( + client: any, + workers: Workers, + panel: Panel, + initialState: Object +): any { + const debugJsModules = AppConstants.DEBUG_JS_MODULES == "1"; + const createStore = configureStore({ + log: prefs.logging || flags.testing, + timing: debugJsModules, + makeThunkArgs: (args, state) => { + return { ...args, client, ...workers, panel }; + }, + }); + + const store = createStore(combineReducers(reducers), initialState); + registerStoreObserver(store, updatePrefs); + + const actions = bindActionCreators( + require("../actions").default, + store.dispatch + ); + + return { store, actions, selectors }; +} + +export function bootstrapWorkers(panelWorkers: Workers): Object { + const workerPath = "resource://devtools/client/debugger/dist"; + + prettyPrint.start(`${workerPath}/pretty-print-worker.js`); + parser = new ParserDispatcher(); + + parser.start(`${workerPath}/parser-worker.js`); + search.start(`${workerPath}/search-worker.js`); + return { ...panelWorkers, prettyPrint, parser, search }; +} + +export function teardownWorkers(): void { + prettyPrint.stop(); + parser.stop(); + search.stop(); +} + +export function bootstrapApp(store: any, panel: Panel): void { + const mount = document.querySelector("#mount"); + if (!mount) { + return; + } + + const toolboxDoc = panel.panelWin.parent.document; + + ReactDOM.render( + React.createElement( + Provider, + { store }, + React.createElement( + ToolboxProvider, + { store: panel.getToolboxStore() }, + React.createElement(App, { toolboxDoc }) + ) + ), + mount + ); +} + +function registerStoreObserver(store, subscriber) { + let oldState = store.getState(); + store.subscribe(() => { + const state = store.getState(); + subscriber(state, oldState); + oldState = state; + }); +} + +function updatePrefs(state: any, oldState: any): void { + const hasChanged = selector => + selector(oldState) && selector(oldState) !== selector(state); + + if (hasChanged(selectors.getPendingBreakpoints)) { + asyncStore.pendingBreakpoints = selectors.getPendingBreakpoints(state); + } + + if ( + oldState.eventListenerBreakpoints && + oldState.eventListenerBreakpoints !== state.eventListenerBreakpoints + ) { + asyncStore.eventListenerBreakpoints = state.eventListenerBreakpoints; + } + + if (hasChanged(selectors.getTabs)) { + asyncStore.tabs = persistTabs(selectors.getTabs(state)); + } + + if (hasChanged(selectors.getXHRBreakpoints)) { + asyncStore.xhrBreakpoints = selectors.getXHRBreakpoints(state); + } + + if (hasChanged(selectors.getBlackBoxList)) { + asyncStore.tabsBlackBoxed = selectors.getBlackBoxList(state); + } +} diff --git a/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js b/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js new file mode 100644 index 0000000000..6915ef74cc --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/astBreakpointLocation.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { findClosestFunction } from "../ast"; + +import type { SourceLocation, Source, ASTLocation } from "../../types"; +import type { Symbols } from "../../reducers/ast"; + +export function getASTLocation( + source: Source, + symbols: ?Symbols, + location: SourceLocation +): ASTLocation { + if (source.isWasm || !symbols || symbols.loading) { + return { name: undefined, offset: location, index: 0 }; + } + + const scope = findClosestFunction(symbols, location); + if (scope) { + // we only record the line, but at some point we may + // also do column offsets + const line = location.line - scope.location.start.line; + return { + name: scope.name, + offset: { line, column: undefined }, + index: scope.index, + }; + } + return { name: undefined, offset: location, index: 0 }; +} + +export function findFunctionByName( + symbols: Symbols, + name: ?string, + index: number +) { + if (symbols.loading) { + return null; + } + + return symbols.functions.find( + node => node.name === name && node.index === index + ); +} diff --git a/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js new file mode 100644 index 0000000000..c561701484 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js @@ -0,0 +1,30 @@ +// @flow + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { comparePosition } from "../location"; +import { getSelectedLocation } from "../selected-location"; +import type { + BreakpointPosition, + BreakpointPositions, + SourceLocation, +} from "../../types"; + +export function findPosition( + positions: ?BreakpointPositions, + location: SourceLocation +): ?BreakpointPosition { + if (!positions) { + return null; + } + + const lineBps = positions[location.line]; + if (!lineBps) { + return null; + } + return lineBps.find(pos => + comparePosition(getSelectedLocation(pos, location), location) + ); +} diff --git a/devtools/client/debugger/src/utils/breakpoint/index.js b/devtools/client/debugger/src/utils/breakpoint/index.js new file mode 100644 index 0000000000..4644487176 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/index.js @@ -0,0 +1,218 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getBreakpoint, + getSource, + getSourceActorsForSource, +} from "../../selectors"; +import { isGenerated } from "../source"; +import { sortSelectedLocations } from "../location"; +import assert from "../assert"; +import { features } from "../prefs"; + +export * from "./astBreakpointLocation"; +export * from "./breakpointPositions"; + +import type { + Source, + SourceActor, + SourceLocation, + SourceActorLocation, + PendingLocation, + Breakpoint, + BreakpointLocation, + PendingBreakpoint, +} from "../../types"; + +import type { State } from "../../reducers/types"; + +// Return the first argument that is a string, or null if nothing is a +// string. +export function firstString(...args: string[]): ?string { + for (const arg of args) { + if (typeof arg === "string") { + return arg; + } + } + return null; +} + +// The ID for a Breakpoint is derived from its location in its Source. +export function makeBreakpointId(location: SourceLocation): string { + const { sourceId, line, column } = location; + const columnString = column || ""; + return `${sourceId}:${line}:${columnString}`; +} + +export function getLocationWithoutColumn(location: SourceLocation): string { + const { sourceId, line } = location; + return `${sourceId}:${line}`; +} + +type AnySourceLocation = SourceLocation | PendingLocation; + +export function makePendingLocationId(location: AnySourceLocation): string { + assertPendingLocation(location); + const { sourceUrl, line, column } = location; + const sourceUrlString = sourceUrl || ""; + const columnString = column || ""; + + return `${sourceUrlString}:${line}:${columnString}`; +} + +export function makeBreakpointLocation( + state: State, + location: SourceLocation +): BreakpointLocation { + const source = getSource(state, location.sourceId); + if (!source) { + throw new Error("no source"); + } + const breakpointLocation: any = { + line: location.line, + column: location.column, + }; + if (source.url) { + breakpointLocation.sourceUrl = source.url; + } else { + breakpointLocation.sourceId = getSourceActorsForSource( + state, + source.id + )[0].id; + } + return breakpointLocation; +} + +export function makeSourceActorLocation( + sourceActor: SourceActor, + location: SourceLocation +) { + return { + sourceActor, + line: location.line, + column: location.column, + }; +} + +// The ID for a BreakpointActor is derived from its location in its SourceActor. +export function makeBreakpointActorId(location: SourceActorLocation): string { + const { sourceActor, line, column } = location; + return `${sourceActor}:${line}:${column || ""}`; +} + +export function assertBreakpoint(breakpoint: Breakpoint): void { + assertLocation(breakpoint.location); + assertLocation(breakpoint.generatedLocation); +} + +export function assertPendingBreakpoint( + pendingBreakpoint: PendingBreakpoint +): void { + assertPendingLocation(pendingBreakpoint.location); + assertPendingLocation(pendingBreakpoint.generatedLocation); +} + +export function assertLocation(location: SourceLocation): void { + assertPendingLocation(location); + const { sourceId } = location; + assert(!!sourceId, "location must have a source id"); +} + +export function assertPendingLocation(location: PendingLocation): void { + assert(!!location, "location must exist"); + + const { sourceUrl } = location; + + // sourceUrl is null when the source does not have a url + assert(sourceUrl !== undefined, "location must have a source url"); + assert(location.hasOwnProperty("line"), "location must have a line"); + assert( + location.hasOwnProperty("column") != null, + "location must have a column" + ); +} + +// syncing +export function breakpointAtLocation( + breakpoints: Breakpoint[], + { line, column }: SourceLocation +): ?Breakpoint { + return breakpoints.find(breakpoint => { + const sameLine = breakpoint.location.line === line; + if (!sameLine) { + return false; + } + + // NOTE: when column breakpoints are disabled we want to find + // the first breakpoint + if (!features.columnBreakpoints) { + return true; + } + + return breakpoint.location.column === column; + }); +} + +export function breakpointExists( + state: State, + location: SourceLocation +): boolean { + const currentBp = getBreakpoint(state, location); + return !!currentBp && !currentBp.disabled; +} + +export function createXHRBreakpoint( + path: string, + method: string, + overrides?: Object = {} +) { + const properties = { + path, + method, + disabled: false, + loading: false, + text: L10N.getFormatStr("xhrBreakpoints.item.label", path), + }; + + return { ...properties, ...overrides }; +} + +function createPendingLocation(location: PendingLocation) { + const { sourceUrl, line, column } = location; + return { sourceUrl, line, column }; +} + +export function createPendingBreakpoint(bp: Breakpoint) { + const pendingLocation = createPendingLocation(bp.location); + const pendingGeneratedLocation = createPendingLocation(bp.generatedLocation); + + assertPendingLocation(pendingLocation); + + return { + options: bp.options, + disabled: bp.disabled, + location: pendingLocation, + astLocation: bp.astLocation, + generatedLocation: pendingGeneratedLocation, + }; +} + +export function getSelectedText( + breakpoint: Breakpoint, + selectedSource: ?Source +): string { + return !!selectedSource && isGenerated(selectedSource) + ? breakpoint.text + : breakpoint.originalText; +} + +export function sortSelectedBreakpoints( + breakpoints: Array<Breakpoint>, + selectedSource: ?Source +): Array<Breakpoint> { + return sortSelectedLocations(breakpoints, selectedSource); +} diff --git a/devtools/client/debugger/src/utils/breakpoint/moz.build b/devtools/client/debugger/src/utils/breakpoint/moz.build new file mode 100644 index 0000000000..24f31beb71 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "astBreakpointLocation.js", + "breakpointPositions.js", + "index.js", +) diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap b/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap new file mode 100644 index 0000000000..c907c080ad --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/tests/__snapshots__/astBreakpointLocation.spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ast invalid location returns name for an anon fn in global scope as undefined 1`] = ` +Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 0, + "line": 44, + }, +} +`; + +exports[`ast invalid location returns the scope name for global scope as undefined 1`] = ` +Object { + "index": 0, + "name": undefined, + "offset": Object { + "column": 0, + "line": 10, + }, +} +`; + +exports[`ast valid location returns name for a nested anon fn as the parent func 1`] = ` +Object { + "index": 0, + "name": "outer", + "offset": Object { + "column": undefined, + "line": 22, + }, +} +`; + +exports[`ast valid location returns name for a nested named fn 1`] = ` +Object { + "index": 0, + "name": "inner", + "offset": Object { + "column": undefined, + "line": 1, + }, +} +`; + +exports[`ast valid location returns name for an anon fn with a named variable 1`] = ` +Object { + "index": 0, + "name": "globalDeclaration", + "offset": Object { + "column": undefined, + "line": 1, + }, +} +`; + +exports[`ast valid location returns the scope and offset 1`] = ` +Object { + "index": 0, + "name": "math", + "offset": Object { + "column": undefined, + "line": 5, + }, +} +`; diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js b/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js new file mode 100644 index 0000000000..a38a668d71 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/tests/astBreakpointLocation.spec.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getASTLocation } from "../astBreakpointLocation.js"; +import { + populateSource, + populateOriginalSource, +} from "../../../workers/parser/tests/helpers"; +import { getSymbols } from "../../../workers/parser/getSymbols"; +import cases from "jest-in-case"; + +async function setup({ file, location, functionName, original }) { + const source = original ? populateOriginalSource(file) : populateSource(file); + + const symbols = getSymbols(source.id); + + const astLocation = getASTLocation(source, symbols, location); + expect(astLocation.name).toBe(functionName); + expect(astLocation).toMatchSnapshot(); +} + +describe("ast", () => { + cases("valid location", setup, [ + { + name: "returns the scope and offset", + file: "math", + location: { line: 6, column: 0 }, + functionName: "math", + }, + { + name: "returns name for a nested anon fn as the parent func", + file: "outOfScope", + location: { line: 25, column: 0 }, + functionName: "outer", + }, + { + name: "returns name for a nested named fn", + file: "outOfScope", + location: { line: 5, column: 0 }, + functionName: "inner", + }, + { + name: "returns name for an anon fn with a named variable", + file: "outOfScope", + location: { line: 40, column: 0 }, + functionName: "globalDeclaration", + }, + ]); + + cases("invalid location", setup, [ + { + name: "returns the scope name for global scope as undefined", + file: "class", + original: true, + location: { line: 10, column: 0 }, + functionName: undefined, + }, + { + name: "returns name for an anon fn in global scope as undefined", + file: "outOfScope", + location: { line: 44, column: 0 }, + functionName: undefined, + }, + ]); +}); diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js new file mode 100644 index 0000000000..f95b296835 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { sortSelectedBreakpoints } from "../index"; + +import { makeMockBreakpoint, makeMockSource } from "../../test-mockup"; + +describe("breakpoint sorting", () => { + it("sortSelectedBreakpoints should sort by line number and column ", () => { + const sorted = sortSelectedBreakpoints( + [ + makeMockBreakpoint(undefined, 100, 2), + makeMockBreakpoint(undefined, 9, 2), + makeMockBreakpoint(undefined, 2), + makeMockBreakpoint(undefined, 2, 7), + ], + makeMockSource() + ); + + expect(sorted[0].location.line).toBe(2); + expect(sorted[0].location.column).toBe(undefined); + expect(sorted[1].location.line).toBe(2); + expect(sorted[1].location.column).toBe(7); + expect(sorted[2].location.line).toBe(9); + expect(sorted[3].location.line).toBe(100); + }); +}); diff --git a/devtools/client/debugger/src/utils/build-query.js b/devtools/client/debugger/src/utils/build-query.js new file mode 100644 index 0000000000..9ad4789602 --- /dev/null +++ b/devtools/client/debugger/src/utils/build-query.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import escapeRegExp from "lodash/escapeRegExp"; + +import type { SearchModifiers } from "../types"; + +type QueryOptions = { + isGlobal?: boolean, + ignoreSpaces?: boolean, +}; + +/** + * Ignore doing outline matches for less than 3 whitespaces + * + * @memberof utils/source-search + * @static + */ +function ignoreWhiteSpace(str: string): string { + return /^\s{0,2}$/.test(str) ? "(?!\\s*.*)" : str; +} + +function wholeMatch(query: string, wholeWord: boolean): string { + if (query === "" || !wholeWord) { + return query; + } + + return `\\b${query}\\b`; +} + +function buildFlags(caseSensitive: boolean, isGlobal: boolean): ?RegExp$flags { + if (caseSensitive && isGlobal) { + return "g"; + } + + if (!caseSensitive && isGlobal) { + return "gi"; + } + + if (!caseSensitive && !isGlobal) { + return "i"; + } +} + +export default function buildQuery( + originalQuery: string, + modifiers: SearchModifiers, + { isGlobal = false, ignoreSpaces = false }: QueryOptions +): RegExp { + const { caseSensitive, regexMatch, wholeWord } = modifiers; + + if (originalQuery === "") { + return new RegExp(originalQuery); + } + + let query = originalQuery; + if (ignoreSpaces) { + query = ignoreWhiteSpace(query); + } + + if (!regexMatch) { + query = escapeRegExp(query); + } + + query = wholeMatch(query, wholeWord); + const flags = buildFlags(caseSensitive, isGlobal); + + if (flags) { + return new RegExp(query, flags); + } + + return new RegExp(query); +} diff --git a/devtools/client/debugger/src/utils/clipboard.js b/devtools/client/debugger/src/utils/clipboard.js new file mode 100644 index 0000000000..f2d5fd0a25 --- /dev/null +++ b/devtools/client/debugger/src/utils/clipboard.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Clipboard function taken from + * https://searchfox.org/mozilla-central/source/devtools/shared/platform/clipboard.js + */ + +// @flow + +export function copyToTheClipboard(string: string): void { + const doCopy = function(e: any) { + e.clipboardData.setData("text/plain", string); + e.preventDefault(); + }; + + document.addEventListener("copy", doCopy); + document.execCommand("copy", false, null); + document.removeEventListener("copy", doCopy); +} diff --git a/devtools/client/debugger/src/utils/connect.js b/devtools/client/debugger/src/utils/connect.js new file mode 100644 index 0000000000..7188149d1b --- /dev/null +++ b/devtools/client/debugger/src/utils/connect.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +// @flow + +import { connect as reduxConnect } from "react-redux"; + +export const connect: typeof reduxConnect = reduxConnect; diff --git a/devtools/client/debugger/src/utils/context.js b/devtools/client/debugger/src/utils/context.js new file mode 100644 index 0000000000..500a38c751 --- /dev/null +++ b/devtools/client/debugger/src/utils/context.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +// @flow + +import type { ThreadId } from "../types"; +import type { State } from "../reducers/types"; + +import { getThreadContext } from "../selectors"; + +// Context encapsulates the main parameters of the current redux state, which +// impact most other information tracked by the debugger. +// +// The main use of Context is to control when asynchronous operations are +// allowed to make changes to the program state. Such operations might be +// invalidated as the state changes from the time the operation was originally +// initiated. For example, operations on pause state might still continue even +// after the thread unpauses. +// +// The methods below can be used to compare an old context with the current one +// and see if the operation is now invalid and should be abandoned. Actions can +// also include a 'cx' Context property, which will be checked by the context +// middleware. If the action fails validateContextAction() then it will not be +// dispatched. +// +// Context can additionally be used as a shortcut to access the main properties +// of the pause state. + +// A normal Context is invalidated if the target navigates. +export type NavigateContext = {| + // Counter reflecting how many times the debugger has navigated to a new page + // and reset most of its state. + +navigateCounter: number, +|}; + +// A ThreadContext is invalidated if the target navigates, or if the current +// thread changes, pauses, or resumes. +export type ThreadContext = {| + +navigateCounter: number, + + // The currently selected thread. + +thread: ThreadId, + + // Counter reflecting how many times the selected thread has paused or + // resumed. + +pauseCounter: number, + + // Whether the current thread is paused. This is determined from the other + // Context properties and is here for convenient access. + +isPaused: boolean, +|}; + +export type Context = NavigateContext | ThreadContext; + +export class ContextError extends Error {} + +export function validateNavigateContext(state: State, cx: Context): void { + const newcx = getThreadContext(state); + + if (newcx.navigateCounter != cx.navigateCounter) { + throw new ContextError("Page has navigated"); + } +} + +export function validateThreadContext(state: State, cx: ThreadContext): void { + const newcx = getThreadContext(state); + + if (cx.thread != newcx.thread) { + throw new ContextError("Current thread has changed"); + } + + if (cx.pauseCounter != newcx.pauseCounter) { + throw new ContextError("Current thread has paused or resumed"); + } +} + +export function validateContext(state: State, cx: Context): void { + validateNavigateContext(state, cx); + + if ("thread" in cx) { + validateThreadContext(state, (cx: any)); + } +} + +export function isValidThreadContext(state: State, cx: ThreadContext): boolean { + const newcx = getThreadContext(state); + return cx.thread == newcx.thread && cx.pauseCounter == newcx.pauseCounter; +} diff --git a/devtools/client/debugger/src/utils/dbg.js b/devtools/client/debugger/src/utils/dbg.js new file mode 100644 index 0000000000..4696c33101 --- /dev/null +++ b/devtools/client/debugger/src/utils/dbg.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import * as timings from "./timings"; +import { prefs, asyncStore, features } from "./prefs"; +import { getDocument } from "./editor/source-documents"; +import type { Source, URL } from "../types"; +import type { ThreadFront } from "../client/firefox/types"; + +function getThreadFront(dbg: Object): ThreadFront { + return dbg.connection.targetList.targetFront.threadFront; +} + +function findSource(dbg: any, url: URL): Source { + const sources = dbg.selectors.getSourceList(); + return sources.find(s => (s.url || "").includes(url)); +} + +function findSources(dbg: any, url: URL): Source[] { + const sources = dbg.selectors.getSourceList(); + return sources.filter(s => (s.url || "").includes(url)); +} + +function evaluate(dbg: Object, expression: any) { + return dbg.client.evaluate(expression); +} + +function bindSelectors(obj: Object): Object { + return Object.keys(obj.selectors).reduce((bound, selector) => { + bound[selector] = (a, b, c) => + obj.selectors[selector](obj.store.getState(), a, b, c); + return bound; + }, {}); +} + +function getCM(): Object { + const cm: any = document.querySelector(".CodeMirror"); + return cm?.CodeMirror; +} + +function formatMappedLocation(mappedLocation) { + const { location, generatedLocation } = mappedLocation; + return { + original: `(${location.line}, ${location.column})`, + generated: `(${generatedLocation.line}, ${generatedLocation.column})`, + }; +} + +function formatMappedLocations(locations) { + return console.table(locations.map(loc => formatMappedLocation(loc))); +} + +function formatSelectedColumnBreakpoints(dbg) { + const positions = dbg.selectors.getBreakpointPositionsForSource( + dbg.selectors.getSelectedSource().id + ); + + return formatMappedLocations(positions); +} + +function getDocumentForUrl(dbg, url) { + const source = findSource(dbg, url); + return getDocument(source.id); +} + +const diff = (a, b) => Object.keys(a).filter(key => !Object.is(a[key], b[key])); + +export function setupHelper(obj: Object) { + const selectors = bindSelectors(obj); + const dbg: Object = { + ...obj, + selectors, + prefs, + asyncStore, + features, + timings, + getCM, + helpers: { + findSource: url => findSource(dbg, url), + findSources: url => findSources(dbg, url), + evaluate: expression => evaluate(dbg, expression), + dumpThread: () => getThreadFront(dbg).dumpThread(), + getDocument: url => getDocumentForUrl(dbg, url), + }, + formatters: { + mappedLocations: locations => formatMappedLocations(locations), + mappedLocation: location => formatMappedLocation(location), + selectedColumnBreakpoints: () => formatSelectedColumnBreakpoints(dbg), + }, + _telemetry: { + events: {}, + }, + diff, + }; + + window.dbg = dbg; +} diff --git a/devtools/client/debugger/src/utils/defer.js b/devtools/client/debugger/src/utils/defer.js new file mode 100644 index 0000000000..333068087a --- /dev/null +++ b/devtools/client/debugger/src/utils/defer.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +type Deferred<T> = { + promise: Promise<T>, + resolve: (arg: T) => mixed, + reject: (arg: mixed) => mixed, +}; + +export default function defer<T>(): Deferred<T> { + let resolve = () => {}; + let reject = () => {}; + const promise = new Promise((_res, _rej) => { + resolve = _res; + reject = _rej; + }); + + return { resolve, reject, promise }; +} diff --git a/devtools/client/debugger/src/utils/editor/create-editor.js b/devtools/client/debugger/src/utils/editor/create-editor.js new file mode 100644 index 0000000000..805e713240 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/create-editor.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import SourceEditor from "./source-editor"; +import { features, prefs } from "../prefs"; + +export function createEditor(): SourceEditor { + const gutters = ["breakpoints", "hit-markers", "CodeMirror-linenumbers"]; + + if (features.codeFolding) { + gutters.push("CodeMirror-foldgutter"); + } + + return new SourceEditor({ + mode: "javascript", + foldGutter: features.codeFolding, + enableCodeFolding: features.codeFolding, + readOnly: true, + lineNumbers: true, + theme: "mozilla", + styleActiveLine: false, + lineWrapping: prefs.editorWrapping, + matchBrackets: true, + showAnnotationRuler: true, + gutters, + value: " ", + extraKeys: { + // Override code mirror keymap to avoid conflicts with split console. + Esc: false, + "Cmd-F": false, + "Ctrl-F": false, + "Cmd-G": false, + "Ctrl-G": false, + }, + cursorBlinkRate: prefs.cursorBlinkRate, + }); +} + +export function createHeadlessEditor(): SourceEditor { + const editor = createEditor(); + editor.appendToLocalElement(document.createElement("div")); + return editor; +} diff --git a/devtools/client/debugger/src/utils/editor/get-expression.js b/devtools/client/debugger/src/utils/editor/get-expression.js new file mode 100644 index 0000000000..1a307ffd8e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-expression.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Position } from "../../types"; + +type Token = { + startColumn: number, + endColumn: number, + type: string, +}; + +export function tokenAtTextPosition( + cm: any, + { line, column }: Position +): Token | null { + if (line < 0 || line >= cm.lineCount()) { + return null; + } + + const token = cm.getTokenAt({ line: line - 1, ch: column }); + if (!token) { + return null; + } + + return { startColumn: token.start, endColumn: token.end, type: token.type }; +} + +// The strategy of querying codeMirror tokens was borrowed +// from Chrome's inital implementation in JavaScriptSourceFrame.js#L414 +export function getExpressionFromCoords(cm: any, coord: Position) { + const token = tokenAtTextPosition(cm, coord); + if (!token) { + return null; + } + + let startHighlight = token.startColumn; + const endHighlight = token.endColumn; + const lineNumber = coord.line; + const line = cm.doc.getLine(coord.line - 1); + while (startHighlight > 1 && line.charAt(startHighlight - 1) === ".") { + const tokenBefore = tokenAtTextPosition(cm, { + line: coord.line, + column: startHighlight - 2, + }); + + if (!tokenBefore || !tokenBefore.type) { + return null; + } + + startHighlight = tokenBefore.startColumn; + } + + const expression = line.substring(startHighlight, endHighlight) || ""; + + if (!expression) { + return null; + } + + const location = { + start: { line: lineNumber, column: startHighlight }, + end: { line: lineNumber, column: endHighlight }, + }; + return { expression, location }; +} diff --git a/devtools/client/debugger/src/utils/editor/get-token-location.js b/devtools/client/debugger/src/utils/editor/get-token-location.js new file mode 100644 index 0000000000..dbcbf22d0d --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-token-location.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import type { Position } from "../../types"; + +export function getTokenLocation( + codeMirror: any, + tokenEl: HTMLElement +): Position { + const { left, top, width, height } = tokenEl.getBoundingClientRect(); + const { line, ch } = codeMirror.coordsChar({ + left: left + width / 2, + top: top + height / 2, + }); + + return { + line: line + 1, + column: ch, + }; +} diff --git a/devtools/client/debugger/src/utils/editor/index.js b/devtools/client/debugger/src/utils/editor/index.js new file mode 100644 index 0000000000..c1e7deafa4 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/index.js @@ -0,0 +1,279 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export * from "./source-documents"; +export * from "./get-token-location"; +export * from "./source-search"; +export * from "../ui"; +export { onMouseOver } from "./token-events"; + +import { createEditor } from "./create-editor"; +import { findNext, findPrev } from "./source-search"; + +import { isWasm, lineToWasmOffset, wasmOffsetToLine } from "../wasm"; + +import type { AstLocation } from "../../workers/parser"; +import type { EditorPosition, EditorRange } from "../editor/types"; +import type { + SearchModifiers, + Source, + SourceLocation, + SourceId, +} from "../../types"; +type Editor = Object; + +let editor: ?Editor; + +export function getEditor() { + if (editor) { + return editor; + } + + editor = createEditor(); + return editor; +} + +export function removeEditor() { + editor = null; +} + +function getCodeMirror() { + return editor && editor.hasCodeMirror ? editor.codeMirror : null; +} + +export function startOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.startOperation(); +} + +export function endOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.endOperation(); +} + +export function traverseResults( + e: Event, + ctx: any, + query: string, + dir: string, + modifiers: SearchModifiers +) { + e.stopPropagation(); + e.preventDefault(); + + if (dir == "prev") { + findPrev(ctx, query, true, modifiers); + } else if (dir == "next") { + findNext(ctx, query, true, modifiers); + } +} + +export function toEditorLine(sourceId: SourceId, lineOrOffset: number): number { + if (isWasm(sourceId)) { + // TODO ensure offset is always "mappable" to edit line. + return wasmOffsetToLine(sourceId, lineOrOffset) || 0; + } + + return lineOrOffset ? lineOrOffset - 1 : 1; +} + +export function fromEditorLine(sourceId: SourceId, line: number): number { + if (isWasm(sourceId)) { + return lineToWasmOffset(sourceId, line) || 0; + } + + return line + 1; +} + +export function toEditorPosition(location: SourceLocation): EditorPosition { + return { + line: toEditorLine(location.sourceId, location.line), + column: isWasm(location.sourceId) || !location.column ? 0 : location.column, + }; +} + +export function toEditorRange( + sourceId: SourceId, + location: AstLocation +): EditorRange { + const { start, end } = location; + return { + start: toEditorPosition({ ...start, sourceId }), + end: toEditorPosition({ ...end, sourceId }), + }; +} + +export function toSourceLine(sourceId: SourceId, line: number): ?number { + return isWasm(sourceId) ? lineToWasmOffset(sourceId, line) : line + 1; +} + +export function scrollToColumn(codeMirror: any, line: number, column: number) { + const { top, left } = codeMirror.charCoords({ line, ch: column }, "local"); + + if (!isVisible(codeMirror, top, left)) { + const scroller = codeMirror.getScrollerElement(); + const centeredX = Math.max(left - scroller.offsetWidth / 2, 0); + const centeredY = Math.max(top - scroller.offsetHeight / 2, 0); + + codeMirror.scrollTo(centeredX, centeredY); + } +} + +function isVisible(codeMirror: any, top: number, left: number) { + function withinBounds(x, min, max) { + return x >= min && x <= max; + } + + const scrollArea = codeMirror.getScrollInfo(); + const charWidth = codeMirror.defaultCharWidth(); + const fontHeight = codeMirror.defaultTextHeight(); + const { scrollTop, scrollLeft } = codeMirror.doc; + + const inXView = withinBounds( + left, + scrollLeft, + scrollLeft + (scrollArea.clientWidth - 30) - charWidth + ); + + const inYView = withinBounds( + top, + scrollTop, + scrollTop + scrollArea.clientHeight - fontHeight + ); + + return inXView && inYView; +} + +export function getLocationsInViewport( + { codeMirror }: Object, + // Offset represents an allowance of characters or lines offscreen to improve + // perceived performance of column breakpoint rendering + offsetHorizontalCharacters: number = 100, + offsetVerticalLines: number = 20 +) { + // Get scroll position + if (!codeMirror) { + return { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }; + } + const charWidth = codeMirror.defaultCharWidth(); + const scrollArea = codeMirror.getScrollInfo(); + const { scrollLeft } = codeMirror.doc; + const rect = codeMirror.getWrapperElement().getBoundingClientRect(); + const topVisibleLine = + codeMirror.lineAtHeight(rect.top, "window") - offsetVerticalLines; + const bottomVisibleLine = + codeMirror.lineAtHeight(rect.bottom, "window") + offsetVerticalLines; + + const leftColumn = Math.floor( + scrollLeft > 0 ? scrollLeft / charWidth - offsetHorizontalCharacters : 0 + ); + const rightPosition = scrollLeft + (scrollArea.clientWidth - 30); + const rightCharacter = + Math.floor(rightPosition / charWidth) + offsetHorizontalCharacters; + + return { + start: { + line: topVisibleLine || 0, + column: leftColumn || 0, + }, + end: { + line: bottomVisibleLine || 0, + column: rightCharacter, + }, + }; +} + +export function markText( + { codeMirror }: Object, + className: string, + { start, end }: EditorRange +) { + return codeMirror.markText( + { ch: start.column, line: start.line }, + { ch: end.column, line: end.line }, + { className } + ); +} + +export function lineAtHeight( + { codeMirror }: Object, + sourceId: SourceId, + event: MouseEvent +) { + const _editorLine = codeMirror.lineAtHeight(event.clientY); + return toSourceLine(sourceId, _editorLine); +} + +export function getSourceLocationFromMouseEvent( + { codeMirror }: Object, + source: Source, + e: MouseEvent +) { + const { line, ch } = codeMirror.coordsChar({ + left: e.clientX, + top: e.clientY, + }); + const sourceId = source.id; + + return { + sourceId, + line: fromEditorLine(sourceId, line), + column: isWasm(sourceId) ? 0 : ch + 1, + }; +} + +export function forEachLine(codeMirror: Object, iter: Function) { + codeMirror.operation(() => { + codeMirror.doc.iter(0, codeMirror.lineCount(), iter); + }); +} + +export function removeLineClass( + codeMirror: Object, + line: number, + className: string +) { + codeMirror.removeLineClass(line, "wrapClass", className); +} + +export function clearLineClass(codeMirror: Object, className: string) { + forEachLine(codeMirror, line => { + removeLineClass(codeMirror, line, className); + }); +} + +export function getTextForLine(codeMirror: Object, line: number): string { + return codeMirror.getLine(line - 1).trim(); +} + +export function getCursorLine(codeMirror: Object): number { + return codeMirror.getCursor().line; +} + +export function getCursorColumn(codeMirror: Object): number { + return codeMirror.getCursor().ch; +} + +export function getTokenEnd(codeMirror: Object, line: number, column: number) { + const token = codeMirror.getTokenAt({ + line, + ch: column + 1, + }); + const tokenString = token.string; + + return tokenString === "{" || tokenString === "[" ? null : token.end; +} diff --git a/devtools/client/debugger/src/utils/editor/moz.build b/devtools/client/debugger/src/utils/editor/moz.build new file mode 100644 index 0000000000..655c0dae43 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "create-editor.js", + "get-expression.js", + "get-token-location.js", + "index.js", + "source-documents.js", + "source-editor.js", + "source-search.js", + "token-events.js", +) diff --git a/devtools/client/debugger/src/utils/editor/source-documents.js b/devtools/client/debugger/src/utils/editor/source-documents.js new file mode 100644 index 0000000000..0ad4c0a689 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-documents.js @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getMode } from "../source"; + +import { isWasm, getWasmLineNumberFormatter, renderWasmText } from "../wasm"; +import { isMinified } from "../isMinified"; +import { resizeBreakpointGutter, resizeToggleButton } from "../ui"; +import SourceEditor from "./source-editor"; + +import type { + SourceId, + Source, + SourceContent, + SourceWithContent, + SourceDocuments, +} from "../../types"; +import type { SymbolDeclarations } from "../../workers/parser"; + +let sourceDocs: SourceDocuments = {}; + +export function getDocument(key: string): Object { + return sourceDocs[key]; +} + +export function hasDocument(key: string): boolean { + return !!getDocument(key); +} + +export function setDocument(key: string, doc: any): void { + sourceDocs[key] = doc; +} + +export function removeDocument(key: string): void { + delete sourceDocs[key]; +} + +export function clearDocuments(): void { + sourceDocs = {}; +} + +function resetLineNumberFormat(editor: SourceEditor): void { + const cm = editor.codeMirror; + cm.setOption("lineNumberFormatter", number => number); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +export function updateLineNumberFormat( + editor: SourceEditor, + sourceId: SourceId +): void { + if (!isWasm(sourceId)) { + return resetLineNumberFormat(editor); + } + const cm = editor.codeMirror; + const lineNumberFormatter = getWasmLineNumberFormatter(sourceId); + cm.setOption("lineNumberFormatter", lineNumberFormatter); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +export function updateDocument(editor: SourceEditor, source: Source): void { + if (!source) { + return; + } + + const sourceId = source.id; + const doc = getDocument(sourceId) || editor.createDocument(); + editor.replaceDocument(doc); + + updateLineNumberFormat(editor, sourceId); +} + +/* used to apply the context menu wrap line option change to all the docs */ +export function updateDocuments(updater: Function) { + for (const key in sourceDocs) { + if (sourceDocs[key].cm == null) { + continue; + } else { + updater(sourceDocs[key]); + } + } +} + +export function clearEditor(editor: SourceEditor) { + const doc = editor.createDocument(); + editor.replaceDocument(doc); + editor.setText(""); + editor.setMode({ name: "text" }); + resetLineNumberFormat(editor); +} + +export function showLoading(editor: SourceEditor): void { + let doc = getDocument("loading"); + + if (doc) { + editor.replaceDocument(doc); + } else { + doc = editor.createDocument(); + setDocument("loading", doc); + doc.setValue(L10N.getStr("loadingText")); + editor.replaceDocument(doc); + editor.setMode({ name: "text" }); + } +} + +export function showErrorMessage(editor: Object, msg: string): void { + let error; + if (msg.includes("WebAssembly binary source is not available")) { + error = L10N.getStr("wasmIsNotAvailable"); + } else { + error = L10N.getFormatStr("errorLoadingText3", msg); + } + const doc = editor.createDocument(); + editor.replaceDocument(doc); + editor.setText(error); + editor.setMode({ name: "text" }); + resetLineNumberFormat(editor); +} + +function setEditorText( + editor: Object, + sourceId: SourceId, + content: SourceContent +): void { + if (content.type === "wasm") { + const wasmLines = renderWasmText(sourceId, content); + // cm will try to split into lines anyway, saving memory + const wasmText = { split: () => wasmLines, match: () => false }; + editor.setText(wasmText); + } else { + editor.setText(content.value); + } +} + +function setMode( + editor, + source: SourceWithContent, + content: SourceContent, + symbols +): void { + // Disable modes for minified files with 1+ million characters Bug 1569829 + if ( + content.type === "text" && + isMinified(source) && + content.value.length > 1000000 + ) { + return; + } + + const mode = getMode(source, content, symbols); + const currentMode = editor.codeMirror.getOption("mode"); + if (!currentMode || currentMode.name != mode.name) { + editor.setMode(mode); + } +} + +/** + * Handle getting the source document or creating a new + * document with the correct mode and text. + */ +export function showSourceText( + editor: Object, + source: SourceWithContent, + content: SourceContent, + symbols?: SymbolDeclarations +): void { + if (hasDocument(source.id)) { + const doc = getDocument(source.id); + if (editor.codeMirror.doc === doc) { + setMode(editor, source, content, symbols); + return; + } + + editor.replaceDocument(doc); + updateLineNumberFormat(editor, source.id); + setMode(editor, source, content, symbols); + return doc; + } + + const doc = editor.createDocument(); + setDocument(source.id, doc); + editor.replaceDocument(doc); + + setEditorText(editor, source.id, content); + setMode(editor, source, content, symbols); + updateLineNumberFormat(editor, source.id); +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.css b/devtools/client/debugger/src/utils/editor/source-editor.css new file mode 100644 index 0000000000..380256b58d --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.css @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --breakpoint-active-color: rgba(44, 187, 15, 0.2); + --breakpoint-active-color-hover: rgba(44, 187, 15, 0.5); + --debug-line-background: rgba(226, 236, 247, 0.5); + --debug-line-border: rgb(145, 188, 219); +} + +.theme-dark:root { + --debug-line-background: rgb(73, 82, 103); + --debug-line-border: rgb(119, 134, 162); + --breakpoint-active-color: rgba(45, 210, 158, 0.5); + --breakpoint-active-color-hover: rgba(0, 255, 175, 0.7); +} + +.CodeMirror .errors { + width: 16px; +} + +.CodeMirror .error { + display: inline-block; + margin-left: 5px; + width: 12px; + height: 12px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + /* background-image: url("chrome://devtools/skin/images/editor-error.png"); */ + opacity: 0.75; +} + +.CodeMirror .hit-counts { + width: 6px; +} + +.CodeMirror .hit-count { + display: inline-block; + height: 12px; + border: solid rgba(0, 0, 0, 0.2); + border-width: 1px 1px 1px 0; + border-radius: 0 3px 3px 0; + padding: 0 3px; + font-size: 10px; + pointer-events: none; +} + +.theme-dark .debug-line .CodeMirror-linenumber { + color: #c0c0c0; +} + +.debug-line .CodeMirror-line { + background-color: var(--debug-line-background) !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.debug-line .CodeMirror-activeline-background { + display: none; +} + +.CodeMirror { + cursor: text; + height: 100%; +} + +.CodeMirror-gutters { + cursor: default; +} + +/* This is to avoid the fake horizontal scrollbar div of codemirror to go 0 +height when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-height` specific to the `scrollbar[orient="horizontal"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-hscrollbar { + min-height: 10px; +} + +/* This is to avoid the fake vertical scrollbar div of codemirror to go 0 +width when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-width` specific to the `scrollbar[orient="vertical"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-vscrollbar { + min-width: 10px; +} + +.cm-trailingspace { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg=="); + opacity: 0.75; + background-position: left bottom; + background-repeat: repeat-x; +} + +/* CodeMirror dialogs styling */ + +.CodeMirror-dialog { + padding: 4px 3px; +} + +.CodeMirror-dialog, +.CodeMirror-dialog input { + font: message-box; +} + +/* Fold addon */ + +.CodeMirror-foldmarker { + color: blue; + text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, + #b9f -1px 1px 2px; + font-family: sans-serif; + line-height: 0.3; + cursor: pointer; +} + +.CodeMirror-foldgutter { + width: 10px; +} + +.CodeMirror-foldgutter-open, +.CodeMirror-foldgutter-folded { + color: #555; + cursor: pointer; + line-height: 1; + padding: 0 1px; +} + +.CodeMirror-foldgutter-open::after, +.CodeMirror-foldgutter-open::before, +.CodeMirror-foldgutter-folded::after, +.CodeMirror-foldgutter-folded::before { + content: ""; + height: 0; + width: 0; + position: absolute; + border: 4px solid transparent; +} + +.CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-codemirror-gutter-background); + top: 4px; +} + +.CodeMirror-foldgutter-open::before { + border-top-color: var(--theme-body-color); + top: 5px; +} + +.new-breakpoint .CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-open::before { + border-top-color: white; +} + +.CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-codemirror-gutter-background); + left: 3px; + top: 3px; +} + +.CodeMirror-foldgutter-folded::before { + border-left-color: var(--theme-body-color); + left: 4px; + top: 3px; +} + +.new-breakpoint .CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-folded::before { + border-left-color: white; +} + +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 2px; + border-radius: 3px; + font-size: 90%; + max-height: 20em; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 0 4px; + border-radius: 2px; + max-width: 19em; + overflow: hidden; + white-space: pre; + cursor: pointer; +} + +.CodeMirror-Tern-completion { + padding-inline-start: 22px; + position: relative; + line-height: 18px; +} + +.CodeMirror-Tern-completion:before { + position: absolute; + left: 2px; + bottom: 2px; + border-radius: 50%; + font-size: 12px; + font-weight: bold; + height: 15px; + width: 15px; + line-height: 16px; + text-align: center; + color: #ffffff; + box-sizing: border-box; +} + +.CodeMirror-Tern-completion-unknown:before { + content: "?"; +} + +.CodeMirror-Tern-completion-object:before { + content: "O"; +} + +.CodeMirror-Tern-completion-fn:before { + content: "F"; +} + +.CodeMirror-Tern-completion-array:before { + content: "A"; +} + +.CodeMirror-Tern-completion-number:before { + content: "N"; +} + +.CodeMirror-Tern-completion-string:before { + content: "S"; +} + +.CodeMirror-Tern-completion-bool:before { + content: "B"; +} + +.CodeMirror-Tern-completion-guess { + color: #999; +} + +.CodeMirror-Tern-tooltip { + border-radius: 3px; + padding: 2px 5px; + white-space: pre-wrap; + max-width: 40em; + position: absolute; + z-index: 10; +} + +.CodeMirror-Tern-hint-doc { + max-width: 25em; +} + +.CodeMirror-Tern-farg-current { + text-decoration: underline; +} + +.CodeMirror-Tern-fhint-guess { + opacity: 0.7; +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.js b/devtools/client/debugger/src/utils/editor/source-editor.js new file mode 100644 index 0000000000..a27d658cc9 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.js @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * CodeMirror source editor utils + * @module utils/source-editor + */ + +const CodeMirror = require("codemirror"); + +// $FlowIgnore +require("raw!chrome://devtools/content/shared/sourceeditor/codemirror/lib/codemirror.css"); +require("codemirror/mode/javascript/javascript"); +require("codemirror/mode/htmlmixed/htmlmixed"); +require("codemirror/mode/coffeescript/coffeescript"); +require("codemirror/mode/jsx/jsx"); +require("codemirror/mode/elm/elm"); +require("codemirror/mode/clojure/clojure"); +require("codemirror/mode/haxe/haxe"); +require("codemirror/addon/search/searchcursor"); +require("codemirror/addon/fold/foldcode"); +require("codemirror/addon/fold/brace-fold"); +require("codemirror/addon/fold/indent-fold"); +require("codemirror/addon/fold/foldgutter"); +require("codemirror/addon/runmode/runmode"); +require("codemirror/addon/selection/active-line"); +require("codemirror/addon/edit/matchbrackets"); +require("codemirror/addon/display/placeholder"); +require("codemirror/mode/clike/clike"); +require("codemirror/mode/rust/rust"); + +// $FlowIgnore +require("raw!chrome://devtools/content/debugger/src/utils/editor/source-editor.css"); + +// NOTE: we should eventually use debugger-html context type mode +type Mode = string | Object; +export type AlignOpts = "top" | "center" | "bottom"; + +// Maximum allowed margin (in number of lines) from top or bottom of the editor +// while shifting to a line which was initially out of view. +const MAX_VERTICAL_OFFSET = 3; + +type SourceEditorOpts = { + enableCodeFolding: boolean, + extraKeys: Object, + gutters: string[], + foldGutter: boolean, + lineNumbers: boolean, + lineWrapping: boolean, + matchBrackets: boolean, + mode: string, + readOnly: boolean, + showAnnotationRuler: boolean, + theme: string, + value: string, +}; + +export default class SourceEditor { + opts: SourceEditorOpts; + editor: any; + + constructor(opts: SourceEditorOpts): void { + this.opts = opts; + } + + appendToLocalElement(node: any): void { + this.editor = CodeMirror(node, this.opts); + } + + destroy(): void { + // Unlink the current document. + if (this.editor.doc) { + this.editor.doc.cm = null; + } + } + + get codeMirror(): Object { + return this.editor; + } + + get CodeMirror(): Object { + return CodeMirror; + } + + setText(str: string): void { + this.editor.setValue(str); + } + + getText(): string { + return this.editor.getValue(); + } + + setMode(value: Mode): void { + this.editor.setOption("mode", value); + } + + /** + * Replaces the current document with a new source document + * @memberof utils/source-editor + */ + replaceDocument(doc: any): void { + this.editor.swapDoc(doc); + } + + /** + * Creates a CodeMirror Document + * @returns CodeMirror.Doc + * @memberof utils/source-editor + */ + createDocument(): Object { + return new CodeMirror.Doc(""); + } + + /** + * Aligns the provided line to either "top", "center" or "bottom" of the + * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or + * bottom. + * @memberof utils/source-editor + */ + alignLine(line: number, align: AlignOpts = "top"): void { + const cm = this.editor; + const editorClientRect = cm.getWrapperElement().getBoundingClientRect(); + + const from = cm.lineAtHeight(editorClientRect.top, "page"); + const to = cm.lineAtHeight( + editorClientRect.height + editorClientRect.top, + "page" + ); + + const linesVisible = to - from; + const halfVisible = Math.round(linesVisible / 2); + + // If the target line is in view, skip the vertical alignment part. + if (line <= to && line >= from) { + return; + } + + // Setting the offset so that the line always falls in the upper half + // of visible lines (lower half for bottom aligned). + // MAX_VERTICAL_OFFSET is the maximum allowed value. + const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); + + let topLine = + { + center: Math.max(line - halfVisible, 0), + bottom: Math.max(line - linesVisible + offset, 0), + top: Math.max(line - offset, 0), + }[align || "top"] || offset; + + // Bringing down the topLine to total lines in the editor if exceeding. + topLine = Math.min(topLine, cm.lineCount()); + this.setFirstVisibleLine(topLine); + } + + /** + * Scrolls the view such that the given line number is the first visible line. + * @memberof utils/source-editor + */ + setFirstVisibleLine(line: number): void { + const { top } = this.editor.charCoords({ line, ch: 0 }, "local"); + this.editor.scrollTo(0, top); + } +} diff --git a/devtools/client/debugger/src/utils/editor/source-search.js b/devtools/client/debugger/src/utils/editor/source-search.js new file mode 100644 index 0000000000..044ed5b3fa --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-search.js @@ -0,0 +1,350 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import buildQuery from "../build-query"; + +import type { SearchModifiers } from "../../types"; + +/** + * @memberof utils/source-search + * @static + */ +function getSearchCursor(cm, query: string, pos, modifiers: SearchModifiers) { + const regexQuery = buildQuery(query, modifiers, { isGlobal: true }); + return cm.getSearchCursor(regexQuery, pos); +} + +/** + * @memberof utils/source-search + * @static + */ +function SearchState(): void { + this.posFrom = this.posTo = this.query = null; + this.overlay = null; + this.results = []; +} + +/** + * @memberof utils/source-search + * @static + */ +function getSearchState(cm: any, query: string) { + const state = cm.state.search || (cm.state.search = new SearchState()); + return state; +} + +function isWhitespace(query): boolean { + return !query.match(/\S/); +} + +/** + * This returns a mode object used by CoeMirror's addOverlay function + * to parse and style tokens in the file. + * The mode object contains a tokenizer function (token) which takes + * a character stream as input, advances it a character at a time, + * and returns style(s) for that token. For more details see + * https://codemirror.net/doc/manual.html#modeapi + * + * Also the token function code is mainly based of work done + * by the chrome devtools team. Thanks guys! :) + * + * @memberof utils/source-search + * @static + */ +function searchOverlay(query, modifiers) { + const regexQuery = buildQuery(query, modifiers, { + ignoreSpaces: true, + // regex must be global for the overlay + isGlobal: true, + }); + + return { + token: function(stream, state) { + // set the last index to be the current stream position + // this acts as an offset + regexQuery.lastIndex = stream.pos; + const match = regexQuery.exec(stream.string); + if (match && match.index === stream.pos) { + // if we have a match at the current stream position + // set the class for a match + stream.pos += match[0].length || 1; + return "highlight highlight-full"; + } else if (match) { + // if we have a match somewhere in the line, go to that point in the + // stream + stream.pos = match.index; + } else { + // if we have no matches in this line, skip to the end of the line + stream.skipToEnd(); + } + }, + }; +} + +/** + * @memberof utils/source-search + * @static + */ +function updateOverlay(cm, state, query, modifiers): void { + cm.removeOverlay(state.overlay); + state.overlay = searchOverlay(query, modifiers); + cm.addOverlay(state.overlay, { opaque: false }); +} + +function updateCursor(cm, state, keepSelection): void { + state.posTo = cm.getCursor("anchor"); + state.posFrom = cm.getCursor("head"); + + if (!keepSelection) { + state.posTo = { line: 0, ch: 0 }; + state.posFrom = { line: 0, ch: 0 }; + } +} + +export function getMatchIndex( + count: number, + currentIndex: number, + rev: boolean +): number { + if (!rev) { + if (currentIndex == count - 1) { + return 0; + } + + return currentIndex + 1; + } + + if (currentIndex == 0) { + return count - 1; + } + + return currentIndex - 1; +} + +/** + * If there's a saved search, selects the next results. + * Otherwise, creates a new search and selects the first + * result. + * + * @memberof utils/source-search + * @static + */ +function doSearch( + ctx, + rev, + query, + keepSelection, + modifiers: SearchModifiers, + focusFirstResult?: boolean = true +) { + const { cm, ed } = ctx; + if (!cm) { + return; + } + const defaultIndex = { line: -1, ch: -1 }; + + return cm.operation(function() { + if (!query || isWhitespace(query)) { + clearSearch(cm, query); + return; + } + + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + const searchLocation = searchNext(ctx, rev, query, isNewQuery, modifiers); + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText && searchLocation && focusFirstResult) { + ed.alignLine(searchLocation.from.line, "center"); + cm.setSelection(searchLocation.from, searchLocation.to); + } + + return searchLocation ? searchLocation.from : defaultIndex; + }); +} + +export function searchSourceForHighlight( + ctx: Object, + rev: boolean, + query: string, + keepSelection: boolean, + modifiers: SearchModifiers, + line: number, + ch: number +) { + const { cm } = ctx; + if (!cm) { + return; + } + + return cm.operation(function() { + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + findNextOnLine(ctx, rev, query, isNewQuery, modifiers, line, ch); + }); +} + +function getCursorPos(newQuery, rev, state) { + if (newQuery) { + return rev ? state.posFrom : state.posTo; + } + + return rev ? state.posTo : state.posFrom; +} + +/** + * Selects the next result of a saved search. + * + * @memberof utils/source-search + * @static + */ +function searchNext(ctx, rev, query, newQuery, modifiers) { + const { cm } = ctx; + let nextMatch; + cm.operation(function() { + const state = getSearchState(cm, query); + const pos = getCursorPos(newQuery, rev, state); + + if (!state.query) { + return; + } + + let cursor = getSearchCursor(cm, state.query, pos, modifiers); + + const location = rev + ? { line: cm.lastLine(), ch: null } + : { line: cm.firstLine(), ch: 0 }; + + if (!cursor.find(rev) && state.query) { + cursor = getSearchCursor(cm, state.query, location, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + nextMatch = { from: cursor.from(), to: cursor.to() }; + }); + + return nextMatch; +} + +function findNextOnLine(ctx, rev, query, newQuery, modifiers, line, ch): void { + const { cm, ed } = ctx; + cm.operation(function() { + const pos = { line: line - 1, ch }; + let cursor = getSearchCursor(cm, query, pos, modifiers); + + if (!cursor.find(rev) && query) { + cursor = getSearchCursor(cm, query, pos, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText) { + ed.alignLine(cursor.from().line, "center"); + cm.setSelection(cursor.from(), cursor.to()); + } + }); +} + +/** + * Remove overlay. + * + * @memberof utils/source-search + * @static + */ +export function removeOverlay(ctx: any, query: string): void { + const state = getSearchState(ctx.cm, query); + ctx.cm.removeOverlay(state.overlay); + const { line, ch } = ctx.cm.getCursor(); + ctx.cm.doc.setSelection({ line, ch }, { line, ch }, { scroll: false }); +} + +/** + * Clears the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function clearSearch(cm: any, query: string): void { + const state = getSearchState(cm, query); + + state.results = []; + + if (!state.query) { + return; + } + cm.removeOverlay(state.overlay); + state.query = null; +} + +/** + * Starts a new search. + * + * @memberof utils/source-search + * @static + */ +export function find( + ctx: any, + query: string, + keepSelection: boolean, + modifiers: SearchModifiers, + focusFirstResult?: boolean +) { + clearSearch(ctx.cm, query); + return doSearch( + ctx, + false, + query, + keepSelection, + modifiers, + focusFirstResult + ); +} + +/** + * Finds the next item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findNext( + ctx: any, + query: string, + keepSelection: boolean, + modifiers: SearchModifiers +) { + return doSearch(ctx, false, query, keepSelection, modifiers); +} + +/** + * Finds the previous item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findPrev( + ctx: any, + query: string, + keepSelection: boolean, + modifiers: SearchModifiers +) { + return doSearch(ctx, true, query, keepSelection, modifiers); +} + +export { buildQuery }; diff --git a/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap new file mode 100644 index 0000000000..f5bba6cd3e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createEditor Adds codeFolding 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": true, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": true, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + "CodeMirror-foldgutter", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; + +exports[`createEditor Returns a SourceEditor 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": false, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": false, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; diff --git a/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js new file mode 100644 index 0000000000..b7a6f79305 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createEditor } from "../create-editor"; +import SourceEditor from "../source-editor"; + +import { features } from "../../prefs"; + +describe("createEditor", () => { + test("Returns a SourceEditor", () => { + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).not.toContain("CodeMirror-foldgutter"); + }); + + test("Adds codeFolding", () => { + features.codeFolding = true; + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).toContain("CodeMirror-foldgutter"); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js new file mode 100644 index 0000000000..ac47d67dbe --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js @@ -0,0 +1,228 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + traverseResults, + toEditorLine, + toEditorPosition, + toEditorRange, + toSourceLine, + scrollToColumn, + markText, + lineAtHeight, + getSourceLocationFromMouseEvent, + forEachLine, + removeLineClass, + clearLineClass, + getTextForLine, + getCursorLine, +} from "../index"; + +import { makeMockSource } from "../../test-mockup"; + +describe("traverseResults", () => { + const e: any = { stopPropagation: jest.fn(), preventDefault: jest.fn() }; + const ctx = {}; + const query = "Awesome books"; + const modifiers = { + caseSensitive: false, + regexMatch: false, + wholeWord: false, + }; + it("traverses next", () => { + traverseResults(e, ctx, query, "next", modifiers); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(e.preventDefault).toHaveBeenCalled(); + // expect(findNext).toHaveBeenCalledWith(ctx, query, true, modifiers); + }); + + it("traverses previous", () => { + e.stopPropagation.mockClear(); + e.preventDefault.mockClear(); + traverseResults(e, ctx, query, "prev", modifiers); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(e.preventDefault).toHaveBeenCalled(); + // expect(findPrev).toHaveBeenCalledWith(ctx, query, true, modifiers); + }); +}); + +describe("toEditorLine", () => { + it("returns an editor line", () => { + const testId = "test-123"; + const line = 30; + expect(toEditorLine(testId, line)).toEqual(29); + }); +}); + +describe("toEditorPosition", () => { + it("returns an editor position", () => { + const loc = { sourceId: "source", line: 100, column: 25 }; + expect(toEditorPosition(loc)).toEqual({ + line: 99, + column: 25, + }); + }); +}); + +describe("toEditorRange", () => { + it("returns an editor range", () => { + const testId = "test-123"; + const loc = { + start: { line: 100, column: 25 }, + end: { line: 200, column: 0 }, + }; + expect(toEditorRange(testId, loc)).toEqual({ + start: { line: 99, column: 25 }, + end: { line: 199, column: 0 }, + }); + }); +}); + +describe("toSourceLine", () => { + it("returns a source line", () => { + const testId = "test-123"; + const line = 30; + expect(toSourceLine(testId, line)).toEqual(31); + }); +}); + +const codeMirror = { + doc: { + iter: jest.fn((_, __, cb) => cb()), + }, + lineCount: jest.fn(() => 100), + getLine: jest.fn(() => "something"), + getCursor: jest.fn(() => ({ line: 3 })), + getScrollerElement: jest.fn(() => ({ + offsetWidth: 100, + offsetHeight: 100, + })), + getScrollInfo: () => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + clientHeight: 100, + clientWidth: 100, + }), + removeLineClass: jest.fn(), + operation: jest.fn(cb => cb()), + charCoords: jest.fn(() => ({ + top: 100, + right: 50, + bottom: 100, + left: 50, + })), + coordsChar: jest.fn(() => ({ line: 6, ch: 30 })), + lineAtHeight: jest.fn(() => 300), + markText: jest.fn(), + scrollTo: jest.fn(), + defaultCharWidth: jest.fn(() => 8), + defaultTextHeight: jest.fn(() => 16), +}; + +const editor = { codeMirror }; + +describe("scrollToColumn", () => { + it("calls codemirror APIs charCoords, getScrollerElement, scrollTo", () => { + scrollToColumn(codeMirror, 60, 123); + expect(codeMirror.charCoords).toHaveBeenCalledWith( + { line: 60, ch: 123 }, + "local" + ); + expect(codeMirror.scrollTo).toHaveBeenCalledWith(0, 50); + }); +}); + +describe("markText", () => { + it("calls codemirror API markText & returns marker", () => { + const loc = { + start: { line: 10, column: 0 }, + end: { line: 30, column: 50 }, + }; + markText(editor, "test-123", loc); + expect(codeMirror.markText).toHaveBeenCalledWith( + { ch: loc.start.column, line: loc.start.line }, + { ch: loc.end.column, line: loc.end.line }, + { className: "test-123" } + ); + }); +}); + +describe("lineAtHeight", () => { + it("calls codemirror API lineAtHeight", () => { + const e: any = { clientX: 30, clientY: 60 }; + expect(lineAtHeight(editor, "test-123", e)).toEqual(301); + expect(editor.codeMirror.lineAtHeight).toHaveBeenCalledWith(e.clientY); + }); +}); + +describe("getSourceLocationFromMouseEvent", () => { + it("calls codemirror API coordsChar & returns location", () => { + const source = makeMockSource(undefined, "test-123"); + const e: any = { clientX: 30, clientY: 60 }; + expect(getSourceLocationFromMouseEvent(editor, source, e)).toEqual({ + sourceId: "test-123", + line: 7, + column: 31, + }); + expect(editor.codeMirror.coordsChar).toHaveBeenCalledWith({ + left: 30, + top: 60, + }); + }); +}); + +describe("forEachLine", () => { + it("calls codemirror API operation && doc.iter across a doc", () => { + const test = jest.fn(); + forEachLine(codeMirror, test); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith(0, 100, test); + }); +}); + +describe("removeLineClass", () => { + it("calls codemirror API removeLineClass", () => { + const line = 3; + const className = "test-class"; + removeLineClass(codeMirror, line, className); + expect(codeMirror.removeLineClass).toHaveBeenCalledWith( + line, + "wrapClass", + className + ); + }); +}); + +describe("clearLineClass", () => { + it("Uses forEachLine & removeLineClass to clear class on all lines", () => { + codeMirror.operation.mockClear(); + codeMirror.doc.iter.mockClear(); + codeMirror.removeLineClass.mockClear(); + clearLineClass(codeMirror, "test-class"); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith( + 0, + 100, + expect.any(Function) + ); + expect(codeMirror.removeLineClass).toHaveBeenCalled(); + }); +}); + +describe("getTextForLine", () => { + it("calls codemirror API getLine & returns line text", () => { + getTextForLine(codeMirror, 3); + expect(codeMirror.getLine).toHaveBeenCalledWith(2); + }); +}); +describe("getCursorLine", () => { + it("calls codemirror API getCursor & returns line number", () => { + getCursorLine(codeMirror); + expect(codeMirror.getCursor).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js new file mode 100644 index 0000000000..9ffe67b436 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import CodeMirror from "codemirror"; +import { getExpressionFromCoords } from "../get-expression"; + +describe("get-expression", () => { + let isCreateTextRangeDefined; + + beforeAll(() => { + if ((document.body: any).createTextRange) { + isCreateTextRangeDefined = true; + } else { + isCreateTextRangeDefined = false; + // CodeMirror needs createTextRange + // https://discuss.codemirror.net/t/working-in-jsdom-or-node-js-natively/138/5 + (document.body: any).createTextRange = () => ({ + getBoundingClientRect: jest.fn(), + getClientRects: () => ({}), + }); + } + }); + + afterAll(() => { + if (!isCreateTextRangeDefined) { + delete (document.body: any).createTextRange; + } + }); + + describe("getExpressionFromCoords", () => { + it("returns null when location.line is greater than the lineCount", () => { + const cm = CodeMirror(document.body, { + value: "let Line1;\n" + "let Line2;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 3, + column: 1, + }); + expect(result).toBeNull(); + }); + + it("gets the expression using CodeMirror.getTokenAt", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 0 })), + doc: { + getLine: () => "", + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 1, column: 1 }); + expect(codemirrorMock.getTokenAt).toHaveBeenCalled(); + }); + + it("requests the correct line and column from codeMirror", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 1 })), + doc: { + getLine: jest.fn(() => ""), + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 20, column: 5 }); + // getExpressionsFromCoords uses one based line indexing + // CodeMirror uses zero based line indexing + expect(codemirrorMock.getTokenAt).toHaveBeenCalledWith({ + line: 19, + ch: 5, + }); + expect(codemirrorMock.doc.getLine).toHaveBeenCalledWith(19); + }); + + it("when called with column 0 returns null", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 0, + }); + expect(result).toBeNull(); + }); + + it("gets the expression when first token on the line", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 1, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 3 }); + }); + + it("includes previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 5, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 7 }); + }); + + it("includes multiple previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + + it("does not include tokens not part of the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 4 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js new file mode 100644 index 0000000000..761a6ef838 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getTokenLocation } from "../get-token-location"; + +describe("getTokenLocation", () => { + const codemirror = { + coordsChar: jest.fn(() => ({ + line: 1, + ch: "C", + })), + }; + const token: any = { + getBoundingClientRect() { + return { + left: 10, + top: 20, + width: 10, + height: 10, + }; + }, + }; + it("calls into codeMirror", () => { + getTokenLocation(codemirror, token); + expect(codemirror.coordsChar).toHaveBeenCalledWith({ + left: 15, + top: 25, + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js new file mode 100644 index 0000000000..922a301790 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + find, + searchSourceForHighlight, + getMatchIndex, + removeOverlay, +} from "../source-search"; + +const getCursor = jest.fn(() => ({ line: 90, ch: 54 })); +const cursor = { + find: jest.fn(), + from: jest.fn(), + to: jest.fn(), +}; +const getSearchCursor = jest.fn(() => cursor); +const modifiers = { + caseSensitive: false, + regexMatch: false, + wholeWord: false, +}; + +const getCM = () => ({ + operation: jest.fn(cb => cb()), + addOverlay: jest.fn(), + removeOverlay: jest.fn(), + getCursor, + getSearchCursor, + firstLine: jest.fn(), + state: {}, +}); + +describe("source-search", () => { + describe("find", () => { + it("calls into CodeMirror APIs via clearSearch & doSearch", () => { + const ctx = { cm: getCM() }; + expect(ctx.cm.state).toEqual({}); + find(ctx, "test", false, modifiers); + // First we check the APIs called via clearSearch + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + // Next those via doSearch + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + const search = { + query: "test", + posTo: { line: 0, ch: 0 }, + posFrom: { line: 0, ch: 0 }, + overlay: { token: expect.any(Function) }, + results: [], + }; + expect(ctx.cm.state).toEqual({ search }); + }); + + it("clears a previous overlay", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: { token: expect.any(Function) }, + results: [], + }; + find(ctx, "test", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith({ + token: expect.any(Function), + }); + }); + + it("clears for empty queries", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: null, + results: [], + }; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + ctx.cm.removeOverlay.mockClear(); + ctx.cm.state.search.query = "bar"; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + }); + }); + + describe("searchSourceForHighlight", () => { + it("calls into CodeMirror APIs and sets the correct selection", () => { + const line = 15; + const from = { line, ch: 1 }; + const to = { line, ch: 5 }; + const cm = { + ...getCM(), + setSelection: jest.fn(), + getSearchCursor: () => ({ + find: () => true, + from: () => from, + to: () => to, + }), + }; + const ed = { alignLine: jest.fn() }; + const ctx = { cm, ed }; + + expect(ctx.cm.state).toEqual({}); + searchSourceForHighlight(ctx, false, "test", false, modifiers, line, 1); + + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + expect(ed.alignLine).toHaveBeenCalledWith(line, "center"); + expect(cm.setSelection).toHaveBeenCalledWith(from, to); + }); + }); + + describe("findNext", () => {}); + + describe("findPrev", () => {}); + + describe("getMatchIndex", () => { + it("iterates in the matches", () => { + const count = 3; + + // reverse 2, 1, 0, 2 + + let matchIndex = getMatchIndex(count, 2, true); + expect(matchIndex).toBe(1); + + matchIndex = getMatchIndex(count, 1, true); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, true); + expect(matchIndex).toBe(2); + + // forward 1, 2, 0, 1 + + matchIndex = getMatchIndex(count, 1, false); + expect(matchIndex).toBe(2); + + matchIndex = getMatchIndex(count, 2, false); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, false); + expect(matchIndex).toBe(1); + }); + }); + + describe("removeOverlay", () => { + it("calls CodeMirror APIs: removeOverlay, getCursor & setSelection", () => { + const ctx = { + cm: { + removeOverlay: jest.fn(), + getCursor, + state: {}, + doc: { + setSelection: jest.fn(), + }, + }, + }; + removeOverlay(ctx, "test"); + expect(ctx.cm.removeOverlay).toHaveBeenCalled(); + expect(ctx.cm.getCursor).toHaveBeenCalled(); + expect(ctx.cm.doc.setSelection).toHaveBeenCalledWith( + { line: 90, ch: 54 }, + { line: 90, ch: 54 }, + { scroll: false } + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/token-events.js b/devtools/client/debugger/src/utils/editor/token-events.js new file mode 100644 index 0000000000..700f7285a3 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/token-events.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getTokenLocation } from "."; +import { isEqual } from "lodash"; + +function isInvalidTarget(target: HTMLElement): boolean { + if (!target || !target.innerText) { + return true; + } + + const tokenText = target.innerText.trim(); + const cursorPos = target.getBoundingClientRect(); + + // exclude literal tokens where it does not make sense to show a preview + const invalidType = ["cm-atom", ""].includes(target.className); + + // exclude syntax where the expression would be a syntax error + const invalidToken = + tokenText === "" || tokenText.match(/^[(){}\|&%,.;=<>\+-/\*\s](?=)/); + + // exclude codemirror elements that are not tokens + const invalidTarget = + (target.parentElement && + !target.parentElement.closest(".CodeMirror-line")) || + cursorPos.top == 0; + + const invalidClasses = ["editor-mount"]; + if (invalidClasses.some(className => target.classList.contains(className))) { + return true; + } + + if (target.closest(".popover")) { + return true; + } + + return !!(invalidTarget || invalidToken || invalidType); +} + +function dispatch(codeMirror, eventName, data) { + codeMirror.constructor.signal(codeMirror, eventName, data); +} + +function invalidLeaveTarget(target: ?HTMLElement) { + if (!target || target.closest(".popover")) { + return true; + } + + return false; +} + +export function onMouseOver(codeMirror: any): any { + let prevTokenPos = null; + + function onMouseLeave(event) { + if (invalidLeaveTarget(event.relatedTarget)) { + return addMouseLeave(event.target); + } + + prevTokenPos = null; + dispatch(codeMirror, "tokenleave", event); + } + + function addMouseLeave(target) { + target.addEventListener("mouseleave", onMouseLeave, { + capture: true, + once: true, + }); + } + + return (enterEvent: any) => { + const { target } = enterEvent; + + if (isInvalidTarget(target)) { + return; + } + + const tokenPos = getTokenLocation(codeMirror, target); + + if (!isEqual(prevTokenPos, tokenPos)) { + addMouseLeave(target); + + dispatch(codeMirror, "tokenenter", { + event: enterEvent, + target, + tokenPos, + }); + prevTokenPos = tokenPos; + } + }; +} diff --git a/devtools/client/debugger/src/utils/editor/types.js b/devtools/client/debugger/src/utils/editor/types.js new file mode 100644 index 0000000000..b72f4914c7 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/types.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export type EditorPosition = { line: number, column: number }; + +export type EditorRange = { end: EditorPosition, start: EditorPosition }; diff --git a/devtools/client/debugger/src/utils/environment.js b/devtools/client/debugger/src/utils/environment.js new file mode 100644 index 0000000000..6f68dd793c --- /dev/null +++ b/devtools/client/debugger/src/utils/environment.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function isNode() { + try { + return process.release.name == "node"; + } catch (e) { + return false; + } +} + +export function isNodeTest() { + return isNode() && process.env.NODE_ENV != "production"; +} diff --git a/devtools/client/debugger/src/utils/evaluation-result.js b/devtools/client/debugger/src/utils/evaluation-result.js new file mode 100644 index 0000000000..8f2a59cd22 --- /dev/null +++ b/devtools/client/debugger/src/utils/evaluation-result.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { + Grip, + ObjectFront, + LongStringFront, + ExpressionResult, +} from "../client/firefox/types"; + +function isFront(result: ExpressionResult): boolean %checks { + return !!result && typeof result === "object" && !!result.getGrip; +} + +export function getGrip( + result: ExpressionResult +): Grip | string | number | null { + if (isFront(result)) { + return result.getGrip(); + } + + return result; +} + +export function getFront( + result: ExpressionResult +): ObjectFront | LongStringFront | null { + return isFront(result) ? result : null; +} diff --git a/devtools/client/debugger/src/utils/expressions.js b/devtools/client/debugger/src/utils/expressions.js new file mode 100644 index 0000000000..bfc5a0a9e0 --- /dev/null +++ b/devtools/client/debugger/src/utils/expressions.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { correctIndentation } from "./indentation"; +import { getGrip } from "./evaluation-result"; +import type { Expression } from "../types"; +import type { Grip } from "../client/firefox/types"; + +const UNAVAILABLE_GRIP = { unavailable: true }; + +/* + * wrap the expression input in a try/catch so that it can be safely + * evaluated. + * + * NOTE: we add line after the expression to protect against comments. + */ +export function wrapExpression(input: string): string { + return correctIndentation(` + try { + ${input} + } catch (e) { + e + } + `); +} + +function isUnavailable(value): boolean { + return ( + value && + !!value.isError && + (value.class === "ReferenceError" || value.class === "TypeError") + ); +} + +export function getValue( + expression: Expression +): Grip | string | number | null | Object { + const { value, exception, error } = expression; + + if (error) { + return error; + } + + if (!value) { + return UNAVAILABLE_GRIP; + } + + if (exception) { + if (isUnavailable(exception)) { + return UNAVAILABLE_GRIP; + } + return exception; + } + + const valueGrip = getGrip(value.result); + + if (valueGrip && typeof valueGrip === "object" && valueGrip.isError) { + if (isUnavailable(valueGrip)) { + return UNAVAILABLE_GRIP; + } + + const { name, message } = valueGrip.preview; + return `${name}: ${message}`; + } + + return valueGrip; +} diff --git a/devtools/client/debugger/src/utils/function.js b/devtools/client/debugger/src/utils/function.js new file mode 100644 index 0000000000..a32bd95fac --- /dev/null +++ b/devtools/client/debugger/src/utils/function.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { isFulfilled } from "./async-value"; +import { findClosestFunction } from "./ast"; +import { correctIndentation } from "./indentation"; +import type { SourceWithContent } from "../types"; +import type { Symbols } from "../reducers/ast"; + +export function findFunctionText( + line: number, + source: SourceWithContent, + symbols: ?Symbols +): ?string { + const func = findClosestFunction(symbols, { + sourceId: source.id, + line, + column: Infinity, + }); + + if ( + source.isWasm || + !func || + !source.content || + !isFulfilled(source.content) || + source.content.value.type !== "text" + ) { + return null; + } + + const { + location: { start, end }, + } = func; + const lines = source.content.value.value.split("\n"); + const firstLine = lines[start.line - 1].slice(start.column); + const lastLine = lines[end.line - 1].slice(0, end.column); + const middle = lines.slice(start.line, end.line - 1); + const functionText = [firstLine, ...middle, lastLine].join("\n"); + const indentedFunctionText = correctIndentation(functionText); + + return indentedFunctionText; +} diff --git a/devtools/client/debugger/src/utils/indentation.js b/devtools/client/debugger/src/utils/indentation.js new file mode 100644 index 0000000000..5a773c28bb --- /dev/null +++ b/devtools/client/debugger/src/utils/indentation.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function getIndentation(line: ?string): number { + if (!line) { + return 0; + } + + const lineMatch = line.match(/^\s*/); + if (!lineMatch) { + return 0; + } + + return lineMatch[0].length; +} + +function getMaxIndentation(lines: string[]): number { + const firstLine = lines[0]; + const secondLine = lines[1]; + const lastLine = lines[lines.length - 1]; + + const indentations = [ + getIndentation(firstLine), + getIndentation(secondLine), + getIndentation(lastLine), + ]; + + return Math.max(...indentations); +} + +export function correctIndentation(text: string): string { + const lines = text.trim().split("\n"); + const indentation = getMaxIndentation(lines); + const formattedLines = lines.map(_line => + _line.replace(new RegExp(`^\\s{0,${indentation - 1}}`), "") + ); + + return formattedLines.join("\n"); +} diff --git a/devtools/client/debugger/src/utils/isMinified.js b/devtools/client/debugger/src/utils/isMinified.js new file mode 100644 index 0000000000..1a80bdea6a --- /dev/null +++ b/devtools/client/debugger/src/utils/isMinified.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { SourceWithContent } from "../types"; +import { isFulfilled } from "./async-value"; + +// Used to detect minification for automatic pretty printing +const SAMPLE_SIZE = 50; +const INDENT_COUNT_THRESHOLD = 5; +const CHARACTER_LIMIT = 250; +const _minifiedCache = new Map(); + +export function isMinified(source: SourceWithContent) { + if (_minifiedCache.has(source.id)) { + return _minifiedCache.get(source.id); + } + + if ( + !source.content || + !isFulfilled(source.content) || + source.content.value.type !== "text" + ) { + return false; + } + + let text = source.content.value.value; + + let lineEndIndex = 0; + let lineStartIndex = 0; + let lines = 0; + let indentCount = 0; + let overCharLimit = false; + + // Strip comments. + text = text.replace(/\/\*[\S\s]*?\*\/|\/\/(.+|\n)/g, ""); + + while (lines++ < SAMPLE_SIZE) { + lineEndIndex = text.indexOf("\n", lineStartIndex); + if (lineEndIndex == -1) { + break; + } + if (/^\s+/.test(text.slice(lineStartIndex, lineEndIndex))) { + indentCount++; + } + // For files with no indents but are not minified. + if (lineEndIndex - lineStartIndex > CHARACTER_LIMIT) { + overCharLimit = true; + break; + } + lineStartIndex = lineEndIndex + 1; + } + + const minified = + (indentCount / lines) * 100 < INDENT_COUNT_THRESHOLD || overCharLimit; + + _minifiedCache.set(source.id, minified); + return minified; +} diff --git a/devtools/client/debugger/src/utils/location.js b/devtools/client/debugger/src/utils/location.js new file mode 100644 index 0000000000..bcc1b7acb2 --- /dev/null +++ b/devtools/client/debugger/src/utils/location.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { sortBy } from "lodash"; +import { getSelectedLocation } from "./selected-location"; + +import type { + MappedLocation, + PartialPosition, + SourceLocation, + SourceId, + Source, +} from "../types"; + +type IncompleteLocation = { + sourceId: SourceId, + line?: number, + column?: number, + sourceUrl?: string, +}; + +export function comparePosition(a: ?PartialPosition, b: ?PartialPosition) { + return a && b && a.line == b.line && a.column == b.column; +} + +export function createLocation({ + sourceId, + // Line 0 represents no specific line chosen for action + line = 0, + column, + sourceUrl = "", +}: IncompleteLocation): SourceLocation { + return { + sourceId, + line, + column, + sourceUrl, + }; +} + +export function sortSelectedLocations<T: MappedLocation>( + locations: $ReadOnlyArray<T>, + selectedSource: ?Source +): Array<T> { + return sortBy(locations, [ + // Priority: line number, undefined column, column number + location => getSelectedLocation(location, selectedSource).line, + location => { + const selectedLocation = getSelectedLocation(location, selectedSource); + return selectedLocation.column === undefined || selectedLocation.column; + }, + ]); +} diff --git a/devtools/client/debugger/src/utils/log.js b/devtools/client/debugger/src/utils/log.js new file mode 100644 index 0000000000..4aca6cc357 --- /dev/null +++ b/devtools/client/debugger/src/utils/log.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* @flow */ + +/** + * + * Utils for logging to the console + * Suppresses logging in non-development environment + * + * @module utils/log + */ + +import { prefs } from "./prefs"; + +/** + * Produces a formatted console log line by imploding args, prefixed by [log] + * + * function input: log(["hello", "world"]) + * console output: [log] hello world + * + * @memberof utils/log + * @static + */ +export function log(...args: any[]): void { + if (prefs.logging) { + console.log(...args); + } +} diff --git a/devtools/client/debugger/src/utils/memoizableAction.js b/devtools/client/debugger/src/utils/memoizableAction.js new file mode 100644 index 0000000000..1ec909d9e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/memoizableAction.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ThunkArgs } from "../actions/types"; +import { asSettled, type AsyncValue } from "./async-value"; +import { validateContext } from "./context"; +import type { Context } from "./context"; + +type ArgsWithContext = { cx: Context }; + +export type MemoizedAction< + Args, + Result +> = Args => ThunkArgs => Promise<Result | null>; + +type MemoizableActionParams<Args, Result> = { + getValue: (args: Args, thunkArgs: ThunkArgs) => AsyncValue<Result> | null, + createKey: (args: Args, thunkArgs: ThunkArgs) => string, + action: (args: Args, thunkArgs: ThunkArgs) => Promise<mixed>, +}; + +/* + * memoizableActon is a utility for actions that should only be performed + * once per key. It is useful for loading sources, parsing symbols ... + * + * @getValue - gets the result from the redux store + * @createKey - creates a key for the requests map + * @action - kicks off the async work for the action + * + * + * For Example + * + * export const setItem = memoizeableAction( + * "setItem", + * { + * hasValue: ({ a }, { getState }) => hasItem(getState(), a), + * getValue: ({ a }, { getState }) => getItem(getState(), a), + * createKey: ({ a }) => a, + * action: ({ a }, thunkArgs) => doSetItem(a, thunkArgs) + * } + * ); + * + */ +export function memoizeableAction<Args: ArgsWithContext, Result>( + name: string, + { getValue, createKey, action }: MemoizableActionParams<Args, Result> +): MemoizedAction<Args, Result> { + const requests = new Map(); + return args => async thunkArgs => { + let result = asSettled(getValue(args, thunkArgs)); + if (!result) { + const key = createKey(args, thunkArgs); + if (!requests.has(key)) { + requests.set( + key, + (async () => { + try { + await action(args, thunkArgs); + } catch (e) { + console.warn(`Action ${name} had an exception:`, e); + } finally { + requests.delete(key); + } + })() + ); + } + + await requests.get(key); + + if (args.cx) { + validateContext(thunkArgs.getState(), args.cx); + } + + result = asSettled(getValue(args, thunkArgs)); + if (!result) { + // Returning null here is not ideal. This means that the action + // resolved but 'getValue' didn't return a loaded value, for instance + // if the data the action was meant to store was deleted. In a perfect + // world we'd throw a ContextError here or handle cancellation somehow. + // Throwing will also allow us to change the return type on the action + // to always return a promise for the getValue AsyncValue type, but + // for now we have to add an additional '| null' for this case. + return null; + } + } + + if (result.state === "rejected") { + throw result.value; + } + return result.value; + }; +} diff --git a/devtools/client/debugger/src/utils/memoize.js b/devtools/client/debugger/src/utils/memoize.js new file mode 100644 index 0000000000..434a2894b5 --- /dev/null +++ b/devtools/client/debugger/src/utils/memoize.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +type Value = any; +type Key = any; +type Store = WeakMap<Key, Store | Value>; + +function hasValue(keys: Key[], store: Store): boolean { + let currentStore = store; + for (const key of keys) { + if (!currentStore || !currentStore.has(key)) { + return false; + } + + currentStore = currentStore.get(key); + } + return true; +} + +function getValue(keys: Key[], store: Store): Value { + let currentStore = store; + for (const key of keys) { + if (!currentStore) { + return null; + } + currentStore = currentStore.get(key); + } + + return currentStore; +} + +function setValue(keys: Key[], store: Store, value: Value): void { + const keysExceptLast = keys.slice(0, -1); + const lastKey = keys[keys.length - 1]; + + let currentStore = store; + for (const key of keysExceptLast) { + if (!currentStore) { + return; + } + + if (!currentStore.has(key)) { + currentStore.set(key, new WeakMap()); + } + currentStore = currentStore.get(key); + } + + if (currentStore) { + currentStore.set(lastKey, value); + } +} + +// memoize with n arguments +export default function memoize(func: Function): Value { + const store = new WeakMap(); + + return function(...keys: Key[]) { + if (hasValue(keys, store)) { + return getValue(keys, store); + } + + const newValue = func.apply(null, keys); + setValue(keys, store, newValue); + return newValue; + }; +} diff --git a/devtools/client/debugger/src/utils/memoizeLast.js b/devtools/client/debugger/src/utils/memoizeLast.js new file mode 100644 index 0000000000..c0d9241932 --- /dev/null +++ b/devtools/client/debugger/src/utils/memoizeLast.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function memoizeLast(fn) { + let lastArgs: any[]; + let lastResult: any; + + const memoized = (...args) => { + if ( + lastArgs && + args.length === lastArgs.length && + args.every((arg, i) => arg === lastArgs[i]) + ) { + return lastResult; + } + + lastArgs = args; + lastResult = fn(...args); + + return lastResult; + }; + + return memoized; +} + +export default memoizeLast; diff --git a/devtools/client/debugger/src/utils/moz.build b/devtools/client/debugger/src/utils/moz.build new file mode 100644 index 0000000000..a13d942073 --- /dev/null +++ b/devtools/client/debugger/src/utils/moz.build @@ -0,0 +1,57 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "breakpoint", + "editor", + "pause", + "resource", + "sources-tree", +] + +CompiledModules( + "assert.js", + "ast.js", + "async-value.js", + "bootstrap.js", + "build-query.js", + "clipboard.js", + "connect.js", + "context.js", + "dbg.js", + "defer.js", + "DevToolsUtils.js", + "environment.js", + "expressions.js", + "evaluation-result.js", + "function.js", + "indentation.js", + "isMinified.js", + "location.js", + "log.js", + "memoize.js", + "memoizeLast.js", + "memoizableAction.js", + "path.js", + "prefs.js", + "preview.js", + "project-search.js", + "quick-open.js", + "result-list.js", + "selected-location.js", + "source-maps.js", + "source-queue.js", + "source.js", + "tabs.js", + "task.js", + "telemetry.js", + "text.js", + "timings.js", + "ui.js", + "url.js", + "utils.js", + "wasm.js", + "worker.js", +) diff --git a/devtools/client/debugger/src/utils/path.js b/devtools/client/debugger/src/utils/path.js new file mode 100644 index 0000000000..dc074dd364 --- /dev/null +++ b/devtools/client/debugger/src/utils/path.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function basename(path: string): string { + return path.split("/").pop(); +} + +export function dirname(path: string): string { + const idx = path.lastIndexOf("/"); + return path.slice(0, idx); +} + +export function isURL(str: string): boolean { + return str.includes("://"); +} + +export function isAbsolute(str: string): boolean { + return str[0] === "/"; +} + +export function join(base: string, dir: string): string { + return `${base}/${dir}`; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js new file mode 100644 index 0000000000..bd4114c5ab --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { flatMap, zip, range } from "lodash"; + +import type { Frame } from "../../../types"; +import { getFrameUrl } from "./getFrameUrl"; +import { getLibraryFromUrl } from "./getLibraryFromUrl"; + +type AnnotatedFrame = + | {| + ...Frame, + library: string, + |} + | Frame; + +export function annotateFrames(frames: Frame[]): AnnotatedFrame[] { + const annotatedFrames = frames.map(f => annotateFrame(f, frames)); + return annotateBabelAsyncFrames(annotatedFrames); +} + +function annotateFrame(frame: Frame, frames: Frame[]): AnnotatedFrame { + const library = getLibraryFromUrl(frame, frames); + if (library) { + return { ...frame, library }; + } + + return frame; +} + +function annotateBabelAsyncFrames(frames: Frame[]): Frame[] { + const babelFrameIndexes = getBabelFrameIndexes(frames); + const isBabelFrame = frameIndex => babelFrameIndexes.includes(frameIndex); + + return frames.map((frame, frameIndex) => + isBabelFrame(frameIndex) ? { ...frame, library: "Babel" } : frame + ); +} + +// Receives an array of frames and looks for babel async +// call stack groups. +function getBabelFrameIndexes(frames) { + const startIndexes = frames.reduce((accumulator, frame, index) => { + if ( + getFrameUrl(frame).match(/regenerator-runtime/i) && + frame.displayName === "tryCatch" + ) { + return [...accumulator, index]; + } + return accumulator; + }, []); + + const endIndexes = frames.reduce((accumulator, frame, index) => { + if ( + getFrameUrl(frame).match(/_microtask/i) && + frame.displayName === "flush" + ) { + return [...accumulator, index]; + } + if (frame.displayName === "_asyncToGenerator/<") { + return [...accumulator, index + 1]; + } + return accumulator; + }, []); + + if (startIndexes.length != endIndexes.length || startIndexes.length === 0) { + return frames; + } + + // Receives an array of start and end index tuples and returns + // an array of async call stack index ranges. + // e.g. [[1,3], [5,7]] => [[1,2,3], [5,6,7]] + // $FlowIgnore + return flatMap(zip(startIndexes, endIndexes), ([startIndex, endIndex]) => + range(startIndex, endIndex + 1) + ); +} diff --git a/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js new file mode 100644 index 0000000000..d39a26d574 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { findIndex } from "lodash"; + +// eslint-disable-next-line max-len +import type { Frame } from "../../../types"; +import { getFrameUrl } from "./getFrameUrl"; + +function collapseLastFrames(frames) { + const index = findIndex(frames, frame => + getFrameUrl(frame).match(/webpack\/bootstrap/i) + ); + + if (index == -1) { + return { newFrames: frames, lastGroup: [] }; + } + + const newFrames = frames.slice(0, index); + const lastGroup = frames.slice(index); + return { newFrames, lastGroup }; +} + +type FrameGroup = Frame[]; +type GroupedFrames = Array<FrameGroup | Frame>; + +export function collapseFrames(frames: Frame[]): GroupedFrames { + // We collapse groups of one so that user frames + // are not in a group of one + function addGroupToList(group, list) { + if (!group) { + return list; + } + + if (group.length > 1) { + list.push(group); + } else { + list = list.concat(group); + } + + return list; + } + const { newFrames, lastGroup } = collapseLastFrames(frames); + frames = newFrames; + let items = []; + let currentGroup = null; + let prevItem = null; + for (const frame of frames) { + const prevLibrary = prevItem?.library; + + if (!currentGroup) { + currentGroup = [frame]; + } else if (prevLibrary && prevLibrary == frame.library) { + currentGroup.push(frame); + } else { + items = addGroupToList(currentGroup, items); + currentGroup = [frame]; + } + + prevItem = frame; + } + + items = addGroupToList(currentGroup, items); + items = addGroupToList(lastGroup, items); + return items; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/displayName.js b/devtools/client/debugger/src/utils/pause/frames/displayName.js new file mode 100644 index 0000000000..dbd53d53cd --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/displayName.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +// eslint-disable-next-line max-len +import type { Frame } from "../../../types"; + +// Decodes an anonymous naming scheme that +// spider monkey implements based on "Naming Anonymous JavaScript Functions" +// http://johnjbarton.github.io/nonymous/index.html +const objectProperty = /([\w\d\$]+)$/; +const arrayProperty = /\[(.*?)\]$/; +const functionProperty = /([\w\d]+)[\/\.<]*?$/; +const annonymousProperty = /([\w\d]+)\(\^\)$/; + +export function simplifyDisplayName(displayName: string | void): string | void { + // if the display name has a space it has already been mapped + if (!displayName || /\s/.exec(displayName)) { + return displayName; + } + + const scenarios = [ + objectProperty, + arrayProperty, + functionProperty, + annonymousProperty, + ]; + + for (const reg of scenarios) { + const match = reg.exec(displayName); + if (match) { + return match[1]; + } + } + + return displayName; +} + +const displayNameMap = { + Babel: { + tryCatch: "Async", + }, + Backbone: { + "extend/child": "Create Class", + ".create": "Create Model", + }, + jQuery: { + "jQuery.event.dispatch": "Dispatch Event", + }, + React: { + // eslint-disable-next-line max-len + "ReactCompositeComponent._renderValidatedComponentWithoutOwnerOrContext/renderedElement<": + "Render", + _renderValidatedComponentWithoutOwnerOrContext: "Render", + }, + VueJS: { + "renderMixin/Vue.prototype._render": "Render", + }, + Webpack: { + // eslint-disable-next-line camelcase + __webpack_require__: "Bootstrap", + }, +}; + +function mapDisplayNames(frame, library) { + const { displayName } = frame; + return displayNameMap[library]?.[displayName] || displayName; +} + +function getFrameDisplayName(frame: Frame): string { + const { + displayName, + originalDisplayName, + userDisplayName, + name, + } = (frame: any); + return originalDisplayName || userDisplayName || displayName || name; +} + +type formatDisplayNameParams = { + shouldMapDisplayName: boolean, +}; +export function formatDisplayName( + frame: Frame, + { shouldMapDisplayName = true }: formatDisplayNameParams = {}, + l10n: typeof L10N +): string { + const { library } = frame; + let displayName = getFrameDisplayName(frame); + if (library && shouldMapDisplayName) { + displayName = mapDisplayNames(frame, library); + } + + return simplifyDisplayName(displayName) || l10n.getStr("anonymousFunction"); +} + +export function formatCopyName(frame: Frame, l10n: typeof L10N): string { + const displayName = formatDisplayName(frame, undefined, l10n); + if (!frame.source) { + throw new Error("no frame source"); + } + const fileName = frame.source.url || frame.source.id; + const frameLocation = frame.location.line; + + return `${displayName} (${fileName}#${frameLocation})`; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js new file mode 100644 index 0000000000..6a151e14f7 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Frame } from "../../../types"; + +export function getFrameUrl(frame: Frame) { + return frame?.source?.url ?? ""; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js new file mode 100644 index 0000000000..232079e3ec --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Frame } from "../../../types"; +import { getFrameUrl } from "./getFrameUrl"; + +const libraryMap = [ + { + label: "Backbone", + pattern: /backbone/i, + }, + { + label: "Babel", + pattern: /node_modules\/@babel/i, + }, + { + label: "jQuery", + pattern: /jquery/i, + }, + { + label: "Preact", + pattern: /preact/i, + }, + { + label: "React", + pattern: /(node_modules\/(?:react(-dom)?(-dev)?\/))|(react(-dom)?(-dev)?(\.[a-z]+)*\.js$)/, + }, + { + label: "Immutable", + pattern: /immutable/i, + }, + { + label: "Webpack", + pattern: /webpack\/bootstrap/i, + }, + { + label: "Express", + pattern: /node_modules\/express/, + }, + { + label: "Pug", + pattern: /node_modules\/pug/, + }, + { + label: "ExtJS", + pattern: /\/ext-all[\.\-]/, + }, + { + label: "MobX", + pattern: /mobx/i, + }, + { + label: "Underscore", + pattern: /underscore/i, + }, + { + label: "Lodash", + pattern: /lodash/i, + }, + { + label: "Ember", + pattern: /ember/i, + }, + { + label: "Choo", + pattern: /choo/i, + }, + { + label: "VueJS", + pattern: /vue(?:\.[a-z]+)*\.js/i, + }, + { + label: "RxJS", + pattern: /rxjs/i, + }, + { + label: "Angular", + pattern: /angular(?!.*\/app\/)/i, + contextPattern: /zone\.js/, + }, + { + label: "Redux", + pattern: /redux/i, + }, + { + label: "Dojo", + pattern: /dojo/i, + }, + { + label: "Marko", + pattern: /marko/i, + }, + { + label: "NuxtJS", + pattern: /[\._]nuxt/i, + }, + { + label: "Aframe", + pattern: /aframe/i, + }, + { + label: "NextJS", + pattern: /[\._]next/i, + }, +]; + +export function getLibraryFromUrl( + frame: Frame, + callStack: Array<Frame> = [] +): ?string | void { + // @TODO each of these fns calls getFrameUrl, just call it once + // (assuming there's not more complex logic to identify a lib) + const frameUrl = getFrameUrl(frame); + + // Let's first check if the frame match a defined pattern. + let match = libraryMap.find(o => o.pattern.test(frameUrl)); + if (match) { + return match.label; + } + + // If it does not, it might still be one of the case where the file is used + // by a library but the name has not enough specificity. In such case, we want + // to only return the library name if there are frames matching the library + // pattern in the callStack (e.g. `zone.js` is used by Angular, but the name + // could be quite common and return false positive if evaluated alone. So we + // only return Angular if there are other frames matching Angular). + match = libraryMap.find( + o => o.contextPattern && o.contextPattern.test(frameUrl) + ); + if (match) { + const contextMatch = callStack.some(f => { + const url = getFrameUrl(f); + if (!url) { + return false; + } + + return libraryMap.some(o => o.pattern.test(url)); + }); + + if (contextMatch) { + return match.label; + } + } + + return null; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/index.js b/devtools/client/debugger/src/utils/pause/frames/index.js new file mode 100644 index 0000000000..68d214162c --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/index.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export * from "./annotateFrames"; +export * from "./collapseFrames"; +export * from "./displayName"; +export * from "./getFrameUrl"; +export * from "./getLibraryFromUrl"; diff --git a/devtools/client/debugger/src/utils/pause/frames/moz.build b/devtools/client/debugger/src/utils/pause/frames/moz.build new file mode 100644 index 0000000000..5bb330a57f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "annotateFrames.js", + "collapseFrames.js", + "displayName.js", + "getFrameUrl.js", + "getLibraryFromUrl.js", + "index.js", +) diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap new file mode 100644 index 0000000000..89dbccd374 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collapseFrames default 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], +] +`; + +exports[`collapseFrames promises 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], + Object { + "asyncCause": "promise callback", + "displayName": "d", + "library": undefined, + }, + Array [ + Object { + "displayName": "e", + "library": "React", + }, + Object { + "displayName": "f", + "library": "React", + }, + ], + Object { + "asyncCause": null, + "displayName": "g", + "library": undefined, + }, +] +`; diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js new file mode 100644 index 0000000000..752f470e2f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { annotateFrames } from "../annotateFrames"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("annotateFrames", () => { + it("should return Angular", () => { + const callstack = [ + makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ), + makeMockFrameWithURL("/node_modules/zone/zone.js"), + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + const frames = annotateFrames(callstack); + expect(frames).toEqual(callstack.map(f => ({ ...f, library: "Angular" }))); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js new file mode 100644 index 0000000000..15210e0437 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { collapseFrames } from "../collapseFrames"; + +describe("collapseFrames", () => { + it("default", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + ]); + + expect(groups).toMatchSnapshot(); + }); + + it("promises", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + { + displayName: "d", + library: undefined, + asyncCause: "promise callback", + }, + { displayName: "e", library: "React" }, + { displayName: "f", library: "React" }, + { displayName: "g", library: undefined, asyncCause: null }, + ]); + + expect(groups).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js new file mode 100644 index 0000000000..a362885e1d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + formatCopyName, + formatDisplayName, + simplifyDisplayName, +} from "../displayName"; + +import { makeMockFrame, makeMockSource } from "../../../test-mockup"; + +describe("formatCopyName", () => { + it("simple", () => { + const source = makeMockSource("todo-view.js"); + const frame = makeMockFrame(undefined, source, undefined, 12, "child"); + + expect(formatCopyName(frame, L10N)).toEqual("child (todo-view.js#12)"); + }); +}); + +describe("formatting display names", () => { + it("uses a library description", () => { + const source = makeMockSource("assets/backbone.js"); + const frame = { + ...makeMockFrame(undefined, source, undefined, undefined, "extend/child"), + library: "Backbone", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("Create Class"); + }); + + it("shortens an anonymous function", () => { + const source = makeMockSource("assets/bar.js"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "extend/child/bar/baz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("baz"); + }); + + it("does not truncates long function names", () => { + const source = makeMockSource("extend/child/bar/baz"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual( + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + }); + + it("returns the original function name when present", () => { + const source = makeMockSource("entry.js"); + const frame = { + ...makeMockFrame(undefined, source), + originalDisplayName: "originalFn", + displayName: "fn", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("originalFn"); + }); + + it("returns anonymous when displayName is undefined", () => { + const frame = { ...makeMockFrame(), displayName: undefined }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is null", () => { + const frame = { ...makeMockFrame(), displayName: (null: any) }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is an empty string", () => { + const frame = { ...makeMockFrame(), displayName: "" }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); +}); + +describe("simplifying display names", () => { + const cases = { + defaultCase: [["define", "define"]], + + objectProperty: [ + ["z.foz", "foz"], + ["z.foz/baz", "baz"], + ["z.foz/baz/y.bay", "bay"], + ["outer/x.fox.bax.nx", "nx"], + ["outer/fow.baw", "baw"], + ["fromYUI._attach", "_attach"], + ["Y.ClassNameManager</getClassName", "getClassName"], + ["orion.textview.TextView</addHandler", "addHandler"], + ["this.eventPool_.createObject", "createObject"], + ], + + arrayProperty: [ + ["this.eventPool_[createObject]", "createObject"], + ["jQuery.each(^)/jQuery.fn[o]", "o"], + ["viewport[get+D]", "get+D"], + ["arr[0]", "0"], + ], + + functionProperty: [ + ["fromYUI._attach/<.", "_attach"], + ["Y.ClassNameManager<", "ClassNameManager"], + ["fromExtJS.setVisible/cb<", "cb"], + ["fromDojo.registerWin/<", "registerWin"], + ], + + annonymousProperty: [["jQuery.each(^)", "each"]], + }; + + Object.keys(cases).forEach(type => { + cases[type].forEach(([kase, expected]) => { + it(`${type} - ${kase}`, () => + expect(simplifyDisplayName(kase)).toEqual(expected)); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js new file mode 100644 index 0000000000..afb8d7afc2 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getLibraryFromUrl } from "../getLibraryFromUrl"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("getLibraryFromUrl", () => { + describe("When Preact is on the frame", () => { + it("should return Preact and not React", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/preact/8.2.5/preact.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Preact"); + }); + }); + + describe("When Vue is on the frame", () => { + it("should return VueJS for different builds", () => { + const buildTypeList = [ + "vue.js", + "vue.common.js", + "vue.esm.js", + "vue.runtime.js", + "vue.runtime.common.js", + "vue.runtime.esm.js", + "vue.min.js", + "vue.runtime.min.js", + ]; + + buildTypeList.forEach(buildType => { + const frame = makeMockFrameWithURL( + `https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/${buildType}` + ); + expect(getLibraryFromUrl(frame)).toEqual("VueJS"); + }); + }); + }); + + describe("When React is in the URL", () => { + it("should not return React if it is not part of the filename", () => { + const notReactUrlList = [ + "https://react.js.com/test.js", + "https://debugger-example.com/test.js", + "https://debugger-react-example.com/test.js", + "https://debugger-react-example.com/react/test.js", + "https://debugger-example.com/react-contextmenu.js", + ]; + notReactUrlList.forEach(notReactUrl => { + const frame = makeMockFrameWithURL(notReactUrl); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + it("should return React if it is part of the filename", () => { + const reactUrlList = [ + "https://debugger-example.com/react.js", + "https://debugger-example.com/react.development.js", + "https://debugger-example.com/react.production.min.js", + "https://debugger-react-example.com/react.js", + "https://debugger-react-example.com/react/react.js", + "https://debugger-example.com/react-dom.js", + "https://debugger-example.com/react-dom.development.js", + "https://debugger-example.com/react-dom.production.min.js", + "https://debugger-react-example.com/react-dom.js", + "https://debugger-react-example.com/react/react-dom.js", + "https://debugger-react-example.com/react-dom-dev.js", + "/node_modules/react/test.js", + "/node_modules/react-dev/test.js", + "/node_modules/react-dom/test.js", + "/node_modules/react-dom-dev/test.js", + ]; + reactUrlList.forEach(reactUrl => { + const frame = makeMockFrameWithURL(reactUrl); + expect(getLibraryFromUrl(frame)).toEqual("React"); + }); + }); + }); + + describe("When Angular is in the URL", () => { + it("should return Angular for AngularJS (1.x)", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should return Angular for Angular (2.x)", () => { + const frame = makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should not return Angular for Angular components", () => { + const frame = makeMockFrameWithURL( + "https://firefox-devtools-angular-log.stackblitz.io/~/src/app/hello.component.ts" + ); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + + describe("When zone.js is on the frame", () => { + it("should not return Angular when no callstack", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + + it("should not return Angular when stack without Angular frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [frame]; + + expect(getLibraryFromUrl(frame, callstack)).toBeNull(); + }); + + it("should return Angular when stack with AngularJS (1.x) frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [ + frame, + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + + expect(getLibraryFromUrl(frame, callstack)).toEqual("Angular"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/index.js b/devtools/client/debugger/src/utils/pause/index.js new file mode 100644 index 0000000000..a2dfcb8e5a --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/index.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export * from "./why"; diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/README.md b/devtools/client/debugger/src/utils/pause/mapScopes/README.md new file mode 100644 index 0000000000..2f65b8e847 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/README.md @@ -0,0 +1,191 @@ +# mapScopes + +The files in this directory manage the Devtools logic for taking the original +JS file from the sourcemap and using the content, combined with source +mappings, to provide an enhanced user experience for debugging the code. + +In this document, we'll refer to the files as either: + +* `original` - The code that the user wrote originally. +* `generated` - The code actually executing in the engine, which may have been + transformed from the `original`, and if a bundler was used, may contain the + final output for several different `original` files. + +The enhancements implemented here break down into as two primary improvements: + +* Rendering the scopes as the user expects them, using the position in the + original file, rather than the content of the generated file. +* Allowing users to interact with the code in the console as if it were the + original code, rather than the generated file. + + +## Overall Approach + +The core goal of scope mapping is to parse the original and generated files, +and then infer a correlation between the bindings in the original file, +and the bindings in the generated file. This is correlation is done via the +source mappings provided alongside the original code in the sourcemap. + +The overall steps break down into: + + +### 1. Parsing + +First the generated and original files are parsed into ASTs, and then the ASTs +are each traversed in order to generate a full picture of the scopes that are +present in the file. This covers information for each binding in each scope, +including all metadata about the declarations themselves, and all references +to each of the bindings. The exact location of each binding reference is the +most important factor because these will be used when working with sourcemaps. + +Importantly, this scope tree's structure also mirrors the structure of the +scope data that the engine itself returns when paused. + + +### 2. Generated Binding -> Grip Correlation + +When the engine pauses, we get back a full description of the current scope, +as well as `grip` objects that can be used as proxy-like objects in order to +access the actual values in the engine itself. + +With this data, along with the location where the engine is paused, we can +use the AST-based scope information from step 1 to attach a line/column +range to each `grip`. Using those mappings we have enough information to get +the real engine value of any variable based on its position in the generated +code, as long as it is in scope at the paused location in the generated file. + +The generated and engine scopes are correlated depth-first, because in some +cases the scope data from the engine will be deeper that the data from the +parsed code, meaning there are additional unknown parent scopes. This can +happen, for instance, if the executing code is running inside of an `eval` +since the parser only knows about the scopes inside of `eval`, and cannot +know what context it is executed inside of. + + +### 3. Original Binding -> Generated Binding Correlation + +Now that we can get the value of a generated location, we need to decide which +generated location correlates with which original location. For each of +the original bindings in each original scope, we iterate through the +individual references to the binding in the file and: + +1. Use the sourcemap to convert the original location into a range on the + generated code. +2. Filter the available bindings in the generated file down to those that + overlap with this generated range. +3. Perform heuristics to decide if any of the bindings in the range appear + to be a valid and safe mapping. + +These steps allow us to build a datastructure that describes the scope +as it is declared in the original file, while rendering the value using the +`grip` objects, as returned from the engine itself. + +There is additional complexity here in the exact details of the heuristics +mentioned above. See the later Heuristics sections diving into these details. + +During this phase, one additional task is performed, which is the construction +of expressions that relate back to the original binding. After matching each +binding in a range, we know the name of the binding in the original scope, +and we _also_ know the name of the generated binding, or more generally the +expression to evaluate in order to get the value of the original binding. + +These expression values are important for ensuring that the developer console +is able to allow users to interact with the original code. If a user types +a variable into the console, it can be translated into the expression +correlated with that original variable, allowing the code to behave as it +would have, were it actually part of the original file. + + +### 4. Generate a Scope Tree + +The structure generated in step 3 is converted into a structure compatible +with the standard scope data returned from the engine itself in order to allow +for consistent usage of scope data, irrespective of the scope data source. +This stage also re-attaches the global scope data, which is otherwise ignored +during the correlation process. + + +### 5. Validation + +As a simple form of validation, we ensure that a large percentage of bindings +were actually resolved to a `grip` object. This offers some amount of +protection, if the sourcemap itself turned out to be somewhat low-quality. + +This happens often with tooling that generates maps that simply map an original +line to a generated line. While line-to-line mappings still enable step +debugging, they do not provide enough information for original-scope mapping. + +On the other hand, generally line-to-line mappings are usually generated by +tooling that performs minimal transformations on their own, so it is often +acceptable to fall back to the engine-only generated-file scope data in this +case. + + +## Range Mapping Heuristics + +Since we know lots of information about the original bindings when performing +matches, it is possible to make educated guesses about how their generated +code will have likely been transformed. This allows us to perform more +aggressive matching logic what would normally be too false-positive-heavy +for generic use, when it is deemed safe enough. + + +### Standard Matching + +In the general case, we iterate through the bindings that were found in the +generated range, and consider it a match if the binding overlaps with the +start of the range. One extra case here is that ranges at the start of +a line often point to the first binding in a range and do not overlap the +first binding, so there is special-casing for the first binding in each range. + + +### ES6 Import Reference Special Case + +References to ES6 imports are often transformed into property accesses on objects +in order to emulate the "live-binding" behavior defined by ES6. The standard +logic here would end up always resolving the imported value to be the import +namespace object itself, rather than reading the value of the property. + +To support this, specifically for ES6 imports, we walk outward from the matched +binding itself, taking property accesses into account, as long as the +property access itself is also within the mapped range. + +While decently effective, there are currently two downsides to this approach: + +* The "live" behavior of imports is often implemented using accessor + properties, which as of the time of this writing, cannot be evaluated to + retrieve their real value. +* The "live" behavior of imports is sometimes implemented with function calls, + which also also cannot be evaluated, causing their value to be + unknown. + + +### ES6 Import Declaration Special Case + +If there are no references to an imported value, or matching based on the +reference failed, we fall back to a second case. + +ES6 import declarations themselves often map back to the location of the +declaration of the imported module's namespace object. By getting the range for +the import declaration itself, we can infer which generated binding is the +namespace object. Alongside that, we already know the name of the property on +the namespace itself because it is statically knowable by analyzing the +import declaration. + +By combining both of those pieces of information, we can access the namespace's +property to get the imported value. + + +### Typescript Classes + +Typescript have several non-ideal ways in which they output source maps, most +of which center around classes that are either exported, or decorated. These +issues are currently tracked in [Typescript issue #22833][1]. + +To work around this issue, we use a method similar to the import declaration +case above. While the class name itself often maps to unhelpful locations, +the class declaration itself generally maps to the class's transformed binding, +so we make use of the class declaration location instead of the location of +the class's declared name in these cases. + + [1]: https://github.com/Microsoft/TypeScript/issues/22833 diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js new file mode 100644 index 0000000000..d8038c931b --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { has } from "lodash"; +import type { SourceScope, BindingLocation } from "../../../workers/parser"; +import type { Scope, BindingContents } from "../../../types"; +import { clientCommands } from "../../../client/firefox"; + +import { locColumn } from "./locColumn"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +export type GeneratedBindingLocation = { + name: string, + loc: BindingLocation, + desc: () => Promise<BindingContents | null>, +}; + +export function buildGeneratedBindingList( + scopes: Scope, + generatedAstScopes: SourceScope[], + thisBinding: ?BindingContents +): Array<GeneratedBindingLocation> { + // The server's binding data doesn't include general 'this' binding + // information, so we manually inject the one 'this' binding we have into + // the normal binding data we are working with. + const frameThisOwner = generatedAstScopes.find( + generated => "this" in generated.bindings + ); + + let globalScope = null; + const clientScopes = []; + for (let s = scopes; s; s = s.parent) { + const bindings = s.bindings + ? Object.assign({}, ...s.bindings.arguments, s.bindings.variables) + : {}; + + clientScopes.push(bindings); + globalScope = s; + } + + const generatedMainScopes = generatedAstScopes.slice(0, -2); + const generatedGlobalScopes = generatedAstScopes.slice(-2); + + const clientMainScopes = clientScopes.slice(0, generatedMainScopes.length); + const clientGlobalScopes = clientScopes.slice(generatedMainScopes.length); + + // Map the main parsed script body using the nesting hierarchy of the + // generated and client scopes. + const generatedBindings = generatedMainScopes.reduce((acc, generated, i) => { + const bindings = clientMainScopes[i]; + + if (generated === frameThisOwner && thisBinding) { + bindings.this = { + value: thisBinding, + }; + } + + for (const name of Object.keys(generated.bindings)) { + // If there is no 'this' value, we exclude the binding entirely. + // Otherwise it would pass through as found, but "(unscoped)", causing + // the search logic to stop with a match. + if (name === "this" && !bindings[name]) { + continue; + } + + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name] || null), + }); + } + } + return acc; + }, []); + + // Bindings in the global/lexical global of the generated code may or + // may not be the real global if the generated code is running inside + // of an evaled context. To handle this, we just look up the client scope + // hierarchy to find the closest binding with that name. + for (const generated of generatedGlobalScopes) { + for (const name of Object.keys(generated.bindings)) { + const { refs } = generated.bindings[name]; + const bindings = clientGlobalScopes.find(b => has(b, name)); + + for (const loc of refs) { + if (bindings) { + generatedBindings.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name]), + }); + } else { + const globalGrip = globalScope?.object; + if (globalGrip) { + // Should always exist, just checking to keep Flow happy. + + generatedBindings.push({ + name, + loc, + desc: async () => { + const objectFront = clientCommands.createObjectFront( + globalGrip + ); + return (await objectFront.getProperty(name)).descriptor; + }, + }); + } + } + } + } + } + + // Sort so we can binary-search. + return sortBindings(generatedBindings); +} + +export function buildFakeBindingList( + generatedAstScopes: SourceScope[] +): Array<GeneratedBindingLocation> { + // TODO if possible, inject real bindings for the global scope + const generatedBindings = generatedAstScopes.reduce((acc, generated) => { + for (const name of Object.keys(generated.bindings)) { + if (name === "this") { + continue; + } + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(getOptimizedOutGrip()), + }); + } + } + return acc; + }, []); + return sortBindings(generatedBindings); +} + +function sortBindings(generatedBindings: Array<GeneratedBindingLocation>) { + return generatedBindings.sort((a, b) => { + const aStart = a.loc.start; + const bStart = b.loc.start; + + if (aStart.line === bStart.line) { + return locColumn(aStart) - locColumn(bStart); + } + return aStart.line - bStart.line; + }); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js new file mode 100644 index 0000000000..a5af8e5583 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +function findInsertionLocation<T>( + array: Array<T>, + callback: T => number +): number { + let left = 0; + let right = array.length; + while (left < right) { + const mid = Math.floor((left + right) / 2); + const item = array[mid]; + + const result = callback(item); + if (result === 0) { + left = mid; + break; + } + if (result >= 0) { + right = mid; + } else { + left = mid + 1; + } + } + + // Ensure the value is the start of any set of matches. + let i = left; + if (i < array.length) { + while (i >= 0 && callback(array[i]) >= 0) { + i--; + } + return i + 1; + } + + return i; +} + +export function filterSortedArray<T>( + array: Array<T>, + callback: T => number +): Array<T> { + const start = findInsertionLocation(array, callback); + + const results = []; + for (let i = start; i < array.length && callback(array[i]) === 0; i++) { + results.push(array[i]); + } + + return results; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js new file mode 100644 index 0000000000..fec33dd57d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js @@ -0,0 +1,329 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { locColumn } from "./locColumn"; +import { mappingContains } from "./mappingContains"; + +import type { BindingContents } from "../../../types"; +// eslint-disable-next-line max-len +import type { ApplicableBinding } from "./getApplicableBindingsForOriginalPosition"; + +import { clientCommands } from "../../../client/firefox"; + +export type GeneratedDescriptor = { + name: string, + // Falsy if the binding itself matched a location, but the location didn't + // have a value descriptor attached. Happens if the binding was 'this' + // or if there was a mismatch between client and generated scopes. + desc: ?BindingContents, + + expression: string, +}; + +/** + * Given a mapped range over the generated source, attempt to resolve a real + * binding descriptor that can be used to access the value. + */ +export async function findGeneratedReference( + applicableBindings: Array<ApplicableBinding> +): Promise<GeneratedDescriptor | null> { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 4) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapBindingReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + return null; +} + +export async function findGeneratedImportReference( + applicableBindings: Array<ApplicableBinding> +): Promise<GeneratedDescriptor | null> { + // When wrapped, for instance as `Object(ns.default)`, the `Object` binding + // will be the first in the list. To avoid resolving `Object` as the + // value of the import itself, we potentially skip the first binding. + applicableBindings = applicableBindings.filter((applicable, i) => { + if ( + !applicable.firstInRange || + applicable.binding.loc.type !== "ref" || + applicable.binding.loc.meta + ) { + return true; + } + + const next = + i + 1 < applicableBindings.length ? applicableBindings[i + 1] : null; + + return !next || next.binding.loc.type !== "ref" || !next.binding.loc.meta; + }); + + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 2) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapImportReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + + return null; +} + +/** + * Given a mapped range over the generated source and the name of the imported + * value that is referenced, attempt to resolve a binding descriptor for + * the import's value. + */ +export async function findGeneratedImportDeclaration( + applicableBindings: Array<ApplicableBinding>, + importName: string +): Promise<GeneratedDescriptor | null> { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 10) { + // Import declarations tend to have a large number of bindings for + // for things like 'require' and 'interop', so this number is larger + // than other binding count checks. + applicableBindings = []; + } + + let result = null; + + for (const { binding } of applicableBindings) { + if (binding.loc.type === "ref") { + continue; + } + + const namespaceDesc = await binding.desc(); + if (isPrimitiveValue(namespaceDesc)) { + continue; + } + if (!isObjectValue(namespaceDesc)) { + // We want to handle cases like + // + // var _mod = require(...); + // var _mod2 = _interopRequire(_mod); + // + // where "_mod" is optimized out because it is only referenced once. To + // allow that, we track the optimized-out value as a possible result, + // but allow later binding values to overwrite the result. + result = { + name: binding.name, + desc: namespaceDesc, + expression: binding.name, + }; + continue; + } + + const desc = await readDescriptorProperty(namespaceDesc, importName); + const expression = `${binding.name}.${importName}`; + + if (desc) { + result = { + name: binding.name, + desc, + expression, + }; + break; + } + } + + return result; +} + +/** + * Given a generated binding, and a range over the generated code, statically + * check if the given binding matches the range. + */ +async function mapBindingReferenceToDescriptor({ + binding, + range, + firstInRange, + firstOnLine, +}: ApplicableBinding): Promise<GeneratedDescriptor | null> { + // Allow the mapping to point anywhere within the generated binding + // location to allow for less than perfect sourcemaps. Since you also + // need at least one character between identifiers, we also give one + // characters of space at the front the generated binding in order + // to increase the probability of finding the right mapping. + if ( + range.start.line === binding.loc.start.line && + // If a binding is the first on a line, Babel will extend the mapping to + // include the whitespace between the newline and the binding. To handle + // that, we skip the range requirement for starting location. + (firstInRange || + firstOnLine || + locColumn(range.start) >= locColumn(binding.loc.start)) && + locColumn(range.start) <= locColumn(binding.loc.end) + ) { + return { + name: binding.name, + desc: await binding.desc(), + expression: binding.name, + }; + } + + return null; +} + +/** + * Given an generated binding, and a range over the generated code, statically + * evaluate accessed properties within the mapped range to resolve the actual + * imported value. + */ +async function mapImportReferenceToDescriptor({ + binding, + range, +}: ApplicableBinding): Promise<GeneratedDescriptor | null> { + if (binding.loc.type !== "ref") { + return null; + } + + // Expression matches require broader searching because sourcemaps usage + // varies in how they map certain things. For instance given + // + // import { bar } from "mod"; + // bar(); + // + // The "bar()" expression is generally expanded into one of two possibly + // forms, both of which map the "bar" identifier in different ways. See + // the "^^" markers below for the ranges. + // + // (0, foo.bar)() // Babel + // ^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // __webpack_require__.i(foo.bar)() // Webpack 2 + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // Object(foo.bar)() // Webpack >= 3 + // ^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // + // Unfortunately, Webpack also has a tendancy to over-map past the call + // expression to the start of the next line, at least when there isn't + // anything else on that line that is mapped, e.g. + // + // Object(foo.bar)() + // ^^^^^^^^^^^^^^^^^ + // ^ // wrapped to column 0 of next line + + if (!mappingContains(range, binding.loc)) { + return null; + } + + // Webpack 2's import declarations wrap calls with an identity fn, so we + // need to make sure to skip that binding because it is mapped to the + // location of the original binding usage. + if ( + binding.name === "__webpack_require__" && + binding.loc.meta && + binding.loc.meta.type === "member" && + binding.loc.meta.property === "i" + ) { + return null; + } + + let expression = binding.name; + let desc = await binding.desc(); + + if (binding.loc.type === "ref") { + const { meta } = binding.loc; + + // Limit to 2 simple property or inherits operartions, since it would + // just be more work to search more and it is very unlikely that + // bindings would be mapped to more than a single member + inherits + // wrapper. + for ( + let op = meta, index = 0; + op && mappingContains(range, op) && desc && index < 2; + index++, op = op?.parent + ) { + // Calling could potentially trigger side-effects, which would not + // be ideal for this case. + if (op.type === "call") { + return null; + } + + if (op.type === "inherit") { + continue; + } + + desc = await readDescriptorProperty(desc, op.property); + expression += `.${op.property}`; + } + } + + return desc + ? { + name: binding.name, + desc, + expression, + } + : null; +} + +function isPrimitiveValue(desc: ?BindingContents) { + return desc && (!desc.value || typeof desc.value !== "object"); +} +function isObjectValue(desc: ?BindingContents) { + return ( + desc && + !isPrimitiveValue(desc) && + desc.value.type === "object" && + // Note: The check for `.type` might already cover the optimizedOut case + // but not 100% sure, so just being cautious. + !desc.value.optimizedOut + ); +} + +async function readDescriptorProperty( + desc: ?BindingContents, + property: string +): Promise<?BindingContents> { + if (!desc) { + return null; + } + + if (typeof desc.value !== "object" || !desc.value) { + // If accessing a property on a primitive type, just return 'undefined' + // as the value. + return { + value: { + type: "undefined", + }, + }; + } + + if (!isObjectValue(desc)) { + // If we got a non-primitive descriptor but it isn't an object, then + // it's definitely not the namespace and it is probably an error. + return desc; + } + + const objectFront = clientCommands.createObjectFront(desc.value); + return (await objectFront.getProperty(property)).descriptor; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js new file mode 100644 index 0000000000..1542b6bfd3 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import typeof SourceMaps from "devtools-source-map"; + +import type { BindingLocationType, BindingType } from "../../../workers/parser"; +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; +import { mappingContains } from "./mappingContains"; + +import type { Source, SourceLocation, PartialPosition } from "../../../types"; + +import type { GeneratedBindingLocation } from "./buildGeneratedBindingList"; + +export type ApplicableBinding = { + binding: GeneratedBindingLocation, + range: GeneratedRange, + firstInRange: boolean, + firstOnLine: boolean, +}; + +type GeneratedRange = { + start: PartialPosition, + end: PartialPosition, +}; + +export async function originalRangeStartsInside( + source: Source, + { + start, + end, + }: { + start: SourceLocation, + end: SourceLocation, + }, + sourceMaps: SourceMaps +) { + const endPosition = await sourceMaps.getGeneratedLocation(end); + const startPosition = await sourceMaps.getGeneratedLocation(start); + + // If the start and end positions collapse into eachother, it means that + // the range in the original content didn't _start_ at the start position. + // Since this likely means that the range doesn't logically apply to this + // binding location, we skip it. + return positionCmp(startPosition, endPosition) !== 0; +} + +export async function getApplicableBindingsForOriginalPosition( + generatedAstBindings: Array<GeneratedBindingLocation>, + source: Source, + { + start, + end, + }: { + start: SourceLocation, + end: SourceLocation, + }, + bindingType: BindingType, + locationType: BindingLocationType, + sourceMaps: SourceMaps +): Promise<Array<ApplicableBinding>> { + const ranges = await sourceMaps.getGeneratedRanges(start); + + const resultRanges: GeneratedRange[] = ranges.map(mapRange => ({ + start: { + line: mapRange.line, + column: mapRange.columnStart, + }, + end: { + line: mapRange.line, + // SourceMapConsumer's 'lastColumn' is inclusive, so we add 1 to make + // it exclusive like all other locations. + column: mapRange.columnEnd + 1, + }, + })); + + // When searching for imports, we expand the range to up to the next available + // mapping to allow for import declarations that are composed of multiple + // variable statements, where the later ones are entirely unmapped. + // Babel 6 produces imports in this style, e.g. + // + // var _mod = require("mod"); // mapped from import statement + // var _mod2 = interop(_mod); // entirely unmapped + if (bindingType === "import" && locationType !== "ref") { + const endPosition = await sourceMaps.getGeneratedLocation(end); + const startPosition = await sourceMaps.getGeneratedLocation(start); + + for (const range of resultRanges) { + if ( + mappingContains(range, { start: startPosition, end: startPosition }) && + positionCmp(range.end, endPosition) < 0 + ) { + range.end = { + line: endPosition.line, + column: endPosition.column, + }; + break; + } + } + } + + return filterApplicableBindings(generatedAstBindings, resultRanges); +} + +function filterApplicableBindings( + bindings: Array<GeneratedBindingLocation>, + ranges: Array<GeneratedRange> +): Array<ApplicableBinding> { + const result = []; + for (const range of ranges) { + // Any binding overlapping a part of the mapping range. + const filteredBindings = filterSortedArray(bindings, binding => { + if (positionCmp(binding.loc.end, range.start) <= 0) { + return -1; + } + if (positionCmp(binding.loc.start, range.end) >= 0) { + return 1; + } + + return 0; + }); + + let firstInRange = true; + let firstOnLine = true; + let line = -1; + + for (const binding of filteredBindings) { + if (binding.loc.start.line === line) { + firstOnLine = false; + } else { + line = binding.loc.start.line; + firstOnLine = true; + } + + result.push({ + binding, + range, + firstOnLine, + firstInRange, + }); + + firstInRange = false; + } + } + + return result; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/index.js b/devtools/client/debugger/src/utils/pause/mapScopes/index.js new file mode 100644 index 0000000000..fa94d7b944 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js @@ -0,0 +1,589 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import typeof SourceMaps from "devtools-source-map"; + +import { + type SourceScope, + type BindingData, + type BindingLocation, +} from "../../../workers/parser"; +import type { RenderableScope } from "../scopes/getScope"; +import { locColumn } from "./locColumn"; +import { + loadRangeMetadata, + findMatchingRange, + type MappedOriginalRange, +} from "./rangeMetadata"; + +// eslint-disable-next-line max-len +import { + findGeneratedReference, + findGeneratedImportReference, + findGeneratedImportDeclaration, + type GeneratedDescriptor, +} from "./findGeneratedBindingFromPosition"; +import { + buildGeneratedBindingList, + buildFakeBindingList, + type GeneratedBindingLocation, +} from "./buildGeneratedBindingList"; +import { + originalRangeStartsInside, + getApplicableBindingsForOriginalPosition, +} from "./getApplicableBindingsForOriginalPosition"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +import { log } from "../../log"; +import type { ThunkArgs } from "../../../actions/types"; + +import type { + PartialPosition, + Scope, + Source, + SourceContent, + Frame, + BindingContents, + ScopeBindings, + MappedLocation, +} from "../../../types"; + +export type OriginalScope = RenderableScope; +export type MappedFrameLocation = MappedLocation & { + this?: $ElementType<Frame, "this">, +}; + +export async function buildMappedScopes( + source: Source, + content: SourceContent, + frame: MappedFrameLocation, + scopes: ?Scope, + { client, parser, sourceMaps }: ThunkArgs +): Promise<?{ + mappings: { + [string]: string, + }, + scope: OriginalScope, +}> { + const originalAstScopes = await parser.getScopes(frame.location); + const generatedAstScopes = await parser.getScopes(frame.generatedLocation); + + if (!originalAstScopes || !generatedAstScopes) { + return null; + } + + const originalRanges = await loadRangeMetadata( + frame.location, + originalAstScopes, + sourceMaps + ); + + if (hasLineMappings(originalRanges)) { + return null; + } + + let generatedAstBindings; + if (scopes) { + generatedAstBindings = buildGeneratedBindingList( + scopes, + generatedAstScopes, + frame.this + ); + } else { + generatedAstBindings = buildFakeBindingList(generatedAstScopes); + } + + const { + mappedOriginalScopes, + expressionLookup, + } = await mapOriginalBindingsToGenerated( + source, + content, + originalRanges, + originalAstScopes, + generatedAstBindings, + client, + sourceMaps + ); + + const globalLexicalScope = scopes + ? getGlobalFromScope(scopes) + : generateGlobalFromAst(generatedAstScopes); + const mappedGeneratedScopes = generateClientScope( + globalLexicalScope, + mappedOriginalScopes + ); + + return isReliableScope(mappedGeneratedScopes) + ? { mappings: expressionLookup, scope: mappedGeneratedScopes } + : null; +} + +async function mapOriginalBindingsToGenerated( + source: Source, + content: SourceContent, + originalRanges: Array<MappedOriginalRange>, + originalAstScopes, + generatedAstBindings, + client, + sourceMaps +) { + const expressionLookup = {}; + const mappedOriginalScopes = []; + + const cachedSourceMaps = batchScopeMappings( + originalAstScopes, + source, + sourceMaps + ); + + for (const item of originalAstScopes) { + const generatedBindings = {}; + + for (const name of Object.keys(item.bindings)) { + const binding = item.bindings[name]; + + const result = await findGeneratedBinding( + cachedSourceMaps, + client, + source, + content, + name, + binding, + originalRanges, + generatedAstBindings + ); + + if (result) { + generatedBindings[name] = result.grip; + + if ( + binding.refs.length !== 0 && + // These are assigned depth-first, so we don't want shadowed + // bindings in parent scopes overwriting the expression. + !Object.prototype.hasOwnProperty.call(expressionLookup, name) + ) { + expressionLookup[name] = result.expression; + } + } + } + + mappedOriginalScopes.push({ + ...item, + generatedBindings, + }); + } + + return { + mappedOriginalScopes, + expressionLookup, + }; +} + +/** + * Consider a scope and its parents reliable if the vast majority of its + * bindings were successfully mapped to generated scope bindings. + */ +function isReliableScope(scope: OriginalScope): boolean { + let totalBindings = 0; + let unknownBindings = 0; + + for (let s = scope; s; s = s.parent) { + const vars = s.bindings?.variables || {}; + for (const key of Object.keys(vars)) { + const binding = vars[key]; + + totalBindings += 1; + if ( + binding.value && + typeof binding.value === "object" && + (binding.value.type === "unscoped" || binding.value.type === "unmapped") + ) { + unknownBindings += 1; + } + } + } + + // As determined by fair dice roll. + return totalBindings === 0 || unknownBindings / totalBindings < 0.25; +} + +function hasLineMappings(ranges): boolean { + return ranges.every( + range => range.columnStart === 0 && range.columnEnd === Infinity + ); +} + +function batchScopeMappings( + originalAstScopes: Array<SourceScope>, + source: Source, + sourceMaps: SourceMaps +) { + const precalculatedRanges = new Map(); + const precalculatedLocations = new Map(); + + // Explicitly dispatch all of the sourcemap requests synchronously up front so + // that they will be batched into a single request for the worker to process. + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + const locs = [ref]; + if (ref.type !== "ref") { + locs.push(ref.declaration); + } + + for (const loc of locs) { + precalculatedRanges.set( + buildLocationKey(loc.start), + sourceMaps.getGeneratedRanges(loc.start) + ); + precalculatedLocations.set( + buildLocationKey(loc.start), + sourceMaps.getGeneratedLocation(loc.start) + ); + precalculatedLocations.set( + buildLocationKey(loc.end), + sourceMaps.getGeneratedLocation(loc.end) + ); + } + } + } + } + + return { + async getGeneratedRanges(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedRanges.has(key)) { + log("Bad precalculated mapping"); + return sourceMaps.getGeneratedRanges(pos); + } + return precalculatedRanges.get(key); + }, + async getGeneratedLocation(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedLocations.has(key)) { + log("Bad precalculated mapping"); + return sourceMaps.getGeneratedLocation(pos); + } + return precalculatedLocations.get(key); + }, + }; +} +function buildLocationKey(loc: PartialPosition): string { + return `${loc.line}:${locColumn(loc)}`; +} + +function generateClientScope( + globalLexicalScope: OriginalScope, + originalScopes: Array<SourceScope & { generatedBindings: ScopeBindings }> +): OriginalScope { + // Build a structure similar to the client's linked scope object using + // the original AST scopes, but pulling in the generated bindings + // linked to each scope. + const result = originalScopes + .slice(0, -2) + .reverse() + .reduce((acc, orig, i): OriginalScope => { + const { + // The 'this' binding data we have is handled independently, so + // the binding data is not included here. + // eslint-disable-next-line no-unused-vars + this: _this, + ...variables + } = orig.generatedBindings; + + return { + parent: acc, + actor: `originalActor${i}`, + type: orig.type, + scopeKind: orig.scopeKind, + bindings: { + arguments: [], + variables, + }, + ...(orig.type === "function" + ? { + function: { + displayName: orig.displayName, + }, + } + : null), + ...(orig.type === "block" + ? { + block: { + displayName: orig.displayName, + }, + } + : null), + }; + }, globalLexicalScope); + + // The rendering logic in getScope 'this' bindings only runs on the current + // selected frame scope, so we pluck out the 'this' binding that was mapped, + // and put it in a special location + const thisScope = originalScopes.find(scope => scope.bindings.this); + if (result.bindings && thisScope) { + result.bindings.this = thisScope.generatedBindings.this || null; + } + + return result; +} + +function getGlobalFromScope(scopes: Scope): OriginalScope { + // Pull the root object scope and root lexical scope to reuse them in + // our mapped scopes. This assumes that file being processed is + // a CommonJS or ES6 module, which might not be ideal. Potentially + // should add some logic to try to detect those cases? + let globalLexicalScope: ?OriginalScope = null; + for (let s = scopes; s.parent; s = s.parent) { + // $FlowIgnore - Flow doesn't like casting 'parent'. + globalLexicalScope = s; + } + if (!globalLexicalScope) { + throw new Error("Assertion failure - there should always be a scope"); + } + return globalLexicalScope; +} + +function generateGlobalFromAst( + generatedScopes: Array<SourceScope> +): OriginalScope { + const globalLexicalAst = generatedScopes[generatedScopes.length - 2]; + if (!globalLexicalAst) { + throw new Error("Assertion failure - there should always be a scope"); + } + return { + actor: "generatedActor1", + type: "block", + scopeKind: "", + bindings: { + arguments: [], + variables: Object.fromEntries( + Object.keys(globalLexicalAst).map(key => [key, getOptimizedOutGrip()]) + ), + }, + // $FlowIgnore - Flow doesn't like casting 'parent'. + parent: { + actor: "generatedActor0", + object: getOptimizedOutGrip(), + scopeKind: "", + type: "object", + }, + }; +} + +function hasValidIdent( + range: MappedOriginalRange, + pos: BindingLocation +): boolean { + return ( + range.type === "match" || + // For declarations, we allow the range on the identifier to be a + // more general "contains" to increase the chances of a match. + (pos.type !== "ref" && range.type === "contains") + ); +} + +// eslint-disable-next-line complexity +async function findGeneratedBinding( + sourceMaps: any, + client: any, + source: Source, + content: SourceContent, + name: string, + originalBinding: BindingData, + originalRanges: Array<MappedOriginalRange>, + generatedAstBindings: Array<GeneratedBindingLocation> +): Promise<?{ + grip: BindingContents, + expression: string | null, +}> { + // If there are no references to the implicits, then we have no way to + // even attempt to map it back to the original since there is no location + // data to use. Bail out instead of just showing it as unmapped. + if ( + originalBinding.type === "implicit" && + !originalBinding.refs.some(item => item.type === "ref") + ) { + return null; + } + + const loadApplicableBindings = async (pos, locationType) => { + let applicableBindings = await getApplicableBindingsForOriginalPosition( + generatedAstBindings, + source, + pos, + originalBinding.type, + locationType, + sourceMaps + ); + if (applicableBindings.length > 0) { + hadApplicableBindings = true; + } + if (locationType === "ref") { + // Some tooling creates ranges that map a line as a whole, which is useful + // for step-debugging, but can easily lead to finding the wrong binding. + // To avoid these false-positives, we entirely ignore bindings matched + // by ranges that cover full lines. + applicableBindings = applicableBindings.filter( + ({ range }) => + !(range.start.column === 0 && range.end.column === Infinity) + ); + } + if ( + locationType !== "ref" && + !(await originalRangeStartsInside(source, pos, sourceMaps)) + ) { + applicableBindings = []; + } + return applicableBindings; + }; + + const { refs } = originalBinding; + + let hadApplicableBindings = false; + let genContent: GeneratedDescriptor | null = null; + for (const pos of refs) { + const applicableBindings = await loadApplicableBindings(pos, pos.type); + + const range = findMatchingRange(originalRanges, pos); + if (range && hasValidIdent(range, pos)) { + if (originalBinding.type === "import") { + genContent = await findGeneratedImportReference(applicableBindings); + } else { + genContent = await findGeneratedReference(applicableBindings); + } + } + + if ( + (pos.type === "class-decl" || pos.type === "class-inner") && + content.contentType && + content.contentType.match(/\/typescript/) + ) { + const declRange = findMatchingRange(originalRanges, pos.declaration); + if (declRange && declRange.type !== "multiple") { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // Resolve to first binding in the range + const declContent = await findGeneratedReference( + applicableDeclBindings + ); + + if (declContent) { + // Prefer the declaration mapping in this case because TS sometimes + // maps class declaration names to "export.Foo = Foo;" or to + // the decorator logic itself + genContent = declContent; + } + } + } + + if ( + !genContent && + pos.type === "import-decl" && + typeof pos.importName === "string" + ) { + const { importName } = pos; + const declRange = findMatchingRange(originalRanges, pos.declaration); + + // The import declaration should have an original position mapping, + // but otherwise we don't really have preferences on the range type + // because it can have multiple bindings, but we do want to make sure + // that all of the bindings that match the range are part of the same + // import declaration. + if (declRange?.singleDeclaration) { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // match the import declaration location + genContent = await findGeneratedImportDeclaration( + applicableDeclBindings, + importName + ); + } + } + + if (genContent) { + break; + } + } + + if (genContent && genContent.desc) { + return { + grip: genContent.desc, + expression: genContent.expression, + }; + } else if (genContent) { + // If there is no descriptor for 'this', then this is not the top-level + // 'this' that the server gave us a binding for, and we can just ignore it. + if (name === "this") { + return null; + } + + // If the location is found but the descriptor is not, then it + // means that the server scope information didn't match the scope + // information from the DevTools parsed scopes. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unscoped", + unscoped: true, + + // HACK: Until support for "unscoped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; + } else if (!hadApplicableBindings && name !== "this") { + // If there were no applicable bindings to consider while searching for + // matching bindings, then the source map for this file didn't make any + // attempt to map the binding, and that most likely means that the + // code was entirely emitted from the output code. + return { + grip: getOptimizedOutGrip(), + expression: ` + (() => { + throw new Error('"' + ${JSON.stringify( + name + )} + '" has been optimized out.'); + })() + `, + }; + } + + // If no location mapping is found, then the map is bad, or + // the map is okay but it original location is inside + // of some scope, but the generated location is outside, leading + // us to search for bindings that don't technically exist. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unmapped", + unmapped: true, + + // HACK: Until support for "unmapped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js new file mode 100644 index 0000000000..35f5061dd3 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { PartialPosition } from "../../../types"; + +export function locColumn(loc: PartialPosition): number { + if (typeof loc.column !== "number") { + // This shouldn't really happen with locations from the AST, but + // the datatype we are using allows null/undefined column. + return 0; + } + + return loc.column; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js new file mode 100644 index 0000000000..8147fb1354 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { PartialPosition } from "../../../types"; +import { positionCmp } from "./positionCmp"; + +export function mappingContains( + mapped: { +start: PartialPosition, +end: PartialPosition }, + item: { +start: PartialPosition, +end: PartialPosition } +): boolean { + return ( + positionCmp(item.start, mapped.start) >= 0 && + positionCmp(item.end, mapped.end) <= 0 + ); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/moz.build b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build new file mode 100644 index 0000000000..05f2b7e3d8 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "buildGeneratedBindingList.js", + "filtering.js", + "findGeneratedBindingFromPosition.js", + "getApplicableBindingsForOriginalPosition.js", + "index.js", + "locColumn.js", + "mappingContains.js", + "optimizedOut.js", + "positionCmp.js", + "rangeMetadata.js", +) diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js new file mode 100644 index 0000000000..a1f7dfcf10 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { BindingContents } from "../../../types"; + +export function getOptimizedOutGrip(): BindingContents { + return { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "null", + optimizedOut: true, + }, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js new file mode 100644 index 0000000000..683ebf5b53 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { PartialPosition } from "../../../types"; +import { locColumn } from "./locColumn"; + +/** + * * === 0 - Positions are equal. + * * < 0 - first position before second position + * * > 0 - first position after second position + */ +export function positionCmp(p1: PartialPosition, p2: PartialPosition): number { + if (p1.line === p2.line) { + const l1 = locColumn(p1); + const l2 = locColumn(p2); + + if (l1 === l2) { + return 0; + } + return l1 < l2 ? -1 : 1; + } + + return p1.line < p2.line ? -1 : 1; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js new file mode 100644 index 0000000000..bbd13ab588 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import typeof SourceMaps from "devtools-source-map"; + +import { locColumn } from "./locColumn"; +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; + +import type { SourceScope } from "../../../workers/parser"; +import type { PartialPosition, SourceLocation } from "../../../types"; + +type SourceOriginalRange = { + line: number, + columnStart: number, + columnEnd: number, +}; + +// * match - Range contains a single identifier with matching start location +// * contains - Range contains a single identifier with non-matching start +// * multiple - Range contains multiple identifiers +// * empty - Range contains no identifiers +type MappedOriginalRangeType = "match" | "contains" | "multiple" | "empty"; +export type MappedOriginalRange = { + type: MappedOriginalRangeType, + singleDeclaration: boolean, + line: number, + columnStart: number, + columnEnd: number, +}; + +export async function loadRangeMetadata( + location: SourceLocation, + originalAstScopes: Array<SourceScope>, + sourceMaps: SourceMaps +): Promise<Array<MappedOriginalRange>> { + const originalRanges: Array<SourceOriginalRange> = await sourceMaps.getOriginalRanges( + location.sourceId + ); + + const sortedOriginalAstBindings = []; + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + sortedOriginalAstBindings.push(ref); + } + } + } + sortedOriginalAstBindings.sort((a, b) => positionCmp(a.start, b.start)); + + let i = 0; + + return originalRanges.map(range => { + const bindings = []; + + while ( + i < sortedOriginalAstBindings.length && + (sortedOriginalAstBindings[i].start.line < range.line || + (sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) < range.columnStart)) + ) { + i++; + } + + while ( + i < sortedOriginalAstBindings.length && + sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) >= range.columnStart && + locColumn(sortedOriginalAstBindings[i].start) < range.columnEnd + ) { + const lastBinding = bindings[bindings.length - 1]; + // Only add bindings when they're in new positions + if ( + !lastBinding || + positionCmp(lastBinding.start, sortedOriginalAstBindings[i].start) !== 0 + ) { + bindings.push(sortedOriginalAstBindings[i]); + } + i++; + } + + let type = "empty"; + let singleDeclaration = true; + if (bindings.length === 1) { + const binding = bindings[0]; + if ( + binding.start.line === range.line && + binding.start.column === range.columnStart + ) { + type = "match"; + } else { + type = "contains"; + } + } else if (bindings.length > 1) { + type = "multiple"; + const binding = bindings[0]; + const declStart = + binding.type !== "ref" ? binding.declaration.start : null; + + singleDeclaration = bindings.every(b => { + return ( + declStart && + b.type !== "ref" && + positionCmp(declStart, b.declaration.start) === 0 + ); + }); + } + + return { + type, + singleDeclaration, + ...range, + }; + }); +} + +export function findMatchingRange( + sortedOriginalRanges: Array<MappedOriginalRange>, + bindingRange: { +end: PartialPosition, +start: PartialPosition } +): ?MappedOriginalRange { + return filterSortedArray(sortedOriginalRanges, range => { + if (range.line < bindingRange.start.line) { + return -1; + } + if (range.line > bindingRange.start.line) { + return 1; + } + + if (range.columnEnd <= locColumn(bindingRange.start)) { + return -1; + } + if (range.columnStart > locColumn(bindingRange.start)) { + return 1; + } + + return 0; + }).pop(); +} diff --git a/devtools/client/debugger/src/utils/pause/moz.build b/devtools/client/debugger/src/utils/pause/moz.build new file mode 100644 index 0000000000..e0705d3115 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "frames", + "mapScopes", + "scopes", +] + +CompiledModules( + "index.js", + "why.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/getScope.js b/devtools/client/debugger/src/utils/pause/scopes/getScope.js new file mode 100644 index 0000000000..fae8a6bdf6 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getScope.js @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +// $FlowIgnore +import { objectInspector } from "devtools/client/shared/components/reps/index"; +import { getBindingVariables } from "./getVariables"; +import { getFramePopVariables, getThisVariable } from "./utils"; +import { simplifyDisplayName } from "../../pause/frames"; + +import type { Frame, Why, Scope } from "../../../types"; + +import type { NamedValue } from "./types"; + +export type RenderableScope = { + type: $ElementType<Scope, "type">, + scopeKind: $ElementType<Scope, "scopeKind">, + actor: $ElementType<Scope, "actor">, + bindings: $ElementType<Scope, "bindings">, + parent: ?RenderableScope, + object?: ?Object, + function?: ?{ + displayName: string, + }, + block?: ?{ + displayName: string, + }, +}; + +const { + utils: { + node: { NODE_TYPES }, + }, +} = objectInspector; + +function getScopeTitle(type, scope: RenderableScope): string | void { + if (type === "block" && scope.block && scope.block.displayName) { + return scope.block.displayName; + } + + if (type === "function" && scope.function) { + return scope.function.displayName + ? simplifyDisplayName(scope.function.displayName) + : L10N.getStr("anonymousFunction"); + } + return L10N.getStr("scopes.block"); +} + +export function getScope( + scope: RenderableScope, + selectedFrame: Frame, + frameScopes: RenderableScope, + why: Why, + scopeIndex: number +): ?NamedValue { + const { type, actor } = scope; + + const isLocalScope = scope.actor === frameScopes.actor; + + const key = `${actor}-${scopeIndex}`; + if (type === "function" || type === "block") { + const { bindings } = scope; + + let vars = getBindingVariables(bindings, key); + + // show exception, return, and this variables in innermost scope + if (isLocalScope) { + vars = vars.concat(getFramePopVariables(why, key)); + + let thisDesc_ = selectedFrame.this; + + if (bindings && "this" in bindings) { + // The presence of "this" means we're rendering a "this" binding + // generated from mapScopes and this can override the binding + // provided by the current frame. + thisDesc_ = bindings.this ? bindings.this.value : null; + } + + const this_ = getThisVariable(thisDesc_, key); + + if (this_) { + vars.push(this_); + } + } + + if (vars?.length) { + const title = getScopeTitle(type, scope) || ""; + vars.sort((a, b) => a.name.localeCompare(b.name)); + return { + name: title, + path: key, + contents: vars, + type: NODE_TYPES.BLOCK, + }; + } + } else if (type === "object" && scope.object) { + let value = scope.object; + // If this is the global window scope, mark it as such so that it will + // preview Window: Global instead of Window: Window + if (value.class === "Window") { + value = { ...scope.object, displayClass: "Global" }; + } + return { + name: scope.object.class, + path: key, + contents: { value }, + }; + } + + return null; +} + +export function mergeScopes( + scope: RenderableScope, + parentScope: RenderableScope, + item: NamedValue, + parentItem: NamedValue +): NamedValue | void { + if (scope.scopeKind == "function lexical" && parentScope.type == "function") { + const contents = (item.contents: any).concat(parentItem.contents); + contents.sort((a, b) => a.name.localeCompare(b.name)); + + return { + name: parentItem.name, + path: parentItem.path, + contents, + type: NODE_TYPES.BLOCK, + }; + } +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/getVariables.js b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js new file mode 100644 index 0000000000..9fd2402a52 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js @@ -0,0 +1,48 @@ +/* eslint max-nested-callbacks: ["error", 4] */ + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { toPairs } from "lodash"; + +import type { NamedValue } from "./types"; +import type { BindingContents, ScopeBindings } from "../../../types"; + +// VarAndBindingsPair actually is [name: string, contents: BindingContents] +type VarAndBindingsPair = [string, any]; +type VarAndBindingsPairs = Array<VarAndBindingsPair>; + +// Scope's bindings field which holds variables and arguments +type ScopeBindingsWrapper = { + variables: ScopeBindings, + arguments: BindingContents[], +}; + +// Create the tree nodes representing all the variables and arguments +// for the bindings from a scope. +export function getBindingVariables( + bindings: ?ScopeBindingsWrapper, + parentName: string +): NamedValue[] { + if (!bindings) { + return []; + } + + const args: VarAndBindingsPairs = bindings.arguments.map( + arg => toPairs(arg)[0] + ); + + const variables: VarAndBindingsPairs = toPairs(bindings.variables); + + return args.concat(variables).map(binding => { + const name = binding[0]; + const contents = binding[1]; + return { + name, + path: `${parentName}/${name}`, + contents, + }; + }); +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/index.js b/devtools/client/debugger/src/utils/pause/scopes/index.js new file mode 100644 index 0000000000..2b5ae9efe8 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/index.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getScope, mergeScopes, type RenderableScope } from "./getScope"; + +import type { Frame, Why, BindingContents } from "../../../types"; + +export type NamedValue = { + name: string, + generatedName?: string, + path: string, + contents: BindingContents | NamedValue[], +}; + +export function getScopes( + why: ?Why, + selectedFrame: Frame, + frameScopes: ?RenderableScope +): ?(NamedValue[]) { + if (!why || !selectedFrame) { + return null; + } + + if (!frameScopes) { + return null; + } + + const scopes = []; + + let scope = frameScopes; + let scopeIndex = 1; + let prev = null, + prevItem = null; + + while (scope) { + let scopeItem = getScope( + scope, + selectedFrame, + frameScopes, + why, + scopeIndex + ); + + if (scopeItem) { + const mergedItem = + prev && prevItem ? mergeScopes(prev, scope, prevItem, scopeItem) : null; + if (mergedItem) { + scopeItem = mergedItem; + scopes.pop(); + } + scopes.push(scopeItem); + } + prev = scope; + prevItem = scopeItem; + scopeIndex++; + scope = scope.parent; + } + + return scopes; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/moz.build b/devtools/client/debugger/src/utils/pause/scopes/moz.build new file mode 100644 index 0000000000..059d187e3d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/moz.build @@ -0,0 +1,13 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "getScope.js", + "getVariables.js", + "index.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js new file mode 100644 index 0000000000..34d5631407 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js @@ -0,0 +1,117 @@ +/* eslint max-nested-callbacks: ["error", 4] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getFramePopVariables } from "../utils"; +import type { NamedValue } from "../types"; + +const errorGrip = { + type: "object", + actor: "server2.conn66.child1/obj243", + class: "Error", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "blah", + stack: + "onclick@http://localhost:8000/examples/doc-return-values.html:1:18\n", + fileName: "http://localhost:8000/examples/doc-return-values.html", + lineNumber: 1, + columnNumber: 18, + }, +}; + +function returnWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + return: grip, + }, + }; +} + +function throwWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + throw: grip, + }, + }; +} + +function getContentsValue(v: NamedValue) { + return (v.contents: any).value; +} + +function getContentsClass(v: NamedValue) { + const value = getContentsValue(v); + return value ? value.class || undefined : ""; +} + +describe("pause - scopes", () => { + describe("getFramePopVariables", () => { + describe("falsey values", () => { + // NOTE: null and undefined are treated like objects and given a type + const falsey = { false: false, "0": 0, null: { type: "null" } }; + for (const test in falsey) { + const value = falsey[test]; + it(`shows ${test} returns`, () => { + const why = returnWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + + it(`shows ${test} throws`, () => { + const why = throwWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + } + }); + + describe("Error / Objects", () => { + it("shows Error returns", () => { + const why = returnWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + + it("shows error throws", () => { + const why = throwWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + }); + + describe("undefined", () => { + it("does not show undefined returns", () => { + const why = returnWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars).toHaveLength(0); + }); + + it("shows undefined throws", () => { + const why = throwWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual({ type: "undefined" }); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js new file mode 100644 index 0000000000..24fedefe45 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getScopes } from ".."; +import { + makeMockFrame, + makeMockScope, + makeWhyNormal, + makeWhyThrow, + mockScopeAddVariable, +} from "../../../test-mockup"; + +import type { Scope } from "../../../../types"; +import type { RenderableScope } from "../getScope"; + +function convertScope(scope: Scope): RenderableScope { + return (scope: any); +} + +describe("scopes", () => { + describe("getScopes", () => { + it("single scope", () => { + const pauseData = makeWhyNormal(); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[0].path).toEqual("actor1-1"); + expect(scopes[0].contents[0]).toEqual({ + name: "<this>", + path: "actor1-1/<this>", + contents: { value: {} }, + }); + }); + + it("second scope", () => { + const pauseData = makeWhyNormal(); + const scope0 = makeMockScope("actor2"); + const scope1 = makeMockScope("actor1", undefined, scope0); + const selectedFrame = makeMockFrame(undefined, undefined, scope1); + mockScopeAddVariable(scope0, "foo"); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[1].path).toEqual("actor2-2"); + expect(scopes[1].contents[0]).toEqual({ + name: "foo", + path: "actor2-2/foo", + contents: { value: null }, + }); + }); + + it("returning scope", () => { + const why = makeWhyNormal("to sender"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<return>", + path: "actor1-1/<return>", + contents: { + value: "to sender", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + + it("throwing scope", () => { + const why = makeWhyThrow("a party"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<exception>", + path: "actor1-1/<exception>", + contents: { + value: "a party", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/types.js b/devtools/client/debugger/src/utils/pause/scopes/types.js new file mode 100644 index 0000000000..4c44b4c530 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/types.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { BindingContents } from "../../../types"; + +export type NamedValue = { + name: string, + generatedName?: string, + path: string, + contents: BindingContents | NamedValue[], +}; diff --git a/devtools/client/debugger/src/utils/pause/scopes/utils.js b/devtools/client/debugger/src/utils/pause/scopes/utils.js new file mode 100644 index 0000000000..b9f2924ced --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/utils.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Why } from "../../../types"; +import type { NamedValue } from "./types"; + +export function getFramePopVariables(why: Why, path: string): NamedValue[] { + const vars: Array<NamedValue> = []; + + if (why && why.frameFinished) { + const { frameFinished } = why; + + // Always display a `throw` property if present, even if it is falsy. + if (Object.prototype.hasOwnProperty.call(frameFinished, "throw")) { + vars.push({ + name: "<exception>", + path: `${path}/<exception>`, + contents: { value: frameFinished.throw }, + }); + } + + if (Object.prototype.hasOwnProperty.call(frameFinished, "return")) { + const returned = frameFinished.return; + + // Do not display undefined. Do display falsy values like 0 and false. The + // protocol grip for undefined is a JSON object: { type: "undefined" }. + if (typeof returned !== "object" || returned.type !== "undefined") { + vars.push({ + name: "<return>", + path: `${path}/<return>`, + contents: { value: returned }, + }); + } + } + } + + return vars; +} + +export function getThisVariable(this_: any, path: string): ?NamedValue { + if (!this_) { + return null; + } + + return { + name: "<this>", + path: `${path}/<this>`, + contents: { value: this_ }, + }; +} + +// Get a string path for an scope item which can be used in different pauses for +// a thread. +export function getScopeItemPath(item: Object): string { + // Calling toString() on item.path allows symbols to be handled. + return item.path.toString(); +} diff --git a/devtools/client/debugger/src/utils/pause/why.js b/devtools/client/debugger/src/utils/pause/why.js new file mode 100644 index 0000000000..116d4c6007 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/why.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Why } from "../../types"; + +// Map protocol pause "why" reason to a valid L10N key +// These are the known unhandled reasons: +// "breakpointConditionThrown", "clientEvaluated" +// "interrupted", "attached" +const reasons = { + debuggerStatement: "whyPaused.debuggerStatement", + breakpoint: "whyPaused.breakpoint", + exception: "whyPaused.exception", + resumeLimit: "whyPaused.resumeLimit", + breakpointConditionThrown: "whyPaused.breakpointConditionThrown", + eventBreakpoint: "whyPaused.eventBreakpoint", + getWatchpoint: "whyPaused.getWatchpoint", + setWatchpoint: "whyPaused.setWatchpoint", + mutationBreakpoint: "whyPaused.mutationBreakpoint", + interrupted: "whyPaused.interrupted", + + // V8 + DOM: "whyPaused.breakpoint", + EventListener: "whyPaused.pauseOnDOMEvents", + XHR: "whyPaused.XHR", + promiseRejection: "whyPaused.promiseRejection", + assert: "whyPaused.assert", + debugCommand: "whyPaused.debugCommand", + other: "whyPaused.other", +}; + +export function getPauseReason(why?: ?Why): string | null { + if (!why) { + return null; + } + + const reasonType = why.type; + if (!reasons[reasonType]) { + console.log("Please file an issue: reasonType=", reasonType); + } + + return reasons[reasonType]; +} + +export function isException(why: Why) { + return why?.type === "exception"; +} + +export function isInterrupted(why: ?Why) { + return why?.type === "interrupted"; +} + +export function inDebuggerEval(why: ?Why) { + if ( + why && + why.type === "exception" && + why.exception && + why.exception.preview && + why.exception.preview.fileName + ) { + return why.exception.preview.fileName === "debugger eval code"; + } + + return false; +} diff --git a/devtools/client/debugger/src/utils/prefs.js b/devtools/client/debugger/src/utils/prefs.js new file mode 100644 index 0000000000..871f6a2bad --- /dev/null +++ b/devtools/client/debugger/src/utils/prefs.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +// $FlowIgnore +const { PrefsHelper } = require("devtools/client/shared/prefs"); + +import { isNode } from "./environment"; +import Services from "devtools-services"; + +// Schema version to bump when the async store format has changed incompatibly +// and old stores should be cleared. +const prefsSchemaVersion = 11; +const { pref } = Services; + +if (isNode()) { + pref("devtools.browsertoolbox.fission", false); + pref("devtools.debugger.logging", false); + pref("devtools.debugger.alphabetize-outline", false); + pref("devtools.debugger.auto-pretty-print", false); + pref("devtools.source-map.client-service.enabled", true); + pref("devtools.chrome.enabled", false); + pref("devtools.debugger.pause-on-exceptions", false); + pref("devtools.debugger.pause-on-caught-exceptions", false); + pref("devtools.debugger.ignore-caught-exceptions", true); + pref("devtools.debugger.call-stack-visible", true); + pref("devtools.debugger.scopes-visible", true); + pref("devtools.debugger.component-visible", false); + pref("devtools.debugger.workers-visible", false); + pref("devtools.debugger.expressions-visible", false); + pref("devtools.debugger.xhr-breakpoints-visible", false); + pref("devtools.debugger.breakpoints-visible", true); + pref("devtools.debugger.event-listeners-visible", false); + pref("devtools.debugger.dom-mutation-breakpoints-visible", false); + pref("devtools.debugger.start-panel-collapsed", false); + pref("devtools.debugger.end-panel-collapsed", false); + pref("devtools.debugger.start-panel-size", 300); + pref("devtools.debugger.end-panel-size", 300); + pref("devtools.debugger.tabsBlackBoxed", "[]"); + pref("devtools.debugger.ui.editor-wrapping", false); + pref("devtools.debugger.ui.framework-grouping-on", true); + pref("devtools.debugger.pending-selected-location", "{}"); + pref("devtools.debugger.expressions", "[]"); + pref("devtools.debugger.file-search-case-sensitive", false); + pref("devtools.debugger.file-search-whole-word", false); + pref("devtools.debugger.file-search-regex-match", false); + pref("devtools.debugger.project-directory-root", ""); + pref("devtools.debugger.map-scopes-enabled", false); + pref("devtools.debugger.prefs-schema-version", prefsSchemaVersion); + pref("devtools.debugger.skip-pausing", false); + pref("devtools.debugger.log-actions", true); + pref("devtools.debugger.log-event-breakpoints", false); + pref("devtools.debugger.features.workers", true); + pref("devtools.debugger.features.async-stepping", false); + pref("devtools.debugger.features.wasm", true); + pref("devtools.debugger.features.shortcuts", true); + pref("devtools.debugger.features.root", true); + pref("devtools.debugger.features.map-scopes", true); + pref("devtools.debugger.features.remove-command-bar-options", true); + pref("devtools.debugger.features.code-folding", false); + pref("devtools.debugger.features.command-click", false); + pref("devtools.debugger.features.outline", true); + pref("devtools.debugger.features.column-breakpoints", true); + pref("devtools.debugger.features.skip-pausing", true); + pref("devtools.debugger.features.component-pane", false); + pref("devtools.debugger.features.autocomplete-expressions", false); + pref("devtools.debugger.features.map-expression-bindings", true); + pref("devtools.debugger.features.map-await-expression", true); + pref("devtools.debugger.features.xhr-breakpoints", true); + pref("devtools.debugger.features.original-blackbox", true); + pref("devtools.debugger.features.event-listeners-breakpoints", true); + pref("devtools.debugger.features.dom-mutation-breakpoints", true); + pref("devtools.debugger.features.log-points", true); + pref("devtools.debugger.features.inline-preview", true); + pref("devtools.debugger.features.overlay-step-buttons", true); + pref("devtools.debugger.features.frame-step", true); + pref("devtools.editor.tabsize", 2); +} + +export const prefs = new PrefsHelper("devtools", { + fission: ["Bool", "browsertoolbox.fission"], + logging: ["Bool", "debugger.logging"], + editorWrapping: ["Bool", "debugger.ui.editor-wrapping"], + alphabetizeOutline: ["Bool", "debugger.alphabetize-outline"], + autoPrettyPrint: ["Bool", "debugger.auto-pretty-print"], + clientSourceMapsEnabled: ["Bool", "source-map.client-service.enabled"], + chromeAndExtensionsEnabled: ["Bool", "chrome.enabled"], + pauseOnExceptions: ["Bool", "debugger.pause-on-exceptions"], + pauseOnCaughtExceptions: ["Bool", "debugger.pause-on-caught-exceptions"], + ignoreCaughtExceptions: ["Bool", "debugger.ignore-caught-exceptions"], + callStackVisible: ["Bool", "debugger.call-stack-visible"], + scopesVisible: ["Bool", "debugger.scopes-visible"], + componentVisible: ["Bool", "debugger.component-visible"], + workersVisible: ["Bool", "debugger.workers-visible"], + breakpointsVisible: ["Bool", "debugger.breakpoints-visible"], + expressionsVisible: ["Bool", "debugger.expressions-visible"], + xhrBreakpointsVisible: ["Bool", "debugger.xhr-breakpoints-visible"], + eventListenersVisible: ["Bool", "debugger.event-listeners-visible"], + domMutationBreakpointsVisible: [ + "Bool", + "debugger.dom-mutation-breakpoints-visible", + ], + startPanelCollapsed: ["Bool", "debugger.start-panel-collapsed"], + endPanelCollapsed: ["Bool", "debugger.end-panel-collapsed"], + startPanelSize: ["Int", "debugger.start-panel-size"], + endPanelSize: ["Int", "debugger.end-panel-size"], + frameworkGroupingOn: ["Bool", "debugger.ui.framework-grouping-on"], + pendingSelectedLocation: ["Json", "debugger.pending-selected-location", {}], + expressions: ["Json", "debugger.expressions", []], + fileSearchCaseSensitive: ["Bool", "debugger.file-search-case-sensitive"], + fileSearchWholeWord: ["Bool", "debugger.file-search-whole-word"], + fileSearchRegexMatch: ["Bool", "debugger.file-search-regex-match"], + debuggerPrefsSchemaVersion: ["Int", "debugger.prefs-schema-version"], + projectDirectoryRoot: ["Char", "debugger.project-directory-root", ""], + projectDirectoryRootName: [ + "Char", + "debugger.project-directory-root-name", + "", + ], + skipPausing: ["Bool", "debugger.skip-pausing"], + mapScopes: ["Bool", "debugger.map-scopes-enabled"], + logActions: ["Bool", "debugger.log-actions"], + logEventBreakpoints: ["Bool", "debugger.log-event-breakpoints"], + indentSize: ["Int", "editor.tabsize"], +}); + +// The pref may not be defined. Defaulting to null isn't viable (cursor never blinks). +// Can't use CodeMirror.defaults here because it's loaded later. +// Hardcode the fallback value to that of CodeMirror.defaults.cursorBlinkRate. +prefs.cursorBlinkRate = Services.prefs.getIntPref("ui.caretBlinkTime", 530); + +export const features = new PrefsHelper("devtools.debugger.features", { + asyncStepping: ["Bool", "async-stepping"], + wasm: ["Bool", "wasm"], + shortcuts: ["Bool", "shortcuts"], + root: ["Bool", "root"], + columnBreakpoints: ["Bool", "column-breakpoints"], + mapScopes: ["Bool", "map-scopes"], + removeCommandBarOptions: ["Bool", "remove-command-bar-options"], + workers: ["Bool", "workers"], + windowlessWorkers: ["Bool", "windowless-workers"], + outline: ["Bool", "outline"], + codeFolding: ["Bool", "code-folding"], + skipPausing: ["Bool", "skip-pausing"], + autocompleteExpression: ["Bool", "autocomplete-expressions"], + mapExpressionBindings: ["Bool", "map-expression-bindings"], + mapAwaitExpression: ["Bool", "map-await-expression"], + componentPane: ["Bool", "component-pane"], + xhrBreakpoints: ["Bool", "xhr-breakpoints"], + originalBlackbox: ["Bool", "original-blackbox"], + eventListenersBreakpoints: ["Bool", "event-listeners-breakpoints"], + domMutationBreakpoints: ["Bool", "dom-mutation-breakpoints"], + logPoints: ["Bool", "log-points"], + commandClick: ["Bool", "command-click"], + showOverlay: ["Bool", "overlay"], + inlinePreview: ["Bool", "inline-preview"], + windowlessServiceWorkers: ["Bool", "windowless-service-workers"], + frameStep: ["Bool", "frame-step"], +}); + +// Import the asyncStore already spawned by the TargetMixin class +// $FlowIgnore +const ThreadUtils = require("devtools/client/shared/thread-utils"); +export const asyncStore = ThreadUtils.asyncStore; + +export function resetSchemaVersion(): void { + prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion; +} + +export function verifyPrefSchema(): void { + if (prefs.debuggerPrefsSchemaVersion < prefsSchemaVersion) { + asyncStore.pendingBreakpoints = {}; + asyncStore.tabs = []; + asyncStore.xhrBreakpoints = []; + asyncStore.eventListenerBreakpoints = undefined; + prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion; + } +} diff --git a/devtools/client/debugger/src/utils/preview.js b/devtools/client/debugger/src/utils/preview.js new file mode 100644 index 0000000000..75325b400e --- /dev/null +++ b/devtools/client/debugger/src/utils/preview.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function isConsole(expression: string): boolean { + return /^console/.test(expression); +} diff --git a/devtools/client/debugger/src/utils/project-search.js b/devtools/client/debugger/src/utils/project-search.js new file mode 100644 index 0000000000..fdea416a88 --- /dev/null +++ b/devtools/client/debugger/src/utils/project-search.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +// Maybe reuse file search's functions? + +import React from "react"; +import type { Match } from "../components/ProjectSearch"; + +export function highlightMatches(lineMatch: Match) { + const { value, matchIndex, match } = lineMatch; + const len = match.length; + + return ( + <span className="line-value"> + <span className="line-match" key={0}> + {value.slice(0, matchIndex)} + </span> + <span className="query-match" key={1}> + {value.substr(matchIndex, len)} + </span> + <span className="line-match" key={2}> + {value.slice(matchIndex + len, value.length)} + </span> + </span> + ); +} diff --git a/devtools/client/debugger/src/utils/quick-open.js b/devtools/client/debugger/src/utils/quick-open.js new file mode 100644 index 0000000000..835244e20a --- /dev/null +++ b/devtools/client/debugger/src/utils/quick-open.js @@ -0,0 +1,159 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { endTruncateStr } from "./utils"; +import { + isPretty, + getFilename, + getSourceClassnames, + getSourceQueryString, +} from "./source"; + +import type { Location as BabelLocation } from "@babel/types"; +import type { Symbols } from "../reducers/ast"; +import type { QuickOpenType } from "../reducers/quick-open"; +import type { Tab } from "../reducers/tabs"; +import type { Source } from "../types"; +import type { + SymbolDeclaration, + IdentifierDeclaration, +} from "../workers/parser"; + +export const MODIFIERS = { + "@": "functions", + "#": "variables", + ":": "goto", + "?": "shortcuts", +}; + +export function parseQuickOpenQuery(query: string): QuickOpenType { + const startsWithModifier = + query[0] === "@" || + query[0] === "#" || + query[0] === ":" || + query[0] === "?"; + + if (startsWithModifier) { + const modifier = query[0]; + return MODIFIERS[modifier]; + } + + const isGotoSource = query.includes(":", 1); + + if (isGotoSource) { + return "gotoSource"; + } + + return "sources"; +} + +export function parseLineColumn(query: string) { + const [, line, column] = query.split(":"); + const lineNumber = parseInt(line, 10); + const columnNumber = parseInt(column, 10); + if (!isNaN(lineNumber)) { + return { + line: lineNumber, + ...(!isNaN(columnNumber) ? { column: columnNumber } : null), + }; + } +} + +export function formatSourcesForList( + source: Source, + tabUrls: Set<$PropertyType<Tab, "url">> +) { + const title = getFilename(source); + const relativeUrlWithQuery = `${source.relativeUrl}${getSourceQueryString( + source + ) || ""}`; + const subtitle = endTruncateStr(relativeUrlWithQuery, 100); + const value = relativeUrlWithQuery; + return { + value, + title, + subtitle, + icon: tabUrls.has(source.url) + ? "tab result-item-icon" + : `result-item-icon ${getSourceClassnames(source)}`, + id: source.id, + url: source.url, + }; +} + +export type QuickOpenResult = {| + id: string, + value: string, + title: string | React$Element<"div">, + subtitle?: string, + location?: BabelLocation, + url?: string, + icon?: string, +|}; + +export type FormattedSymbolDeclarations = {| + functions: Array<QuickOpenResult>, +|}; + +export function formatSymbol( + symbol: SymbolDeclaration | IdentifierDeclaration +): QuickOpenResult { + return { + id: `${symbol.name}:${symbol.location.start.line}`, + title: symbol.name, + subtitle: `${symbol.location.start.line}`, + value: symbol.name, + location: symbol.location, + }; +} + +export function formatSymbols(symbols: ?Symbols): FormattedSymbolDeclarations { + if (!symbols || symbols.loading) { + return { functions: [] }; + } + + const { functions } = symbols; + + return { + functions: functions.map(formatSymbol), + }; +} + +export function formatShortcutResults(): Array<QuickOpenResult> { + return [ + { + value: L10N.getStr("symbolSearch.search.functionsPlaceholder.title"), + title: `@ ${L10N.getStr("symbolSearch.search.functionsPlaceholder")}`, + id: "@", + }, + { + value: L10N.getStr("symbolSearch.search.variablesPlaceholder.title"), + title: `# ${L10N.getStr("symbolSearch.search.variablesPlaceholder")}`, + id: "#", + }, + { + value: L10N.getStr("gotoLineModal.title"), + title: `: ${L10N.getStr("gotoLineModal.placeholder")}`, + id: ":", + }, + ]; +} + +export function formatSources( + sources: Source[], + tabUrls: Set<$PropertyType<Tab, "url">> +): Array<QuickOpenResult> { + const formattedSources: Array<QuickOpenResult> = []; + + for (let i = 0; i < sources.length; ++i) { + const source = sources[i]; + + if (!!source.relativeUrl && !isPretty(source)) { + formattedSources.push(formatSourcesForList(source, tabUrls)); + } + } + + return formattedSources; +} diff --git a/devtools/client/debugger/src/utils/resource/base-query.js b/devtools/client/debugger/src/utils/resource/base-query.js new file mode 100644 index 0000000000..fda49b0901 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/base-query.js @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getResourceValues, + getValidatedResource, + makeIdentity, + type ResourceBound, + type Id, + type ResourceState, + type ResourceValues, + type ResourceIdentity, +} from "./core"; +import { arrayShallowEqual } from "./compare"; + +export type ResourceQuery<R: ResourceBound, Args, Reduced> = ( + ResourceState<R>, + Args +) => Reduced; + +export type QueryFilter<R: ResourceBound, Args> = ( + ResourceValues<R>, + Args +) => Array<Id<R>>; + +export type QueryMap<R: ResourceBound, Args, Mapped> = + | QueryMapNoArgs<R, Mapped> + | QueryMapWithArgs<R, Args, Mapped>; +export type QueryMapNoArgs<R: ResourceBound, Mapped> = { + +needsArgs?: false, + (R, ResourceIdentity): Mapped, +}; +export type QueryMapWithArgs<R: ResourceBound, Args, Mapped> = { + +needsArgs: true, + (R, ResourceIdentity, Args): Mapped, +}; + +export type QueryReduce<R: ResourceBound, Args, Mapped, Reduced> = ( + $ReadOnlyArray<Mapped>, + $ReadOnlyArray<Id<R>>, + Args +) => Reduced; + +export type QueryContext<Args> = { + args: Args, + identMap: WeakMap<ResourceIdentity, ResourceIdentity>, +}; +export type QueryResult<Mapped, Reduced> = { + mapped: Array<Mapped>, + reduced: Reduced, +}; +export type QueryResultCompare<Reduced> = (Reduced, Reduced) => boolean; + +export type QueryCacheHandler<R: ResourceBound, Args, Mapped, Reduced> = ( + ResourceState<R>, + QueryContext<Args>, + QueryResult<Mapped, Reduced> | null +) => QueryResult<Mapped, Reduced>; + +export type QueryCache<R: ResourceBound, Args, Mapped, Reduced> = ( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +) => ResourceQuery<R, Args, Reduced>; + +export function makeMapWithArgs<R: ResourceBound, Args, Mapped>( + map: (R, ResourceIdentity, Args) => Mapped +): QueryMapWithArgs<R, Args, Mapped> { + const wrapper = (resource, identity, args) => map(resource, identity, args); + wrapper.needsArgs = true; + return wrapper; +} + +export function makeResourceQuery<R: ResourceBound, Args, Mapped, Reduced>({ + cache, + filter, + map, + reduce, + resultCompare, +}: {| + cache: QueryCache<R, Args, Mapped, Reduced>, + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, + resultCompare: QueryResultCompare<Reduced>, +|}): ResourceQuery<R, Args, Reduced> { + const loadResource = makeResourceMapper(map); + + return cache((state, context, existing) => { + const ids = filter(getResourceValues(state), context.args); + const mapped = ids.map(id => loadResource(state, id, context)); + + if (existing && arrayShallowEqual(existing.mapped, mapped)) { + // If the items are exactly the same as the existing ones, we return + // early to reuse the existing result. + return existing; + } + + const reduced = reduce(mapped, ids, context.args); + + if (existing && resultCompare(existing.reduced, reduced)) { + return existing; + } + + return { mapped, reduced }; + }); +} + +type ResourceLoader<R: ResourceBound, Args, Mapped> = ( + ResourceState<R>, + Id<R>, + QueryContext<Args> +) => Mapped; + +function makeResourceMapper<R: ResourceBound, Args, Mapped>( + map: QueryMap<R, Args, Mapped> +): ResourceLoader<R, Args, Mapped> { + return map.needsArgs + ? makeResourceArgsMapper(map) + : makeResourceNoArgsMapper(map); +} + +/** + * Resources loaded when things care about arguments need to be given a + * special ResourceIdentity object that correlates with both the resource + * _and_ the arguments being passed to the query. That means they need extra + * logic when loading those resources. + */ +function makeResourceArgsMapper<R: ResourceBound, Args, Mapped>( + map: QueryMapWithArgs<R, Args, Mapped> +): ResourceLoader<R, Args, Mapped> { + const mapper = (value, identity, context) => + map(value, getIdentity(context.identMap, identity), context.args); + return (state, id, context) => getCachedResource(state, id, context, mapper); +} + +function makeResourceNoArgsMapper<R: ResourceBound, Args, Mapped>( + map: QueryMapNoArgs<R, Mapped> +): ResourceLoader<R, Args, Mapped> { + const mapper = (value, identity, context) => map(value, identity); + return (state, id, context) => getCachedResource(state, id, context, mapper); +} + +function getCachedResource<R: ResourceBound, Args, Mapped>( + state: ResourceState<R>, + id: Id<R>, + context: QueryContext<Args>, + map: ( + value: R, + identity: ResourceIdentity, + context: QueryContext<Args> + ) => Mapped +): Mapped { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + + return map(validatedState.values[id], validatedState.identity[id], context); +} + +function getIdentity( + identMap: WeakMap<ResourceIdentity, ResourceIdentity>, + identity: ResourceIdentity +) { + let ident = identMap.get(identity); + if (!ident) { + ident = makeIdentity(); + identMap.set(identity, ident); + } + + return ident; +} diff --git a/devtools/client/debugger/src/utils/resource/compare.js b/devtools/client/debugger/src/utils/resource/compare.js new file mode 100644 index 0000000000..5320e1f067 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/compare.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function strictEqual(value: mixed, other: mixed): boolean { + return value === other; +} + +export function shallowEqual(value: mixed, other: mixed): boolean { + return ( + value === other || + (Array.isArray(value) && + Array.isArray(other) && + arrayShallowEqual(value, other)) || + (isObject(value) && isObject(other) && objectShallowEqual(value, other)) + ); +} + +export function arrayShallowEqual( + value: $ReadOnlyArray<mixed>, + other: $ReadOnlyArray<mixed> +): boolean { + return value.length === other.length && value.every((k, i) => k === other[i]); +} + +function objectShallowEqual( + value: { [string]: mixed }, + other: { [string]: mixed } +): boolean { + const existingKeys = Object.keys(other); + const keys = Object.keys(value); + + return ( + keys.length === existingKeys.length && + keys.every((k, i) => k === existingKeys[i]) && + keys.every(k => value[k] === other[k]) + ); +} + +function isObject(value: mixed): boolean %checks { + return typeof value === "object" && !!value; +} diff --git a/devtools/client/debugger/src/utils/resource/core.js b/devtools/client/debugger/src/utils/resource/core.js new file mode 100644 index 0000000000..dad5337447 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/core.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export type Resource<R: ResourceBound> = $ReadOnly<$Exact<R>>; + +export type ResourceBound = { + +id: string, +}; +export type Id<R: ResourceBound> = $ElementType<R, "id">; + +type ResourceSubset<R: ResourceBound> = $ReadOnly<{ + +id: Id<R>, + ...$Shape<$Rest<R, { +id: Id<R> }>>, +}>; + +export opaque type ResourceIdentity: { [string]: mixed } = {||}; +export type ResourceValues<R: ResourceBound> = { [Id<R>]: R }; + +export opaque type ResourceState<R: ResourceBound> = { + identity: { [Id<R>]: ResourceIdentity }, + values: ResourceValues<R>, +}; + +export function createInitial<R: ResourceBound>(): ResourceState<R> { + return { + identity: {}, + values: {}, + }; +} + +export function insertResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<R> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + state = { + identity: { ...state.identity }, + values: { ...state.values }, + }; + + for (const resource of resources) { + const { id } = resource; + if (state.identity[id]) { + throw new Error( + `Resource "${id}" already exists, cannot insert ${JSON.stringify( + resource + )}` + ); + } + if (state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has value but no identity` + ); + } + + state.identity[resource.id] = makeIdentity(); + state.values[resource.id] = resource; + } + return state; +} + +export function removeResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<ResourceSubset<R> | Id<R>> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + state = { + identity: { ...state.identity }, + values: { ...state.values }, + }; + + for (let id of resources) { + if (typeof id !== "string") { + id = id.id; + } + + if (!state.identity[id]) { + throw new Error(`Resource "${id}" does not exists, cannot remove`); + } + if (!state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has identity but no value` + ); + } + + delete state.identity[id]; + delete state.values[id]; + } + return state; +} + +export function updateResources<R: ResourceBound>( + state: ResourceState<R>, + resources: $ReadOnlyArray<ResourceSubset<R>> +): ResourceState<R> { + if (resources.length === 0) { + return state; + } + + let didCopyValues = false; + + for (const subset of resources) { + const { id } = subset; + + if (!state.identity[id]) { + throw new Error(`Resource "${id}" does not exists, cannot update`); + } + if (!state.values[id]) { + throw new Error( + `Resource state corrupt: ${id} has identity but no value` + ); + } + + const existing = state.values[id]; + const updated = {}; + + for (const field of Object.keys(subset)) { + if (field === "id") { + continue; + } + + if (subset[field] !== existing[field]) { + updated[field] = subset[field]; + } + } + + if (Object.keys(updated).length > 0) { + if (!didCopyValues) { + didCopyValues = true; + state = { + identity: state.identity, + values: { ...state.values }, + }; + } + + state.values[id] = { ...existing, ...updated }; + } + } + + return state; +} + +export function makeIdentity(): ResourceIdentity { + return ({}: any); +} + +export function getValidatedResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): + | (ResourceState<R> & { + values: Array<R>, + identity: Array<string>, + }) + | null { + const value = state.values[id]; + const identity = state.identity[id]; + if ((value && !identity) || (!value && identity)) { + throw new Error( + `Resource state corrupt: ${id} has mismatched value and identity` + ); + } + + return value ? (state: any) : null; +} + +export function getResourceValues<R: ResourceBound>( + state: ResourceState<R> +): ResourceValues<R> { + return state.values; +} diff --git a/devtools/client/debugger/src/utils/resource/index.js b/devtools/client/debugger/src/utils/resource/index.js new file mode 100644 index 0000000000..fb8b917d2d --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/index.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export { + createInitial, + insertResources, + removeResources, + updateResources, +} from "./core"; +export type { + Id, + Resource, + ResourceBound, + // Disabled pending eslint-plugin-import bug #1345 + // eslint-disable-next-line import/named + ResourceState, + // Disabled pending eslint-plugin-import bug #1345 + // eslint-disable-next-line import/named + ResourceIdentity, + ResourceValues, +} from "./core"; + +export { + hasResource, + getResourceIds, + getResource, + getMappedResource, +} from "./selector"; +export type { ResourceMap } from "./selector"; + +export { makeResourceQuery, makeMapWithArgs } from "./base-query"; +export type { + ResourceQuery, + QueryMap, + QueryMapNoArgs, + QueryMapWithArgs, + QueryFilter, + QueryReduce, + QueryResultCompare, +} from "./base-query"; + +export { + filterAllIds, + makeWeakQuery, + makeShallowQuery, + makeStrictQuery, + makeIdQuery, + makeLoadQuery, + makeFilterQuery, + makeReduceQuery, + makeReduceAllQuery, +} from "./query"; +export type { + WeakQuery, + ShallowQuery, + StrictQuery, + IdQuery, + LoadQuery, + FilterQuery, + ReduceQuery, + ReduceAllQuery, +} from "./query"; + +export { + queryCacheWeak, + queryCacheShallow, + queryCacheStrict, +} from "./query-cache"; +export type { WeakArgsBound, ShallowArgsBound } from "./query-cache"; + +export { memoizeResourceShallow } from "./memoize"; diff --git a/devtools/client/debugger/src/utils/resource/memoize.js b/devtools/client/debugger/src/utils/resource/memoize.js new file mode 100644 index 0000000000..b00501b4df --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/memoize.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound } from "./core"; +import type { QueryMap } from "./base-query"; +import { shallowEqual } from "./compare"; + +/** + * Wraps a 'mapper' function to create a shallow-equality memoized version + * of the mapped result. The returned function will return the same value + * even if the input object is different, as long as the identity is the same + * and the mapped result is shallow-equal to the most recent mapped value. + */ +export function memoizeResourceShallow< + R: ResourceBound, + Args, + Mapped, + T: QueryMap<R, Args, Mapped> +>(map: T): T { + const cache = new WeakMap(); + + const fn = (input, identity, args) => { + let existingEntry = cache.get(identity); + + if (!existingEntry || existingEntry.input !== input) { + const mapper = (map: any); + const output = mapper(input, identity, args); + + if (existingEntry) { + // If the new output is shallow-equal to the old output, we reuse + // the previous object instead to preserve object equality. + const newOutput = shallowEqual(output, existingEntry.output) + ? existingEntry.output + : output; + + existingEntry.output = newOutput; + existingEntry.input = input; + } else { + existingEntry = { + input, + output, + }; + cache.set(identity, existingEntry); + } + } + + return existingEntry.output; + }; + fn.needsArgs = map.needsArgs; + return (fn: any); +} diff --git a/devtools/client/debugger/src/utils/resource/moz.build b/devtools/client/debugger/src/utils/resource/moz.build new file mode 100644 index 0000000000..7fa8b2a810 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "base-query.js", + "compare.js", + "core.js", + "index.js", + "memoize.js", + "query-cache.js", + "query.js", + "selector.js", +) diff --git a/devtools/client/debugger/src/utils/resource/query-cache.js b/devtools/client/debugger/src/utils/resource/query-cache.js new file mode 100644 index 0000000000..6fa8c922ad --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/query-cache.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound, ResourceState } from "./core"; +import type { + ResourceQuery, + QueryCacheHandler, + QueryContext, + QueryResult, +} from "./base-query"; +import { strictEqual, shallowEqual } from "./compare"; + +export type WeakArgsBound = + | $ReadOnly<{ [string]: mixed }> + | $ReadOnlyArray<mixed>; + +export type ShallowArgsBound = + | $ReadOnly<{ [string]: mixed }> + | $ReadOnlyArray<mixed>; + +/** + * A query 'cache' function that uses the identity of the arguments object to + * cache data for the query itself. + */ +export function queryCacheWeak< + R: ResourceBound, + Args: WeakArgsBound, + Mapped, + Reduced +>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + const cache = new WeakMap(); + return makeCacheFunction({ + handler, + // The WeakMap will only return entries for the exact object, + // so there is no need to compare at all. + compareArgs: () => true, + getEntry: args => cache.get(args) || null, + setEntry: (args, entry) => { + cache.set(args, entry); + }, + }); +} + +/** + * A query 'cache' function that uses shallow comparison to cache the most + * recent calculated result based on the value of the argument. + */ +export function queryCacheShallow< + R: ResourceBound, + // We require args to be an object here because if you're using a primitive + // then you should be using queryCacheStrict instead. + Args: ShallowArgsBound, + Mapped, + Reduced +>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + let latestEntry = null; + return makeCacheFunction({ + handler, + compareArgs: shallowEqual, + getEntry: () => latestEntry, + setEntry: (args, entry) => { + latestEntry = entry; + }, + }); +} + +/** + * A query 'cache' function that uses strict comparison to cache the most + * recent calculated result based on the value of the argument. + */ +export function queryCacheStrict<R: ResourceBound, Args, Mapped, Reduced>( + handler: QueryCacheHandler<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + let latestEntry = null; + return makeCacheFunction({ + handler, + compareArgs: strictEqual, + getEntry: () => latestEntry, + setEntry: (args, entry) => { + latestEntry = entry; + }, + }); +} + +type CacheEntry<R: ResourceBound, Args, Mapped, Reduced> = { + context: QueryContext<Args>, + state: ResourceState<R>, + result: QueryResult<Mapped, Reduced>, +}; + +type CacheFunctionInfo<R: ResourceBound, Args, Mapped, Reduced> = {| + // The handler to call when the args or the state are different from + // those in the entry for the arguments. + handler: QueryCacheHandler<R, Args, Mapped, Reduced>, + + // Compare two sets of arguments to decide whether or not they should be + // treated as the same set of arguments from the standpoint of caching. + compareArgs: (a: Args, b: Args) => boolean, + + getEntry: (args: Args) => CacheEntry<R, Args, Mapped, Reduced> | null, + setEntry: (args: Args, entry: CacheEntry<R, Args, Mapped, Reduced>) => void, +|}; +function makeCacheFunction<R: ResourceBound, Args, Mapped, Reduced>( + info: CacheFunctionInfo<R, Args, Mapped, Reduced> +): ResourceQuery<R, Args, Reduced> { + const { handler, compareArgs, getEntry, setEntry } = info; + + return (state, args: Args) => { + let entry = getEntry(args); + + const sameArgs = !!entry && compareArgs(entry.context.args, args); + const sameState = !!entry && entry.state === state; + + if (!entry || !sameArgs || !sameState) { + const context = + !entry || !sameArgs + ? { + args, + identMap: new WeakMap(), + } + : entry.context; + + const result = handler(state, context, entry ? entry.result : null); + + if (entry) { + entry.context = context; + entry.state = state; + entry.result = result; + } else { + entry = { + context, + state, + result, + }; + setEntry(args, entry); + } + } + + return entry.result.reduced; + }; +} diff --git a/devtools/client/debugger/src/utils/resource/query.js b/devtools/client/debugger/src/utils/resource/query.js new file mode 100644 index 0000000000..b2edd0f5e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/query.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { ResourceBound, Id, ResourceValues } from "./core"; + +import { + makeResourceQuery, + type ResourceQuery, + type QueryFilter, + type QueryMap, + type QueryReduce, +} from "./base-query"; + +import { + queryCacheWeak, + queryCacheShallow, + queryCacheStrict, + type WeakArgsBound, + type ShallowArgsBound, +} from "./query-cache"; + +import { memoizeResourceShallow } from "./memoize"; +import { shallowEqual } from "./compare"; + +export function filterAllIds<R: ResourceBound>( + values: ResourceValues<R> +): Array<Id<R>> { + return Object.keys(values); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type WeakQuery< + R: ResourceBound, + Args: WeakArgsBound, + Reduced +> = ResourceQuery<R, Args, Reduced>; +export function makeWeakQuery< + R: ResourceBound, + Args: WeakArgsBound, + Mapped, + Reduced +>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): WeakQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheWeak, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type ShallowQuery<R: ResourceBound, Args, Reduced> = ResourceQuery< + R, + Args, + Reduced +>; +export function makeShallowQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Mapped, + Reduced +>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): ShallowQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheShallow, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type StrictQuery<R: ResourceBound, Args, Reduced> = ResourceQuery< + R, + Args, + Reduced +>; +export function makeStrictQuery<R: ResourceBound, Args, Mapped, Reduced>({ + filter, + map, + reduce, +}: {| + filter: QueryFilter<R, Args>, + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced>, +|}): StrictQuery<R, Args, Reduced> { + return makeResourceQuery({ + cache: queryCacheStrict, + filter, + map: memoizeResourceShallow(map), + reduce, + resultCompare: shallowEqual, + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type IdQuery<R: ResourceBound, Mapped> = WeakQuery< + R, + Array<Id<R>>, + Array<Mapped> +>; +export function makeIdQuery<R: ResourceBound, Mapped>( + map: QueryMap<R, void, Mapped> +): IdQuery<R, Mapped> { + return makeWeakQuery({ + filter: (state, ids) => ids, + map: (r, identity) => map(r, identity), + reduce: items => items.slice(), + }); +} + +/** + * Create a query function to take a list of IDs and map each Reduceding + * resource object into a mapped form. + */ +export type LoadQuery<R: ResourceBound, Mapped> = WeakQuery< + R, + Array<Id<R>>, + $ReadOnly<{ [Id<R>]: Mapped }> +>; +export function makeLoadQuery<R: ResourceBound, Mapped>( + map: QueryMap<R, void, Mapped> +): LoadQuery<R, Mapped> { + return makeWeakQuery({ + filter: (state, ids) => ids, + map: (r, identity) => map(r, identity), + reduce: reduceMappedArrayToObject, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each reduced resource. + */ +export type FilterQuery< + R: ResourceBound, + Args: WeakArgsBound, + Mapped +> = WeakQuery<R, Args, $ReadOnly<{ [Id<R>]: Mapped }>>; +export function makeFilterQuery<R: ResourceBound, Args: WeakArgsBound, Mapped>( + filter: (R, Args) => boolean, + map: QueryMap<R, Args, Mapped> +): FilterQuery<R, Args, Mapped> { + return makeWeakQuery({ + filter: (values, args) => { + const ids = []; + for (const id of Object.keys(values)) { + if (filter(values[id], args)) { + ids.push(id); + } + } + return ids; + }, + map, + reduce: reduceMappedArrayToObject, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each resulting resource. + */ +export type ReduceQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Reduced +> = ShallowQuery<R, Args, Reduced>; +export function makeReduceQuery< + R: ResourceBound, + Args: ShallowArgsBound, + Mapped, + Reduced +>( + map: QueryMap<R, Args, Mapped>, + reduce: QueryReduce<R, Args, Mapped, Reduced> +): ReduceQuery<R, Args, Reduced> { + return makeShallowQuery({ + filter: filterAllIds, + map, + reduce, + }); +} + +/** + * Create a query function that accepts an argument and can filter the + * resource items to a subset before mapping each resulting resource. + */ +export type ReduceAllQuery<R: ResourceBound, Reduced> = ShallowQuery< + R, + void, + Reduced +>; +export function makeReduceAllQuery<R: ResourceBound, Mapped, Reduced>( + map: QueryMap<R, void, Mapped>, + reduce: QueryReduce<R, void, Mapped, Reduced> +): ReduceAllQuery<R, Reduced> { + return makeStrictQuery({ + filter: filterAllIds, + map, + reduce, + }); +} + +function reduceMappedArrayToObject<Args, ID, Mapped>( + items: $ReadOnlyArray<Mapped>, + ids: $ReadOnlyArray<ID>, + args: Args +): { [ID]: Mapped } { + return items.reduce((acc: { [ID]: Mapped }, item, i) => { + acc[ids[i]] = item; + return acc; + }, {}); +} diff --git a/devtools/client/debugger/src/utils/resource/selector.js b/devtools/client/debugger/src/utils/resource/selector.js new file mode 100644 index 0000000000..df5fae4422 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/selector.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getValidatedResource, + getResourceValues, + type ResourceState, + type Id, + type ResourceBound, + type ResourceIdentity, +} from "./core"; + +export type ResourceMap<R: ResourceBound, Mapped> = ( + R, + ResourceIdentity +) => Mapped; + +export function hasResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): boolean %checks { + return !!getValidatedResource(state, id); +} + +export function getResourceIds<R: ResourceBound>( + state: ResourceState<R> +): Array<Id<R>> { + return Object.keys(getResourceValues(state)); +} + +export function getResource<R: ResourceBound>( + state: ResourceState<R>, + id: Id<R> +): R { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + return validatedState.values[id]; +} + +export function getMappedResource<R: ResourceBound, Mapped>( + state: ResourceState<R>, + id: Id<R>, + map: ResourceMap<R, Mapped> +): Mapped { + const validatedState = getValidatedResource(state, id); + if (!validatedState) { + throw new Error(`Resource ${id} does not exist`); + } + + return map(validatedState.values[id], validatedState.identity[id]); +} diff --git a/devtools/client/debugger/src/utils/resource/tests/crud.spec.js b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js new file mode 100644 index 0000000000..474323e6e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/tests/crud.spec.js @@ -0,0 +1,266 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var it: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +import { + createInitial, + insertResources, + removeResources, + updateResources, + hasResource, + getResourceIds, + getResource, + getMappedResource, + type Resource, + type ResourceIdentity, +} from ".."; + +type TestResource = Resource<{ + id: string, + name: string, + data: number, + obj: {}, +}>; + +const makeResource = (id: string): TestResource => ({ + id, + name: `name-${id}`, + data: 42, + obj: {}, +}); + +const mapName = (resource: TestResource) => resource.name; +const mapWithIdent = (resource: TestResource, identity: ResourceIdentity) => ({ + resource, + identity, + obj: {}, +}); + +const clone = <T>(v: T): T => (JSON.parse((JSON.stringify(v): any)): any); + +describe("resource CRUD operations", () => { + let r1, r2, r3; + let originalInitial; + let initialState; + beforeEach(() => { + r1 = makeResource("id-1"); + r2 = makeResource("id-2"); + r3 = makeResource("id-3"); + + initialState = createInitial(); + originalInitial = clone(initialState); + }); + + describe("insert", () => { + it("should work", () => { + const state = insertResources(initialState, [r1, r2, r3]); + + expect(initialState).toEqual(originalInitial); + expect(getResource(state, r1.id)).toBe(r1); + expect(getResource(state, r2.id)).toBe(r2); + expect(getResource(state, r3.id)).toBe(r3); + }); + + it("should throw on duplicate", () => { + const state = insertResources(initialState, [r1]); + expect(() => { + insertResources(state, [r1]); + }).toThrow(/already exists/); + + expect(() => { + insertResources(state, [r2, r2]); + }).toThrow(/already exists/); + }); + + it("should be a no-op when given no resources", () => { + const state = insertResources(initialState, []); + + expect(state).toBe(initialState); + }); + }); + + describe("read", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + }); + + it("should allow reading all IDs", () => { + expect(getResourceIds(initialState)).toEqual([r1.id, r2.id, r3.id]); + }); + + it("should allow checking for existing of an ID", () => { + expect(hasResource(initialState, r1.id)).toBe(true); + expect(hasResource(initialState, r2.id)).toBe(true); + expect(hasResource(initialState, r3.id)).toBe(true); + expect(hasResource(initialState, "unknownId")).toBe(false); + }); + + it("should allow reading an item", () => { + expect(getResource(initialState, r1.id)).toBe(r1); + expect(getResource(initialState, r2.id)).toBe(r2); + expect(getResource(initialState, r3.id)).toBe(r3); + + expect(() => { + getResource(initialState, "unknownId"); + }).toThrow(/does not exist/); + }); + + it("should allow reading and mapping an item", () => { + expect(getMappedResource(initialState, r1.id, mapName)).toBe(r1.name); + expect(getMappedResource(initialState, r2.id, mapName)).toBe(r2.name); + expect(getMappedResource(initialState, r3.id, mapName)).toBe(r3.name); + + expect(() => { + getMappedResource(initialState, "unknownId", mapName); + }).toThrow(/does not exist/); + }); + + it("should allow reading and mapping an item with identity", () => { + const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent); + const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent); + + const state = updateResources(initialState, [{ ...r1, obj: {} }]); + + const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent); + const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent); + + // The update changed the resource object, but not the identity. + expect(r1NewIdent.resource).not.toBe(r1Ident.resource); + expect(r1NewIdent.resource).toEqual(r1Ident.resource); + expect(r1NewIdent.identity).toBe(r1Ident.identity); + + // The update did not change the r2 resource. + expect(r2NewIdent.resource).toBe(r2Ident.resource); + expect(r2NewIdent.identity).toBe(r2Ident.identity); + }); + }); + + describe("update", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + originalInitial = clone(initialState); + }); + + it("should work", () => { + const r1Ident = getMappedResource(initialState, r1.id, mapWithIdent); + const r2Ident = getMappedResource(initialState, r2.id, mapWithIdent); + const r3Ident = getMappedResource(initialState, r3.id, mapWithIdent); + + const state = updateResources(initialState, [ + { + id: r1.id, + data: 21, + }, + { + id: r2.id, + name: "newName", + }, + ]); + + expect(initialState).toEqual(originalInitial); + expect(getResource(state, r1.id)).toEqual({ ...r1, data: 21 }); + expect(getResource(state, r2.id)).toEqual({ ...r2, name: "newName" }); + expect(getResource(state, r3.id)).toBe(r3); + + const r1NewIdent = getMappedResource(state, r1.id, mapWithIdent); + const r2NewIdent = getMappedResource(state, r2.id, mapWithIdent); + const r3NewIdent = getMappedResource(state, r3.id, mapWithIdent); + + // The update changed the resource object, but not the identity. + expect(r1NewIdent.resource).not.toBe(r1Ident.resource); + expect(r1NewIdent.resource).toEqual({ + ...r1Ident.resource, + data: 21, + }); + expect(r1NewIdent.identity).toBe(r1Ident.identity); + + // The update changed the resource object, but not the identity. + expect(r2NewIdent.resource).toEqual({ + ...r2Ident.resource, + name: "newName", + }); + expect(r2NewIdent.identity).toBe(r2Ident.identity); + + // The update did not change the r3 resource. + expect(r3NewIdent.resource).toBe(r3Ident.resource); + expect(r3NewIdent.identity).toBe(r3Ident.identity); + }); + + it("should throw if not found", () => { + expect(() => { + updateResources(initialState, [ + { + ...r1, + id: "unknownId", + }, + ]); + }).toThrow(/does not exists/); + }); + + it("should be a no-op when new fields are strict-equal", () => { + const state = updateResources(initialState, [r1]); + expect(state).toBe(initialState); + }); + + it("should be a no-op when given no resources", () => { + const state = updateResources(initialState, []); + expect(state).toBe(initialState); + }); + }); + + describe("delete", () => { + beforeEach(() => { + initialState = insertResources(initialState, [r1, r2, r3]); + originalInitial = clone(initialState); + }); + + it("should work with objects", () => { + const state = removeResources(initialState, [r1]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should work with object subsets", () => { + const state = removeResources(initialState, [{ id: r1.id }]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should work with ids", () => { + const state = removeResources(initialState, [r1.id]); + + expect(initialState).toEqual(originalInitial); + expect(hasResource(state, r1.id)).toBe(false); + expect(hasResource(state, r2.id)).toBe(true); + expect(hasResource(state, r3.id)).toBe(true); + }); + + it("should throw if not found", () => { + expect(() => { + removeResources(initialState, [makeResource("unknownId")]); + }).toThrow(/does not exist/); + expect(() => { + removeResources(initialState, [{ id: "unknownId" }]); + }).toThrow(/does not exist/); + expect(() => { + removeResources(initialState, ["unknownId"]); + }).toThrow(/does not exist/); + }); + + it("should be a no-op when given no resources", () => { + const state = removeResources(initialState, []); + expect(state).toBe(initialState); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/resource/tests/query.spec.js b/devtools/client/debugger/src/utils/resource/tests/query.spec.js new file mode 100644 index 0000000000..adca73f750 --- /dev/null +++ b/devtools/client/debugger/src/utils/resource/tests/query.spec.js @@ -0,0 +1,1079 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + createInitial, + insertResources, + updateResources, + makeMapWithArgs, + makeWeakQuery, + makeShallowQuery, + makeStrictQuery, + type Id, + type Resource, + type ResourceValues, + type ResourceIdentity, + type QueryMapNoArgs, + type QueryMapWithArgs, + type QueryFilter, + type QueryReduce, +} from ".."; + +type TestResource = Resource<{ + id: string, + name: string, + data: number, + obj: {}, +}>; + +const makeResource = (id: string): TestResource => ({ + id, + name: `name-${id}`, + data: 42, + obj: {}, +}); + +// Jest's mock type just wouldn't cooperate below, so this is a custom version +// that does what I need. +type MockedFn<InputFn, OutputFn> = OutputFn & { + mock: { + calls: Array<any>, + }, + mockImplementation(fn: InputFn): void, +}; + +// We need to pass the 'needsArgs' prop through to the query fn so we use +// this utility to do that and at the same time preserve typechecking. +const mockFn = (f: any) => Object.assign((jest.fn(f): any), f); + +const mockFilter = <Args, F: QueryFilter<TestResource, Args>>( + callback: F +): MockedFn<F, F> => mockFn(callback); +const mockMapNoArgs = <Mapped, F: (TestResource, ResourceIdentity) => Mapped>( + callback: F +): MockedFn<F, QueryMapNoArgs<TestResource, Mapped>> => mockFn(callback); +const mockMapWithArgs = < + Args, + Mapped, + F: (TestResource, ResourceIdentity, Args) => Mapped +>( + callback: F +): MockedFn<F, QueryMapWithArgs<TestResource, Args, Mapped>> => + mockFn(makeMapWithArgs(callback)); +const mockReduce = < + Args, + Mapped, + Reduced, + F: QueryReduce<TestResource, Args, Mapped, Reduced> +>( + callback: F +): MockedFn<F, F> => mockFn(callback); + +type TestArgs = Array<Id<TestResource>>; +type TestReduced = { [Id<TestResource>]: TestResource }; + +describe("resource query operations", () => { + let r1, r2, r3; + let initialState; + let mapNoArgs, mapWithArgs, reduce; + + beforeEach(() => { + r1 = makeResource("id-1"); + r2 = makeResource("id-2"); + r3 = makeResource("id-3"); + + initialState = createInitial(); + + initialState = insertResources(initialState, [r1, r2, r3]); + + mapNoArgs = mockMapNoArgs( + (resource: TestResource, ident: ResourceIdentity): TestResource => + resource + ); + mapWithArgs = mockMapWithArgs( + ( + resource: TestResource, + ident: ResourceIdentity, + args: mixed + ): TestResource => resource + ); + reduce = mockReduce( + ( + mapped: $ReadOnlyArray<TestResource>, + ids: $ReadOnlyArray<string>, + args: mixed + ): TestReduced => { + return mapped.reduce((acc, item, i) => { + acc[ids[i]] = item; + return acc; + }, {}); + } + ); + }); + + describe("weak cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + (values: ResourceValues<TestResource>, args: TestArgs): TestArgs => args + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeWeakQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from first query still available. + const result3 = query(initialState, firstArgs); + expect(result3).not.toBe(result2); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from second query still available. + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result2); + expect(result4).not.toBe(result3); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(2); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeWeakQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from first query still available. + const result3 = query(initialState, firstArgs); + expect(result3).not.toBe(result2); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + + // Same result from second query still available. + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result2); + expect(result4).not.toBe(result3); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(2); + }); + }); + }); + + describe("shallow cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + ( + values: ResourceValues<TestResource>, + { ids }: { ids: TestArgs } + ): TestArgs => ids + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeShallowQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return last with same state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 1", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return new with updated id state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r2.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: { ...r2, name: "newName" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids, flag: true }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids, flag: false }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, { ids, flag: true }); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(3); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, { ids, flag: false }); + expect(result4).toBe(result1); + expect(filter.mock.calls).toHaveLength(4); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeShallowQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return last with same state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 1", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return last with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return new with updated id state and same args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r2.id, + name: "newName", + }, + ]); + + const result2 = query(state, { ids }); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: { ...r2, name: "newName" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const ids = [r1.id, r2.id]; + const result1 = query(initialState, { ids, flag: true }); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, { ids, flag: false }); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, { ids, flag: true }); + expect(result3).toBe(result1); + expect(filter.mock.calls).toHaveLength(3); + expect(mapWithArgs.mock.calls).toHaveLength(6); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, { ids, flag: false }); + expect(result4).toBe(result1); + expect(filter.mock.calls).toHaveLength(4); + expect(mapWithArgs.mock.calls).toHaveLength(8); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + }); + + describe("strict cache", () => { + let filter; + + beforeEach(() => { + filter = mockFilter( + // eslint-disable-next-line max-nested-callbacks + (values: ResourceValues<TestResource>, ids: Array<string>): TestArgs => + ids + ); + }); + + describe("no args", () => { + let query; + + beforeEach(() => { + query = makeStrictQuery({ filter, map: mapNoArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapNoArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, firstArgs); + expect(result3).toBe(result2); + expect(filter.mock.calls).toHaveLength(3); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result3); + expect(filter.mock.calls).toHaveLength(4); + expect(mapNoArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + + describe("with args", () => { + let query; + + beforeEach(() => { + query = makeStrictQuery({ filter, map: mapWithArgs, reduce }); + }); + + it("should return same with same state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 1", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Updating r2 does not affect cached result that only cares about r2. + const state = updateResources(initialState, [ + { + id: r3.id, + obj: {}, + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return same with updated other state and same args 2", () => { + // eslint-disable-next-line max-nested-callbacks + mapWithArgs.mockImplementation(resource => ({ ...resource, name: "" })); + + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the map function ignores the name value, updating it should + // not reset the cached for this query. + const state = updateResources(initialState, [ + { + id: r3.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "" }, + [r2.id]: { ...r2, name: "" }, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + }); + + it("should return diff with updated id state and same args", () => { + const args = [r1.id, r2.id]; + const result1 = query(initialState, args); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + // Since the mapper returns a value with name, changing a name will + // invalidate the cache. + const state = updateResources(initialState, [ + { + id: r1.id, + name: "newName", + }, + ]); + + const result2 = query(state, args); + expect(result2).not.toBe(result1); + expect(result2).toEqual({ + [r1.id]: { ...r1, name: "newName" }, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(3); + expect(reduce.mock.calls).toHaveLength(2); + }); + + it("should return diff with same state and diff args", () => { + const firstArgs = [r1.id, r2.id]; + const secondArgs = [r1.id, r2.id]; + + const result1 = query(initialState, firstArgs); + expect(result1).toEqual({ + [r1.id]: r1, + [r2.id]: r2, + }); + expect(filter.mock.calls).toHaveLength(1); + expect(mapWithArgs.mock.calls).toHaveLength(2); + expect(reduce.mock.calls).toHaveLength(1); + + const result2 = query(initialState, secondArgs); + expect(result2).toBe(result1); + expect(filter.mock.calls).toHaveLength(2); + expect(mapWithArgs.mock.calls).toHaveLength(4); + expect(reduce.mock.calls).toHaveLength(1); + + const result3 = query(initialState, firstArgs); + expect(result3).toBe(result2); + expect(filter.mock.calls).toHaveLength(3); + expect(mapWithArgs.mock.calls).toHaveLength(6); + expect(reduce.mock.calls).toHaveLength(1); + + const result4 = query(initialState, secondArgs); + expect(result4).toBe(result3); + expect(filter.mock.calls).toHaveLength(4); + expect(mapWithArgs.mock.calls).toHaveLength(8); + expect(reduce.mock.calls).toHaveLength(1); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/result-list.js b/devtools/client/debugger/src/utils/result-list.js new file mode 100644 index 0000000000..11fae2f8fa --- /dev/null +++ b/devtools/client/debugger/src/utils/result-list.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export function scrollList(resultList: Element[], index: number): void { + if (!resultList.hasOwnProperty(index)) { + return; + } + + const resultEl = resultList[index]; + + const scroll = () => { + // Avoid expensive DOM computations involved in scrollIntoView + // https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/ + requestAnimationFrame(() => { + setTimeout(() => { + resultEl.scrollIntoView({ block: "nearest", behavior: "auto" }); + }); + }); + }; + + scroll(); +} diff --git a/devtools/client/debugger/src/utils/selected-location.js b/devtools/client/debugger/src/utils/selected-location.js new file mode 100644 index 0000000000..a07877449f --- /dev/null +++ b/devtools/client/debugger/src/utils/selected-location.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { isOriginalId } from "devtools-source-map"; +import type { SourceLocation, MappedLocation, Source } from "../types"; + +export function getSelectedLocation( + mappedLocation: MappedLocation, + context: ?(Source | SourceLocation) +): SourceLocation { + if (!context) { + return mappedLocation.location; + } + + // $FlowIgnore + const sourceId = context.sourceId || context.id; + return isOriginalId(sourceId) + ? mappedLocation.location + : mappedLocation.generatedLocation; +} diff --git a/devtools/client/debugger/src/utils/source-maps.js b/devtools/client/debugger/src/utils/source-maps.js new file mode 100644 index 0000000000..ef273539c1 --- /dev/null +++ b/devtools/client/debugger/src/utils/source-maps.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import SourceMaps, { isOriginalId } from "devtools-source-map"; +import { getSource } from "../selectors"; + +import type { SourceLocation, MappedLocation, Source } from "../types"; + +export async function getGeneratedLocation( + state: Object, + source: Source, + location: SourceLocation, + sourceMaps: typeof SourceMaps +): Promise<SourceLocation> { + if (!isOriginalId(location.sourceId)) { + return location; + } + + const { line, sourceId, column } = await sourceMaps.getGeneratedLocation( + location + ); + + const generatedSource = getSource(state, sourceId); + if (!generatedSource) { + throw new Error(`Could not find generated source ${sourceId}`); + } + + return { + line, + sourceId, + column: column === 0 ? undefined : column, + sourceUrl: generatedSource.url, + }; +} + +export async function getOriginalLocation( + generatedLocation: SourceLocation, + sourceMaps: typeof SourceMaps +) { + if (isOriginalId(generatedLocation.sourceId)) { + return location; + } + + return sourceMaps.getOriginalLocation(generatedLocation); +} + +export async function getMappedLocation( + state: Object, + sourceMaps: typeof SourceMaps, + location: SourceLocation +): Promise<MappedLocation> { + const source = getSource(state, location.sourceId); + + if (!source) { + throw new Error(`no source ${location.sourceId}`); + } + + if (isOriginalId(location.sourceId)) { + const generatedLocation = await getGeneratedLocation( + state, + source, + location, + sourceMaps + ); + return { location, generatedLocation }; + } + + const generatedLocation = location; + const originalLocation = await sourceMaps.getOriginalLocation( + generatedLocation + ); + + return { location: originalLocation, generatedLocation }; +} + +export async function mapLocation( + state: Object, + sourceMaps: typeof SourceMaps, + location: SourceLocation +): Promise<SourceLocation> { + const source = getSource(state, location.sourceId); + + if (!source) { + return location; + } + + if (isOriginalId(location.sourceId)) { + return getGeneratedLocation(state, source, location, sourceMaps); + } + + return sourceMaps.getOriginalLocation(location); +} + +export function isOriginalSource(source: ?Source) { + if (!source) { + return false; + } + + if (!source.hasOwnProperty("isOriginal")) { + throw new Error("source must have an isOriginal property"); + } + + return source.isOriginal; +} diff --git a/devtools/client/debugger/src/utils/source-queue.js b/devtools/client/debugger/src/utils/source-queue.js new file mode 100644 index 0000000000..78aac2d5dc --- /dev/null +++ b/devtools/client/debugger/src/utils/source-queue.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { throttle } from "lodash"; +import type { QueuedSourceData } from "../types"; + +// This SourceQueue module is now only used for source mapped sources +let newQueuedSources; +let queuedSources; +let currentWork; + +async function dispatchNewSources(): Promise<void> { + const sources = queuedSources; + queuedSources = []; + currentWork = await newQueuedSources(sources); +} + +const queue = throttle(dispatchNewSources, 100); + +export default { + initialize: (actions: Object) => { + newQueuedSources = actions.newQueuedSources; + queuedSources = []; + }, + queue: (source: QueuedSourceData) => { + queuedSources.push(source); + queue(); + }, + queueSources: (sources: QueuedSourceData[]) => { + if (sources.length > 0) { + queuedSources = queuedSources.concat(sources); + queue(); + } + }, + + flush: () => Promise.all([queue.flush(), currentWork]), + clear: () => { + queuedSources = []; + queue.cancel(); + }, +}; diff --git a/devtools/client/debugger/src/utils/source.js b/devtools/client/debugger/src/utils/source.js new file mode 100644 index 0000000000..90459b6843 --- /dev/null +++ b/devtools/client/debugger/src/utils/source.js @@ -0,0 +1,573 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Utils for working with Source URLs + * @module utils/source + */ + +// $FlowIgnore +const { getUnicodeUrl } = require("devtools/client/shared/unicode-url"); + +import { isOriginalSource } from "../utils/source-maps"; +import { endTruncateStr } from "./utils"; +import { truncateMiddleText } from "../utils/text"; +import { parse as parseURL } from "../utils/url"; +import { memoizeLast } from "../utils/memoizeLast"; +import { renderWasmText } from "./wasm"; +import { toEditorLine } from "./editor"; +export { isMinified } from "./isMinified"; +import { getURL, getFileExtension } from "./sources-tree"; +import { features } from "./prefs"; + +import type { + SourceId, + Source, + SourceActor, + SourceContent, + SourceLocation, + Thread, + URL, +} from "../types"; + +import { isFulfilled, type AsyncValue } from "./async-value"; +import type { Symbols, TabsSources } from "../reducers/types"; + +type transformUrlCallback = string => string; + +export const sourceTypes = { + coffee: "coffeescript", + js: "javascript", + jsx: "react", + ts: "typescript", + tsx: "typescript", + vue: "vue", +}; + +const javascriptLikeExtensions = ["marko", "es6", "vue", "jsm"]; + +function getPath(source: Source): Array<string> { + const { path } = getURL(source); + let lastIndex = path.lastIndexOf("/"); + let nextToLastIndex = path.lastIndexOf("/", lastIndex - 1); + + const result = []; + do { + result.push(path.slice(nextToLastIndex + 1, lastIndex)); + lastIndex = nextToLastIndex; + nextToLastIndex = path.lastIndexOf("/", lastIndex - 1); + } while (lastIndex !== nextToLastIndex); + + result.push(""); + + return result; +} + +export function shouldBlackbox(source: ?Source) { + if (!source) { + return false; + } + + if (!source.url) { + return false; + } + + if (!features.originalBlackbox && isOriginalSource(source)) { + return false; + } + + return true; +} + +/** + * Returns true if the specified url and/or content type are specific to + * javascript files. + * + * @return boolean + * True if the source is likely javascript. + * + * @memberof utils/source + * @static + */ +export function isJavaScript(source: Source, content: SourceContent): boolean { + const extension = getFileExtension(source).toLowerCase(); + const contentType = content.type === "wasm" ? null : content.contentType; + return ( + javascriptLikeExtensions.includes(extension) || + !!(contentType && contentType.includes("javascript")) + ); +} + +/** + * @memberof utils/source + * @static + */ +export function isPretty(source: Source): boolean { + return isPrettyURL(source.url); +} + +export function isPrettyURL(url: URL): boolean { + return url ? url.endsWith(":formatted") : false; +} + +export function isThirdParty(source: Source): boolean { + const { url } = source; + if (!source || !url) { + return false; + } + + return url.includes("node_modules") || url.includes("bower_components"); +} + +/** + * @memberof utils/source + * @static + */ +export function getPrettySourceURL(url: ?URL): string { + if (!url) { + url = ""; + } + return `${url}:formatted`; +} + +/** + * @memberof utils/source + * @static + */ +export function getRawSourceURL(url: URL): string { + return url && url.endsWith(":formatted") + ? url.slice(0, -":formatted".length) + : url; +} + +function resolveFileURL( + url: URL, + transformUrl: transformUrlCallback = initialUrl => initialUrl, + truncate: boolean = true +): string { + url = getRawSourceURL(url || ""); + const name = transformUrl(url); + if (!truncate) { + return name; + } + return endTruncateStr(name, 50); +} + +export function getFormattedSourceId(id: string): string { + const firstIndex = id.indexOf("/"); + const secondIndex = id.indexOf("/", firstIndex); + return `SOURCE${id.slice(firstIndex, secondIndex)}`; +} + +/** + * Gets a readable filename from a source URL for display purposes. + * If the source does not have a URL, the source ID will be returned instead. + * + * @memberof utils/source + * @static + */ +export function getFilename( + source: Source, + rawSourceURL: URL = getRawSourceURL(source.url) +): string { + const { id } = source; + if (!rawSourceURL) { + return getFormattedSourceId(id); + } + + const { filename } = getURL(source); + return getRawSourceURL(filename); +} + +/** + * Provides a middle-trunated filename + * + * @memberof utils/source + * @static + */ +export function getTruncatedFileName( + source: Source, + querystring: string = "", + length: number = 30 +): string { + return truncateMiddleText(`${getFilename(source)}${querystring}`, length); +} + +/* Gets path for files with same filename for editor tabs, breakpoints, etc. + * Pass the source, and list of other sources + * + * @memberof utils/source + * @static + */ + +export function getDisplayPath( + mySource: Source, + sources: Source[] | TabsSources +): string | void { + const rawSourceURL = getRawSourceURL(mySource.url); + const filename = getFilename(mySource, rawSourceURL); + + // Find sources that have the same filename, but different paths + // as the original source + const similarSources = sources.filter(source => { + const rawSource = getRawSourceURL(source.url); + return ( + rawSourceURL != rawSource && filename == getFilename(source, rawSource) + ); + }); + + if (similarSources.length == 0) { + return undefined; + } + + // get an array of source path directories e.g. ['a/b/c.html'] => [['b', 'a']] + const paths = new Array(similarSources.length + 1); + + paths[0] = getPath(mySource); + for (let i = 0; i < similarSources.length; ++i) { + paths[i + 1] = getPath(similarSources[i]); + } + + // create an array of similar path directories and one dis-similar directory + // for example [`a/b/c.html`, `a1/b/c.html`] => ['b', 'a'] + // where 'b' is the similar directory and 'a' is the dis-similar directory. + let displayPath = ""; + for (let i = 0; i < paths[0].length; i++) { + let similar = false; + for (let k = 1; k < paths.length; ++k) { + if (paths[k][i] === paths[0][i]) { + similar = true; + break; + } + } + + displayPath = paths[0][i] + (i !== 0 ? "/" : "") + displayPath; + + if (!similar) { + break; + } + } + + return displayPath; +} + +/** + * Gets a readable source URL for display purposes. + * If the source does not have a URL, the source ID will be returned instead. + * + * @memberof utils/source + * @static + */ +export function getFileURL(source: Source, truncate: boolean = true): string { + const { url, id } = source; + if (!url) { + return getFormattedSourceId(id); + } + + return resolveFileURL(url, getUnicodeUrl, truncate); +} + +const contentTypeModeMap = { + "text/javascript": { name: "javascript" }, + "text/typescript": { name: "javascript", typescript: true }, + "text/coffeescript": { name: "coffeescript" }, + "text/typescript-jsx": { + name: "jsx", + base: { name: "javascript", typescript: true }, + }, + "text/jsx": { name: "jsx" }, + "text/x-elm": { name: "elm" }, + "text/x-clojure": { name: "clojure" }, + "text/x-clojurescript": { name: "clojure" }, + "text/wasm": { name: "text" }, + "text/html": { name: "htmlmixed" }, +}; + +export function getSourcePath(url: URL): string { + if (!url) { + return ""; + } + + const { path, href } = parseURL(url); + // for URLs like "about:home" the path is null so we pass the full href + return path || href; +} + +/** + * Returns amount of lines in the source. If source is a WebAssembly binary, + * the function returns amount of bytes. + */ +export function getSourceLineCount(content: SourceContent): number { + if (content.type === "wasm") { + const { binary } = content.value; + return binary.length; + } + + let count = 0; + + for (let i = 0; i < content.value.length; ++i) { + if (content.value[i] === "\n") { + ++count; + } + } + + return count + 1; +} + +/** + * + * Checks if a source is minified based on some heuristics + * @param key + * @param text + * @return boolean + * @memberof utils/source + * @static + */ + +/** + * + * Returns Code Mirror mode for source content type + * @param contentType + * @return String + * @memberof utils/source + * @static + */ +// eslint-disable-next-line complexity +export function getMode( + source: Source, + content: SourceContent, + symbols?: Symbols +): { name: string, base?: Object } { + const extension = getFileExtension(source); + + if (content.type !== "text") { + return { name: "text" }; + } + + const { contentType, value: text } = content; + + if (extension === "jsx" || (symbols && symbols.hasJsx)) { + if (symbols && symbols.hasTypes) { + return { name: "text/typescript-jsx" }; + } + return { name: "jsx" }; + } + + if (symbols && symbols.hasTypes) { + if (symbols.hasJsx) { + return { name: "text/typescript-jsx" }; + } + + return { name: "text/typescript" }; + } + + const languageMimeMap = [ + { ext: "c", mode: "text/x-csrc" }, + { ext: "kt", mode: "text/x-kotlin" }, + { ext: "cpp", mode: "text/x-c++src" }, + { ext: "m", mode: "text/x-objectivec" }, + { ext: "rs", mode: "text/x-rustsrc" }, + { ext: "hx", mode: "text/x-haxe" }, + ]; + + // check for C and other non JS languages + const result = languageMimeMap.find(({ ext }) => extension === ext); + if (result !== undefined) { + return { name: result.mode }; + } + + // if the url ends with a known Javascript-like URL, provide JavaScript mode. + // uses the first part of the URL to ignore query string + if (javascriptLikeExtensions.find(ext => ext === extension)) { + return { name: "javascript" }; + } + + // Use HTML mode for files in which the first non whitespace + // character is `<` regardless of extension. + const isHTMLLike = text.match(/^\s*</); + if (!contentType) { + if (isHTMLLike) { + return { name: "htmlmixed" }; + } + return { name: "text" }; + } + + // // @flow or /* @flow */ + if (text.match(/^\s*(\/\/ @flow|\/\* @flow \*\/)/)) { + return contentTypeModeMap["text/typescript"]; + } + + if (/script|elm|jsx|clojure|wasm|html/.test(contentType)) { + if (contentType in contentTypeModeMap) { + return contentTypeModeMap[contentType]; + } + + return contentTypeModeMap["text/javascript"]; + } + + if (isHTMLLike) { + return { name: "htmlmixed" }; + } + + return { name: "text" }; +} + +export function isInlineScript(source: SourceActor): boolean { + return source.introductionType === "scriptElement"; +} + +function getNthLine(str: string, lineNum: number) { + let startIndex = -1; + + let newLinesFound = 0; + while (newLinesFound < lineNum) { + const nextIndex = str.indexOf("\n", startIndex + 1); + if (nextIndex === -1) { + return null; + } + startIndex = nextIndex; + newLinesFound++; + } + const endIndex = str.indexOf("\n", startIndex + 1); + if (endIndex === -1) { + return str.slice(startIndex + 1); + } + + return str.slice(startIndex + 1, endIndex); +} + +export const getLineText = memoizeLast( + ( + sourceId: SourceId, + asyncContent: AsyncValue<SourceContent> | null, + line: number + ) => { + if (!asyncContent || !isFulfilled(asyncContent)) { + return ""; + } + + const content = asyncContent.value; + + if (content.type === "wasm") { + const editorLine = toEditorLine(sourceId, line); + const lines = renderWasmText(sourceId, content); + return lines[editorLine] || ""; + } + + const lineText = getNthLine(content.value, line - 1); + return lineText || ""; + } +); + +export function getTextAtPosition( + sourceId: SourceId, + asyncContent: AsyncValue<SourceContent> | null, + location: SourceLocation +): string { + const { column, line = 0 } = location; + + const lineText = getLineText(sourceId, asyncContent, line); + return lineText.slice(column, column + 100).trim(); +} + +export function getSourceClassnames( + source: ?Object, + symbols: ?Symbols +): string { + // Conditionals should be ordered by priority of icon! + const defaultClassName = "file"; + + if (!source || !source.url) { + return defaultClassName; + } + + if (isPretty(source)) { + return "prettyPrint"; + } + + if (source.isBlackBoxed) { + return "blackBox"; + } + + if (symbols && !symbols.loading && symbols.framework) { + return symbols.framework.toLowerCase(); + } + + if (isUrlExtension(source.url)) { + return "extension"; + } + + return sourceTypes[getFileExtension(source)] || defaultClassName; +} + +export function getRelativeUrl(source: Source, root: string): string { + const { group, path } = getURL(source); + if (!root) { + return path; + } + + // + 1 removes the leading "/" + const url = group + path; + return url.slice(url.indexOf(root) + root.length + 1); +} + +export function underRoot( + source: Source, + root: string, + threads: Array<Thread> +): boolean { + // source.url doesn't include thread actor ID, so remove the thread actor ID from the root + threads.forEach(thread => { + if (root.includes(thread.actor)) { + root = root.slice(thread.actor.length + 1); + } + }); + + if (source.url && source.url.includes("chrome://")) { + const { group, path } = getURL(source); + return (group + path).includes(root); + } + + return !!source.url && source.url.includes(root); +} + +export function isOriginal(source: Source): boolean { + // Pretty-printed sources are given original IDs, so no need + // for any additional check + return isOriginalSource(source); +} + +export function isGenerated(source: Source): boolean { + return !isOriginal(source); +} + +export function getSourceQueryString(source: ?Source) { + if (!source) { + return; + } + + return parseURL(getRawSourceURL(source.url)).search; +} + +export function isUrlExtension(url: URL): boolean { + return url.includes("moz-extension:") || url.includes("chrome-extension"); +} + +export function isExtensionDirectoryPath(url: URL): ?boolean { + if (isUrlExtension(url)) { + const urlArr = url.replace(/\/+/g, "/").split("/"); + let extensionIndex = urlArr.indexOf("moz-extension:"); + if (extensionIndex === -1) { + extensionIndex = urlArr.indexOf("chrome-extension:"); + } + return !urlArr[extensionIndex + 2]; + } +} + +export function getPlainUrl(url: URL): string { + const queryStart = url.indexOf("?"); + return queryStart !== -1 ? url.slice(0, queryStart) : url; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/addToTree.js b/devtools/client/debugger/src/utils/sources-tree/addToTree.js new file mode 100644 index 0000000000..fdc1f97ccd --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/addToTree.js @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + nodeHasChildren, + isPathDirectory, + isInvalidUrl, + partIsFile, + createSourceNode, + createDirectoryNode, + getPathParts, + type PathPart, +} from "./utils"; +import { createTreeNodeMatcher, findNodeInContents } from "./treeOrder"; +import { getDisplayURL } from "./getURL"; + +import type { ParsedURL } from "./getURL"; +import type { TreeDirectory, TreeNode } from "./types"; +import type { DisplaySource, Source } from "../../types"; + +function createNodeInTree( + part: string, + path: string, + tree: TreeDirectory, + index: number +): TreeDirectory { + const node = createDirectoryNode(part, path, []); + + // we are modifying the tree + const contents = tree.contents.slice(0); + contents.splice(index, 0, node); + tree.contents = contents; + + return node; +} + +/* + * Look for the child node + * 1. if it exists return it + * 2. if it does not exist create it + */ +function findOrCreateNode( + parts: PathPart[], + subTree: TreeDirectory, + path: string, + part: string, + index: number, + url: Object, + debuggeeHost: ?string, + source: Source +): TreeDirectory { + const addedPartIsFile = partIsFile(index, parts, url); + + const { found: childFound, index: childIndex } = findNodeInContents( + subTree, + createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost) + ); + + // we create and enter the new node + if (!childFound) { + return createNodeInTree(part, path, subTree, childIndex); + } + + // we found a path with the same name as the part. We need to determine + // if this is the correct child, or if we have a naming conflict + const child = subTree.contents[childIndex]; + const childIsFile = !nodeHasChildren(child); + + // if we have a naming conflict, we'll create a new node + if (childIsFile != addedPartIsFile) { + // pass true to findNodeInContents to sort node by url + const { index: insertIndex } = findNodeInContents( + subTree, + createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost, source, true) + ); + return createNodeInTree(part, path, subTree, insertIndex); + } + + // if there is no naming conflict, we can traverse into the child + return (child: any); +} + +/* + * walk the source tree to the final node for a given url, + * adding new nodes along the way + */ +function traverseTree( + url: ParsedURL, + tree: TreeDirectory, + debuggeeHost: ?string, + source: Source, + thread: string +): TreeNode { + const parts = getPathParts(url, thread, debuggeeHost); + return parts.reduce( + (subTree, { part, path, debuggeeHostIfRoot }, index) => + findOrCreateNode( + parts, + subTree, + path, + part, + index, + url, + debuggeeHostIfRoot, + source + ), + tree + ); +} + +/* + * Add a source file to a directory node in the tree + */ +function addSourceToNode( + node: TreeDirectory, + url: ParsedURL, + source: Source +): Source | TreeNode[] { + const isFile = !isPathDirectory(url.path); + + if (node.type == "source" && !isFile) { + throw new Error(`Unexpected type "source" at: ${node.name}`); + } + + // if we have a file, and the subtree has no elements, overwrite the + // subtree contents with the source + if (isFile) { + // $FlowIgnore + node.type = "source"; + return source; + } + + let { filename } = url; + + if (filename === "(index)" && url.search) { + filename = url.search; + } else { + filename += url.search; + } + + const { found: childFound, index: childIndex } = findNodeInContents( + node, + createTreeNodeMatcher(filename, false, null) + ); + + // if we are readding an existing file in the node, overwrite the existing + // file and return the node's contents + if (childFound) { + const existingNode = node.contents[childIndex]; + if (existingNode.type === "source") { + existingNode.contents = source; + } + + return node.contents; + } + + // if this is a new file, add the new file; + const newNode = createSourceNode(filename, source.url, source); + const contents = node.contents.slice(0); + contents.splice(childIndex, 0, newNode); + return contents; +} + +/** + * @memberof utils/sources-tree + * @static + */ +export function addToTree( + tree: TreeDirectory, + source: DisplaySource, + debuggeeHost: ?string, + thread: string +): void { + const url = getDisplayURL(source, debuggeeHost); + + if (isInvalidUrl(url, source)) { + return; + } + + const finalNode = traverseTree(url, tree, debuggeeHost, source, thread); + + // $FlowIgnore + finalNode.contents = addSourceToNode(finalNode, url, source); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/collapseTree.js b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js new file mode 100644 index 0000000000..57d3e89849 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createDirectoryNode } from "./utils"; + +import type { TreeDirectory, TreeNode } from "./types"; + +/** + * Take an existing source tree, and return a new one with collapsed nodes. + */ +function _collapseTree(node: TreeNode, depth: number): TreeNode { + // Node is a folder. + if (node.type === "directory") { + if (!Array.isArray(node.contents)) { + console.log(`Expected array at: ${node.path}`); + } + + // Node is not a (1) thread and (2) root/domain node, + // and only contains 1 item. + if (depth > 2 && node.contents.length === 1) { + const next = node.contents[0]; + // Do not collapse if the next node is a leaf node. + if (next.type === "directory") { + if (!Array.isArray(next.contents)) { + console.log( + `Expected array at: ${next.name} -- ${ + node.name + } -- ${JSON.stringify(next.contents)}` + ); + } + const name = `${node.name}/${next.name}`; + const nextNode = createDirectoryNode(name, next.path, next.contents); + return _collapseTree(nextNode, depth + 1); + } + } + + // Map the contents. + return createDirectoryNode( + node.name, + node.path, + node.contents.map(next => _collapseTree(next, depth + 1)) + ); + } + + // Node is a leaf, not a folder, do not modify it. + return node; +} + +export function collapseTree(node: TreeDirectory): TreeDirectory { + const tree = _collapseTree(node, 0); + return ((tree: any): TreeDirectory); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/formatTree.js b/devtools/client/debugger/src/utils/sources-tree/formatTree.js new file mode 100644 index 0000000000..83598a0e44 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/formatTree.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { TreeNode } from "./types"; + +export function formatTree( + tree: TreeNode, + depth: number = 0, + str: string = "" +): string { + const whitespace = new Array(depth * 2).join(" "); + + if (tree.type === "directory") { + str += `${whitespace} - ${tree.name} path=${tree.path} \n`; + tree.contents.forEach(t => { + str = formatTree(t, depth + 1, str); + }); + } else { + str += `${whitespace} - ${tree.name} path=${tree.path} source_id=${tree.contents.id} \n`; + } + + return str; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/getDirectories.js b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js new file mode 100644 index 0000000000..fe5b813d09 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createParentMap } from "./utils"; +import flattenDeep from "lodash/flattenDeep"; +import type { TreeNode, TreeDirectory } from "./types"; +import type { Source } from "../../types"; + +function findSourceItem(sourceTree: TreeDirectory, source: Source): ?TreeNode { + function _traverse(subtree: TreeNode) { + if (subtree.type === "source") { + if (subtree.contents.id === source.id) { + return subtree; + } + + return null; + } + + const matches = subtree.contents.map(child => _traverse(child)); + return matches && matches.filter(Boolean)[0]; + } + + return _traverse(sourceTree); +} + +export function findSourceTreeNodes( + sourceTree: TreeDirectory, + path: string +): TreeNode[] { + function _traverse(subtree: TreeNode) { + if (subtree.path.endsWith(path)) { + return subtree; + } + + if (subtree.type === "directory") { + const matches = subtree.contents.map(child => _traverse(child)); + return matches && matches.filter(Boolean); + } + } + + const result = _traverse(sourceTree); + // $FlowIgnore + return Array.isArray(result) ? flattenDeep(result) : result; +} + +function getAncestors(sourceTree: TreeDirectory, item: ?TreeNode) { + if (!item) { + return null; + } + + const parentMap = createParentMap(sourceTree); + const directories = []; + + directories.push(item); + while (true) { + item = parentMap.get(item); + if (!item) { + return directories; + } + directories.push(item); + } +} + +export function getDirectories(source: Source, sourceTree: TreeDirectory) { + const item = findSourceItem(sourceTree, source); + const ancestors = getAncestors(sourceTree, item); + return ancestors || [sourceTree]; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/getURL.js b/devtools/client/debugger/src/utils/sources-tree/getURL.js new file mode 100644 index 0000000000..78713db17e --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/getURL.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { parse } from "../url"; + +const { + getUnicodeHostname, + getUnicodeUrlPath, + // $FlowIgnore +} = require("devtools/client/shared/unicode-url"); + +import type { DisplaySource, Source } from "../../types"; +export type ParsedURL = { + path: string, + search: string, + group: string, + filename: string, +}; + +export function getFilenameFromPath(pathname?: string): string { + let filename = ""; + if (pathname) { + filename = pathname.substring(pathname.lastIndexOf("/") + 1); + // This file does not have a name. Default should be (index). + if (filename == "") { + filename = "(index)"; + } + } + return filename; +} + +const NoDomain = "(no domain)"; +const def = { path: "", search: "", group: "", filename: "" }; + +export function getURL(source: Source, defaultDomain: ?string = ""): ParsedURL { + const { url } = source; + if (!url) { + return def; + } + return getURLInternal(url, defaultDomain); +} + +export function getDisplayURL( + source: DisplaySource, + defaultDomain: ?string = "" +): ParsedURL { + const { displayURL } = source; + if (!displayURL) { + return def; + } + return getURLInternal(displayURL, defaultDomain); +} + +function getURLInternal(url: string, defaultDomain: ?string): ParsedURL { + const { pathname, search, protocol, host } = parse(url); + const filename = getUnicodeUrlPath(getFilenameFromPath(pathname)); + + switch (protocol) { + case "javascript:": + // Ignore `javascript:` URLs for now + return def; + + case "moz-extension:": + case "resource:": + return { + ...def, + path: pathname, + search, + filename, + group: `${protocol}//${host || ""}`, + }; + + case "webpack:": + case "ng:": + return { + ...def, + path: pathname, + search, + filename, + group: `${protocol}//`, + }; + + case "about:": + // An about page is a special case + return { + ...def, + path: "/", + search, + filename, + group: url, + }; + + case "data:": + return { + ...def, + path: "/", + search, + group: NoDomain, + filename: url, + }; + + case "": + if (pathname && pathname.startsWith("/")) { + // use file protocol for a URL like "/foo/bar.js" + return { + ...def, + path: pathname, + search, + filename, + group: "file://", + }; + } else if (!host) { + return { + ...def, + path: pathname, + search, + group: defaultDomain || "", + filename, + }; + } + break; + + case "http:": + case "https:": + return { + ...def, + path: pathname, + search, + filename, + group: getUnicodeHostname(host), + }; + } + + return { + ...def, + path: pathname, + search, + group: protocol ? `${protocol}//` : "", + filename, + }; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/index.js b/devtools/client/debugger/src/utils/sources-tree/index.js new file mode 100644 index 0000000000..e9cc35e1c4 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/index.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Utils for Sources Tree Component + * @module utils/sources-tree + */ + +export { addToTree } from "./addToTree"; +export { collapseTree } from "./collapseTree"; +export { formatTree } from "./formatTree"; +export { getDirectories, findSourceTreeNodes } from "./getDirectories"; +export { getFilenameFromPath, getURL } from "./getURL"; +export { sortTree } from "./sortTree"; +export { createTree, updateTree } from "./updateTree"; + +export * from "./utils"; diff --git a/devtools/client/debugger/src/utils/sources-tree/moz.build b/devtools/client/debugger/src/utils/sources-tree/moz.build new file mode 100644 index 0000000000..f1439a46e3 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "addToTree.js", + "collapseTree.js", + "formatTree.js", + "getDirectories.js", + "getURL.js", + "index.js", + "sortTree.js", + "treeOrder.js", + "updateTree.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/sources-tree/sortTree.js b/devtools/client/debugger/src/utils/sources-tree/sortTree.js new file mode 100644 index 0000000000..c090917fea --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/sortTree.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { nodeHasChildren, isExactUrlMatch } from "./utils"; + +import type { TreeDirectory } from "./types"; +import type { URL } from "../../types"; + +/** + * Look at the nodes in the source tree, and determine the index of where to + * insert a new node. The ordering is index -> folder -> file. + * @memberof utils/sources-tree + * @static + */ +export function sortTree(tree: TreeDirectory, debuggeeUrl: URL = ""): number { + return (tree.contents: any).sort((previousNode, currentNode) => { + const currentNodeIsDir = nodeHasChildren(currentNode); + const previousNodeIsDir = nodeHasChildren(previousNode); + if (currentNode.name === "(index)") { + return 1; + } else if (previousNode.name === "(index)") { + return -1; + } else if (isExactUrlMatch(currentNode.name, debuggeeUrl)) { + return 1; + } else if (isExactUrlMatch(previousNode.name, debuggeeUrl)) { + return -1; + // If neither is the case, continue to compare alphabetically + } else if (previousNodeIsDir && !currentNodeIsDir) { + return -1; + } else if (!previousNodeIsDir && currentNodeIsDir) { + return 1; + } + return previousNode.name.localeCompare(currentNode.name); + }); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap new file mode 100644 index 0000000000..a76261e0dc --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources-tree addToTree can add a file to an intermediate directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - unpkg.com path=FakeThread/unpkg.com + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 + - mode path=FakeThread/unpkg.com/codemirror@5.1/mode + - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml + - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39 + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree correctly parses file sources 1`] = ` +" - root path= + - FakeThread path=FakeThread + - file:// path=FakeThread/file:// + - a path=FakeThread/file:///a + - b.js path=FakeThread/file:///a/b.js source_id=actor1 +" +`; + +exports[`sources-tree addToTree does not attempt to add two of the same directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - davidwalsh.name path=FakeThread/davidwalsh.name + - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/37 + - wp-content path=FakeThread/davidwalsh.name/wp-content + - prism.js path=FakeThread/davidwalsh.name/wp-content/prism.js source_id=server1.conn13.child1/39 +" +`; + +exports[`sources-tree addToTree does not attempt to add two of the same file 1`] = ` +" - root path= + - FakeThread path=FakeThread + - davidwalsh.name path=FakeThread/davidwalsh.name + - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/39 + - util.js path=FakeThread/davidwalsh.name/util.js source_id=server1.conn13.child1/37 + - FakeThread2 path=FakeThread2 + - davidwalsh.name path=FakeThread2/davidwalsh.name + - util.js path=FakeThread2/davidwalsh.name/util.js source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree does not mangle encoded URLs 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - foo path=FakeThread/example.com/foo + - B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 path=FakeThread/example.com/foo/B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 source_id=actor1 +" +`; + +exports[`sources-tree addToTree excludes javascript: URLs from the tree 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - source1.js path=FakeThread/example.com/source1.js source_id=actor2 +" +`; + +exports[`sources-tree addToTree name does include query params 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - foo path=FakeThread/example.com/foo + - name.js?bar=3 path=FakeThread/example.com/foo/name.js?bar=3 source_id=actor1 +" +`; + +exports[`sources-tree addToTree replaces a file with a directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - unpkg.com path=FakeThread/unpkg.com + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 + - mode path=FakeThread/unpkg.com/codemirror@5.1/mode + - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml + - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39 + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree supports data URLs 1`] = ` +" - root path= + - FakeThread path=FakeThread + - (no domain) path=FakeThread/(no domain) + - data:text/html,<script>console.log(123)</script> path=data:text/html,<script>console.log(123)</script> source_id=server1.conn13.child1/39 +" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap new file mode 100644 index 0000000000..529874f8bd --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources tree collapseTree can collapse a single source 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with a deeper level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c/d path=Main Thread/example.com/a/b/c/d + - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2 + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with a shallower level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 + - x.js path=Main Thread/example.com/a/b/x.js source_id=actor3 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with the same level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c/d path=Main Thread/example.com/a/b/c/d + - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2 + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap new file mode 100644 index 0000000000..d6bc9a653e --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`calls updateTree.js adds one source 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js adds two sources 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source2.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source2.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/40\\", + \\"url\\": \\"https://davidwalsh.name/source2.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source2.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source2.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js shows all the sources 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js update sources that change their display URL 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"?param\\", + \\"path\\": \\"FakeThread/davidwalsh.name/?param\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/?param\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/?param\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/?param\\" + } + } + ] + } + ] + } + ] +}" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js new file mode 100644 index 0000000000..392d854bea --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js @@ -0,0 +1,374 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/* eslint max-nested-callbacks: ["error", 4]*/ + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { + addToTree, + createDirectoryNode, + createSourceNode, + createTree, + formatTree, + nodeHasChildren, +} from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return sourcesMap; +} + +function createSourcesList(sources: { url: string, id?: string }[]) { + return sources.map((s, i) => makeMockDisplaySource(s.url, s.id)); +} + +function getChildNode(tree, ...path) { + return path.reduce((child, index) => child.contents[index], tree); +} + +describe("sources-tree", () => { + describe("addToTree", () => { + it("should provide node API", () => { + const source = makeMockDisplaySource( + "http://example.com/a/b/c.js", + "actor1" + ); + + const root = createDirectoryNode("root", "", [ + createSourceNode("foo", "/foo", source), + ]); + + expect(root.name).toBe("root"); + expect(nodeHasChildren(root)).toBe(true); + expect(root.contents).toHaveLength(1); + + const child = root.contents[0]; + expect(child.name).toBe("foo"); + expect(child.path).toBe("/foo"); + expect(child.contents).toBe(source); + expect(nodeHasChildren(child)).toBe(false); + }); + + it("builds a path-based tree", () => { + const source1 = makeMockDisplaySource( + "http://example.com/foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0].contents[0]; + expect(base.name).toBe("example.com"); + expect(base.contents).toHaveLength(1); + + const fooNode = base.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("builds a path-based tree for webpack URLs", () => { + const source1 = makeMockDisplaySource( + "webpack:///foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("webpack://"); + expect(base.contents).toHaveLength(1); + + const fooNode = base.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("builds a path-based tree for webpack URLs with absolute path", () => { + const source1 = makeMockDisplaySource( + "webpack:////Users/foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("webpack://"); + expect(base.contents).toHaveLength(1); + + const emptyNode = base.contents[0]; + expect(emptyNode.name).toBe(""); + expect(emptyNode.contents).toHaveLength(1); + + const userNode = emptyNode.contents[0]; + expect(userNode.name).toBe("Users"); + expect(userNode.contents).toHaveLength(1); + + const fooNode = userNode.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("handles url with no filename", function() { + const source1 = makeMockDisplaySource("http://example.com/", "actor1"); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("example.com"); + expect(base.contents).toHaveLength(1); + + const indexNode = base.contents[0]; + expect(indexNode.name).toBe("(index)"); + }); + + it("does not mangle encoded URLs", () => { + const sourceName = // eslint-disable-next-line max-len + "B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1"; + + const source1 = makeMockDisplaySource( + `https://example.com/foo/${sourceName}`, + "actor1" + ); + + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + const childNode = getChildNode(tree, 0, 0, 0, 0); + expect(childNode.name).toEqual(sourceName); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("name does include query params", () => { + const sourceName = "name.js?bar=3"; + + const source1 = makeMockDisplaySource( + `https://example.com/foo/${sourceName}`, + "actor1" + ); + + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("does not attempt to add two of the same directory", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/wp-content/prism.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/", + }, + ]; + + const sourceMap = { FakeThread: createSourcesMap(sources) }; + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "", + threads: [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + isTopLevel: false, + }, + ], + }).sourceTree; + + expect(tree.contents[0].contents).toHaveLength(1); + const subtree = tree.contents[0].contents[0]; + expect(subtree.contents).toHaveLength(2); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("supports data URLs", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "data:text/html,<script>console.log(123)</script>", + }, + ]; + + const sourceMap = { FakeThread: createSourcesMap(sources) }; + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "", + threads: [ + { + actor: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ], + }).sourceTree; + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("does not attempt to add two of the same file", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/util.js", + }, + ]; + + const sourceMap = { + FakeThread: createSourcesMap(sources), + FakeThread2: createSourcesMap([sources[1]]), + }; + + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "https://davidwalsh.name", + threads: [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + isTopLevel: false, + }, + { + actor: "FakeThread2", + name: "FakeThread2", + url: "https://davidwalsh.name/WorkerA.js", + targetType: "worker", + isTopLevel: false, + }, + ], + }).sourceTree; + + expect(tree.contents[0].contents).toHaveLength(1); + const subtree = tree.contents[0].contents[0]; + expect(subtree.contents).toHaveLength(2); + const subtree2 = tree.contents[1].contents[0]; + expect(subtree2.contents).toHaveLength(1); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("excludes javascript: URLs from the tree", () => { + const source1 = makeMockDisplaySource( + "javascript:alert('Hello World')", + "actor1" + ); + const source2 = makeMockDisplaySource( + "http://example.com/source1.js", + "actor2" + ); + const source3 = makeMockDisplaySource( + "javascript:let i = 10; while (i > 0) i--; console.log(i);", + "actor3" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + addToTree(tree, source2, "http://example.com/", "FakeThread"); + addToTree(tree, source3, "http://example.com/", "FakeThread"); + + const base = tree.contents[0].contents[0]; + expect(tree.contents).toHaveLength(1); + + const source1Node = base.contents[0]; + expect(source1Node.name).toBe("source1.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly parses file sources", () => { + const source = makeMockDisplaySource("file:///a/b.js", "actor1"); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source, "file:///a/index.html", "FakeThread"); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0].contents[0]; + expect(base.name).toBe("file://"); + expect(base.contents).toHaveLength(1); + + const aNode = base.contents[0]; + expect(aNode.name).toBe("a"); + expect(aNode.contents).toHaveLength(1); + + const bNode = aNode.contents[0]; + expect(bNode.name).toBe("b.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("can add a file to an intermediate directory", () => { + const testData = [ + { + id: "server1.conn13.child1/39", + url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://unpkg.com/codemirror@5.1", + }, + ]; + + const sources = createSourcesList(testData); + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "https://unpkg.com/", "FakeThread") + ); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("replaces a file with a directory", () => { + const testData = [ + { + id: "server1.conn13.child1/37", + url: "https://unpkg.com/codemirror@5.1", + }, + + { + id: "server1.conn13.child1/39", + url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js", + }, + ]; + + const sources = createSourcesList(testData); + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "https://unpkg.com/", "FakeThread") + ); + expect(formatTree(tree)).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js new file mode 100644 index 0000000000..fba925c2ed --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { + collapseTree, + formatTree, + addToTree, + createDirectoryNode, +} from "../index"; + +const abcSource = makeMockDisplaySource( + "http://example.com/a/b/c.js", + "actor1" +); +const abcdeSource = makeMockDisplaySource( + "http://example.com/a/b/c/d/e.js", + "actor2" +); +const abxSource = makeMockDisplaySource( + "http://example.com/a/b/x.js", + "actor3" +); + +describe("sources tree", () => { + describe("collapseTree", () => { + it("can collapse a single source", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + expect(fullTree.contents).toHaveLength(1); + const tree = collapseTree(fullTree); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(1); + + const abcNode = abFolder.contents[0]; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with a deeper level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [cdFolder, abcNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(cdFolder.name).toBe("c/d"); + + const [abcdeNode] = cdFolder.contents; + expect(abcdeNode.name).toBe("e.js"); + expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with a shallower level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abxSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + expect(tree.contents).toHaveLength(1); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [abcNode, abxNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(abxNode.name).toBe("x.js"); + expect(abxNode.path).toBe("Main Thread/example.com/a/b/x.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with the same level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + expect(tree.contents).toHaveLength(1); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [cdFolder, abcNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(cdFolder.name).toBe("c/d"); + + const [abcdeNode] = cdFolder.contents; + expect(abcdeNode.name).toBe("e.js"); + expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js new file mode 100644 index 0000000000..ffcad21905 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { getDirectories, findSourceTreeNodes, createTree } from "../index"; + +function formatDirectories(source, tree) { + const paths: any = getDirectories(source, tree); + return paths.map(node => node.path); +} + +function createSources(urls) { + return { + FakeThread: urls.reduce((sources, url, index) => { + const id = `a${index}`; + sources[id] = makeMockDisplaySource(url, id); + return sources; + }, {}), + }; +} + +describe("getDirectories", () => { + it("gets a source's ancestor directories", function() { + const sources = createSources([ + "http://a/b.js", + "http://a/c.js", + "http://b/c.js", + ]); + + const threads = [ + { + actor: "FakeThread", + url: "http://a", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ]; + + const debuggeeUrl = "http://a/"; + const { sourceTree } = createTree({ + sources, + debuggeeUrl, + threads, + }); + + expect(formatDirectories(sources.FakeThread.a0, sourceTree)).toEqual([ + "FakeThread/a/b.js", + "FakeThread/a", + "FakeThread", + ]); + expect(formatDirectories(sources.FakeThread.a1, sourceTree)).toEqual([ + "FakeThread/a/c.js", + "FakeThread/a", + "FakeThread", + ]); + expect(formatDirectories(sources.FakeThread.a2, sourceTree)).toEqual([ + "FakeThread/b/c.js", + "FakeThread/b", + "FakeThread", + ]); + }); +}); + +describe("findSourceTreeNodes", () => { + it("finds a node", () => { + const sources = createSources([ + "http://src/main.js", + "http://src/utils/help.js", + "http://src/utils/print.js", + "http://workers/worker.js", + ]); + + const threads = [ + { + actor: "FakeThread", + url: "http://a", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ]; + + const debuggeeUrl = "http://a/"; + const { sourceTree } = createTree({ + sources, + debuggeeUrl, + threads, + }); + + const nodes = findSourceTreeNodes(sourceTree, "src") || []; + expect(nodes[0].path).toEqual("FakeThread/src"); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js new file mode 100644 index 0000000000..1b176908e9 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getURL } from "../getURL"; +import { makeMockSource } from "../../../utils/test-mockup"; +import type { Source } from "../../../types"; + +function createMockSource(props): Source { + const rv = { + ...makeMockSource(), + ...Object.assign( + { + id: "server1.conn13.child1/39", + url: "", + sourceMapURL: "", + isBlackBoxed: false, + isPrettyPrinted: false, + isWasm: false, + }, + props + ), + }; + return (rv: any); +} + +describe("getUrl", () => { + it("handles normal url with http and https for filename", function() { + const urlObject = getURL(createMockSource({ url: "https://a/b.js" })); + expect(urlObject.filename).toBe("b.js"); + + const urlObject2 = getURL( + createMockSource({ id: "server1.conn13.child1/40", url: "http://a/b.js" }) + ); + expect(urlObject2.filename).toBe("b.js"); + }); + + it("handles url with querystring for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/b.js?key=randomKey", + }) + ); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with '#' for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/b.js#specialSection", + }) + ); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with no file extension for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/c", + id: "c", + }) + ); + expect(urlObject.filename).toBe("c"); + }); + + it("handles url with no name for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/", + id: "c", + }) + ); + expect(urlObject.filename).toBe("(index)"); + }); + + it("separates resources by protocol and host", () => { + const urlObject = getURL( + createMockSource({ + url: "moz-extension://xyz/123", + id: "c2", + }) + ); + expect(urlObject.group).toBe("moz-extension://xyz"); + }); + + it("creates a group name for webpack", () => { + const urlObject = getURL( + createMockSource({ + url: "webpack:///src/component.jsx", + id: "c3", + }) + ); + expect(urlObject.group).toBe("webpack://"); + }); + + it("creates a group name for angular source", () => { + const urlObject = getURL( + createMockSource({ + url: "ng://src/component.jsx", + id: "c3", + }) + ); + expect(urlObject.group).toBe("ng://"); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js new file mode 100644 index 0000000000..6835be7bc9 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getDomain } from "../treeOrder"; + +describe("getDomain", () => { + it("parses a url and returns the host name", () => { + expect(getDomain("http://www.mozilla.com")).toBe("mozilla.com"); + }); + + it("returns null for an undefined string", () => { + expect(getDomain(undefined)).toBe(null); + }); + + it("returns null for an empty string", () => { + expect(getDomain("")).toBe(null); + }); + + it("returns null for a poorly formed string", () => { + expect(getDomain("\\/~`?,.{}[]!@$%^&*")).toBe(null); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js new file mode 100644 index 0000000000..f837c7c1be --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; +import { updateTree, createTree } from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return { FakeThread: sourcesMap }; +} + +function formatTree(tree) { + if (!tree) { + throw new Error("Tree must exist"); + } + return JSON.stringify(tree.uncollapsedTree, null, 2); +} + +const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/source1.js", + }, + { + id: "server1.conn13.child1/40", + url: "https://davidwalsh.name/source2.js", + }, +]; + +const threads = [ + { + actor: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, +]; + +const debuggeeUrl = "blah"; + +describe("calls updateTree.js", () => { + it("adds one source", () => { + const prevSources = createSourcesMap([sources[0]]); + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1]]), + uncollapsedTree, + sourceTree, + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + it("adds two sources", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1], sources[2]]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + it("update sources that change their display URL", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([ + { + ...sources[0], + url: `${sources[0].url}?param`, + }, + ]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + // NOTE: we currently only add sources to the tree and clear the tree + // on navigate. + it("shows all the sources", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1]]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js new file mode 100644 index 0000000000..0740800c4d --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js @@ -0,0 +1,223 @@ +/* eslint max-nested-callbacks: ["error", 4]*/ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../test-mockup"; + +import { + createDirectoryNode, + getRelativePath, + isExactUrlMatch, + isDirectory, + addToTree, + isNotJavaScript, + getPathWithoutThread, + createTree, + getSourcesInsideGroup, + getAllSources, +} from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return sourcesMap; +} + +describe("sources tree", () => { + describe("isExactUrlMatch", () => { + it("recognizes root url match", () => { + const rootA = "http://example.com/path/to/file.html"; + const rootB = "https://www.demo.com/index.html"; + + expect(isExactUrlMatch("example.com", rootA)).toBe(true); + expect(isExactUrlMatch("www.example.com", rootA)).toBe(true); + expect(isExactUrlMatch("api.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("example.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("www.example.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("demo.com", rootA)).toBe(false); + + expect(isExactUrlMatch("demo.com", rootB)).toBe(true); + expect(isExactUrlMatch("www.demo.com", rootB)).toBe(true); + expect(isExactUrlMatch("maps.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("demo.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("www.demo.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("example.com", rootB)).toBe(false); + }); + }); + + describe("isDirectory", () => { + it("identifies directories correctly", () => { + const sources = [ + makeMockDisplaySource("http://example.com/a.js", "actor1"), + makeMockDisplaySource("http://example.com/b/c/d.js", "actor2"), + ]; + + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "http://example.com/", "Main Thread") + ); + const [bFolderNode, aFileNode] = tree.contents[0].contents[0].contents; + const [cFolderNode] = bFolderNode.contents; + const [dFileNode] = cFolderNode.contents; + + expect(isDirectory(bFolderNode)).toBe(true); + expect(isDirectory(aFileNode)).toBe(false); + expect(isDirectory(cFolderNode)).toBe(true); + expect(isDirectory(dFileNode)).toBe(false); + }); + }); + + describe("getRelativePath", () => { + it("gets the relative path of the file", () => { + const relPath = "path/to/file.html"; + expect(getRelativePath("http://example.com/path/to/file.html")).toBe( + relPath + ); + expect(getRelativePath("http://www.example.com/path/to/file.html")).toBe( + relPath + ); + expect(getRelativePath("https://www.example.com/path/to/file.js")).toBe( + "path/to/file.js" + ); + expect(getRelativePath("webpack:///path/to/file.html")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html?bla")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html#bla")).toBe(relPath); + expect(getRelativePath("file:///path/to/file")).toBe("path/to/file"); + }); + }); + + describe("isNotJavaScript", () => { + it("js file", () => { + const source = makeMockDisplaySource("http://example.com/foo.js"); + expect(isNotJavaScript(source)).toBe(false); + }); + + it("css file", () => { + const source = makeMockDisplaySource("http://example.com/foo.css"); + expect(isNotJavaScript(source)).toBe(true); + }); + + it("svg file", () => { + const source = makeMockDisplaySource("http://example.com/foo.svg"); + expect(isNotJavaScript(source)).toBe(true); + }); + + it("png file", () => { + const source = makeMockDisplaySource("http://example.com/foo.png"); + expect(isNotJavaScript(source)).toBe(true); + }); + }); + + describe("getPathWithoutThread", () => { + it("main thread pattern", () => { + const path = getPathWithoutThread("server1.conn0.child1/context18"); + expect(path).toBe(""); + }); + + it("main thread host", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/context18/dbg-workers.glitch.me" + ); + expect(path).toBe("dbg-workers.glitch.me"); + }); + + it("main thread children", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/context18/dbg-workers.glitch.me/more" + ); + expect(path).toBe("dbg-workers.glitch.me/more"); + }); + + it("worker thread", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1" + ); + expect(path).toBe(""); + }); + + it("worker thread with children", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils" + ); + expect(path).toBe("dbg-workers.glitch.me/utils"); + }); + + it("worker thread with file named like pattern", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils/context38/index.js" + ); + expect(path).toBe("dbg-workers.glitch.me/utils/context38/index.js"); + }); + }); + + it("gets all sources in all threads and gets sources inside of the selected directory", () => { + const testData1 = [ + { + id: "server1.conn13.child1/39", + url: "https://example.com/a.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://example.com/b.js", + }, + { + id: "server1.conn13.child1/35", + url: "https://example.com/c.js", + }, + ]; + const testData2 = [ + { + id: "server1.conn13.child1/33", + url: "https://example.com/d.js", + }, + { + id: "server1.conn13.child1/31", + url: "https://example.com/e.js", + }, + ]; + const sources = { + FakeThread: createSourcesMap(testData1), + OtherThread: createSourcesMap(testData2), + }; + const threads = [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://example.com/", + targetType: "worker", + isTopLevel: false, + }, + { + actor: "OtherThread", + name: "OtherThread", + url: "https://example.com/", + targetType: "worker", + isTopLevel: false, + }, + ]; + + const tree = createTree({ + sources, + debuggeeUrl: "https://example.com/", + threads, + }).sourceTree; + + const dirA = tree.contents[0]; + const dirB = tree.contents[1]; + + expect(getSourcesInsideGroup(dirA, { threads, sources })).toHaveLength(3); + expect(getSourcesInsideGroup(dirB, { threads, sources })).toHaveLength(2); + expect(getSourcesInsideGroup(tree, { threads, sources })).toHaveLength(5); + + expect(getAllSources({ threads, sources })).toHaveLength(5); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/treeOrder.js b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js new file mode 100644 index 0000000000..1cf2536f32 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { parse } from "../url"; + +import { nodeHasChildren } from "./utils"; + +import type { TreeNode } from "./types"; + +import type { Source } from "../../types"; + +/* + * Gets domain from url (without www prefix) + */ +export function getDomain(url?: string): ?string { + if (!url) { + return null; + } + const { host } = parse(url); + if (!host) { + return null; + } + return host.startsWith("www.") ? host.substr("www.".length) : host; +} + +/* + * Checks if node name matches debugger host/domain. + */ +function isExactDomainMatch(part: string, debuggeeHost: string): boolean { + return part.startsWith("www.") + ? part.substr("www.".length) === debuggeeHost + : part === debuggeeHost; +} + +/* + * Checks if node name matches IndexName + */ +function isIndexName(part: string, ...rest): boolean { + return part === IndexName; +} + +/* + * Function to assist with node search for a defined sorted order, see e.g. + * `createTreeNodeMatcher`. Returns negative number if the node + * stands earlier in sorting order, positive number if the node stands later + * in sorting order, or zero if the node is found. + */ +export type FindNodeInContentsMatcher = (node: TreeNode) => number; + +/* + * Performs a binary search to insert a node into contents. Returns positive + * number, index of the found child, or negative number, which can be used + * to calculate a position where a new node can be inserted (`-index - 1`). + * The matcher is a function that returns result of comparision of a node with + * lookup value. + */ +export function findNodeInContents( + tree: TreeNode, + matcher: FindNodeInContentsMatcher +): {| found: boolean, index: number |} { + if (tree.type === "source" || tree.contents.length === 0) { + return { found: false, index: 0 }; + } + + let left = 0; + let right = tree.contents.length - 1; + while (left < right) { + const middle = Math.floor((left + right) / 2); + if (matcher(tree.contents[middle]) < 0) { + left = middle + 1; + } else { + right = middle; + } + } + const result = matcher(tree.contents[left]); + if (result === 0) { + return { found: true, index: left }; + } + return { found: false, index: result > 0 ? left : left + 1 }; +} + +const IndexName = "(index)"; + +/* + * An array of functions to identify exceptions when sorting sourcesTree. + * Each function must return a boolean. Keep functions in array in the + * order exceptions should be sorted in. + */ +const matcherFunctions = [isIndexName, isExactDomainMatch]; + +/* + * Creates a matcher for findNodeInContents. + * The sorting order of nodes during comparison is: + * - "(index)" node + * - root node with the debuggee host/domain + * - hosts/directories (not files) sorted by name + * - files sorted by name + */ +export function createTreeNodeMatcher( + part: string, + isDir: boolean, + debuggeeHost: ?string, + source?: Source, + sortByUrl?: boolean +): FindNodeInContentsMatcher { + return (node: TreeNode) => { + for (let i = 0; i < matcherFunctions.length; i++) { + // Check part against exceptions + if (matcherFunctions[i](part, debuggeeHost)) { + for (let j = 0; j < i; j++) { + // Check node.name against exceptions + if (matcherFunctions[j](node.name, debuggeeHost)) { + return -1; + } + } + // If part and node.name share the same exception, return 0 + if (matcherFunctions[i](node.name, debuggeeHost)) { + return 0; + } + return 1; + } + // Check node.name against exceptions if part is not exception + if (matcherFunctions[i](node.name, debuggeeHost)) { + return -1; + } + } + // Sort directories before files + const nodeIsDir = nodeHasChildren(node); + if (nodeIsDir && !isDir) { + return -1; + } else if (!nodeIsDir && isDir) { + return 1; + } + + if (sortByUrl && node.type === "source" && source) { + return node.contents.url.localeCompare(source.url); + } + + if (isExactDomainMatch(part, node.name)) { + return 0; + } + + return node.name.localeCompare(part); + }; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/types.js b/devtools/client/debugger/src/utils/sources-tree/types.js new file mode 100644 index 0000000000..c8dacd6849 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/types.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Source } from "../../types"; + +/** + * TODO: createNode is exported so this type could be useful to other modules + * @memberof utils/sources-tree + * @static + */ +export type TreeNode = TreeSource | TreeDirectory; + +export type TreeSource = { + type: "source", + name: string, + path: string, + contents: Source, +}; + +export type TreeDirectory = { + type: "directory", + name: string, + path: string, + contents: TreeNode[], +}; + +export type ParentMap = WeakMap<TreeNode, TreeDirectory>; + +export type SourcesGroups = { + sourcesInside: Source[], + sourcesOuside: Source[], +}; diff --git a/devtools/client/debugger/src/utils/sources-tree/updateTree.js b/devtools/client/debugger/src/utils/sources-tree/updateTree.js new file mode 100644 index 0000000000..af67f0f95d --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/updateTree.js @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { addToTree } from "./addToTree"; +import { collapseTree } from "./collapseTree"; +import { + createDirectoryNode, + createParentMap, + getPathParts, + isInvalidUrl, +} from "./utils"; +import { + getDomain, + createTreeNodeMatcher, + findNodeInContents, +} from "./treeOrder"; + +import { getDisplayURL } from "./getURL"; + +import type { SourcesMapByThread } from "../../reducers/types"; +import type { Thread, DisplaySource, URL } from "../../types"; +import type { TreeDirectory, TreeSource, TreeNode } from "./types"; + +function getSourcesDiff( + newSources, + prevSources +): { + toAdd: Array<DisplaySource>, + toUpdate: Array<[DisplaySource, DisplaySource]>, +} { + const toAdd = []; + const toUpdate = []; + + for (const sourceId in newSources) { + const newSource = newSources[sourceId]; + const prevSource = prevSources ? prevSources[sourceId] : null; + if (!prevSource) { + toAdd.push(newSource); + } else if (prevSource.displayURL !== newSource.displayURL) { + toUpdate.push([prevSource, newSource]); + } + } + + return { toAdd, toUpdate }; +} + +type UpdateTreeParams = { + newSources: SourcesMapByThread, + prevSources: SourcesMapByThread, + uncollapsedTree: TreeDirectory, + debuggeeUrl: URL, + threads: Thread[], + sourceTree?: TreeNode, +}; + +type CreateTreeParams = { + sources: SourcesMapByThread, + debuggeeUrl: URL, + threads: Thread[], +}; + +export function createTree({ + debuggeeUrl, + sources, + threads, +}: CreateTreeParams) { + const uncollapsedTree = createDirectoryNode("root", "", []); + const result = updateTree({ + debuggeeUrl, + newSources: sources, + prevSources: {}, + threads, + uncollapsedTree, + }); + + if (!result) { + throw new Error("Tree must exist"); + } + + return result; +} + +export function updateTree({ + newSources, + prevSources, + debuggeeUrl, + uncollapsedTree, + threads, + create, + sourceTree, +}: UpdateTreeParams) { + const debuggeeHost = getDomain(debuggeeUrl); + const contexts = (Object.keys(newSources): any); + + let shouldUpdate = !sourceTree; + for (const context of contexts) { + const thread = threads.find(t => t.actor === context); + if (!thread) { + continue; + } + + const { toAdd, toUpdate } = getSourcesDiff( + (Object.values(newSources[context]): any), + prevSources[context] ? (Object.values(prevSources[context]): any) : null + ); + + for (const source of toAdd) { + shouldUpdate = true; + addToTree(uncollapsedTree, source, debuggeeHost, thread.actor); + } + + for (const [prevSource, newSource] of toUpdate) { + shouldUpdate = true; + updateInTree( + uncollapsedTree, + prevSource, + newSource, + debuggeeHost, + thread.actor + ); + } + } + + if (!shouldUpdate) { + return false; + } + + const newSourceTree = collapseTree(uncollapsedTree); + + return { + uncollapsedTree, + sourceTree: newSourceTree, + parentMap: createParentMap(newSourceTree), + }; +} + +export function updateInTree( + tree: TreeDirectory, + prevSource: DisplaySource, + newSource: DisplaySource, + debuggeeHost: ?string, + thread: string +): void { + const newUrl = getDisplayURL(newSource, debuggeeHost); + const prevUrl = getDisplayURL(prevSource, debuggeeHost); + + const prevEntries = findEntries( + tree, + prevUrl, + prevSource, + thread, + debuggeeHost + ); + if (!prevEntries) { + return; + } + + if (!isInvalidUrl(newUrl, newSource)) { + const parts = getPathParts(newUrl, thread, debuggeeHost); + + if (parts.length === prevEntries.length) { + let match = true; + for (let i = 0; i < parts.length - 2; i++) { + if (parts[i].path !== prevEntries[i + 1].node.path) { + match = false; + break; + } + } + + if (match) { + const { node, index } = prevEntries.pop(); + // This is guaranteed to be a TreeSource or else findEntries would + // not have returned anything. + const fileNode: TreeSource = (node.contents[index]: any); + fileNode.name = parts[parts.length - 1].part; + fileNode.path = parts[parts.length - 1].path; + fileNode.contents = newSource; + return; + } + } + } + + // Fall back to removing the current entry and inserting a new one if we + // are unable do a straight find-replace of the name and contents. + for (let i = prevEntries.length - 1; i >= 0; i--) { + const { node, index } = prevEntries[i]; + + // If the node has only a single child, we want to keep stepping upward + // to find the overall value to remove. + if (node.contents.length > 1 || (i === 0 && thread)) { + node.contents.splice(index, 1); + break; + } + } + addToTree(tree, newSource, debuggeeHost, thread); +} + +type Entry = { + node: TreeDirectory, + index: number, +}; + +function findEntries(tree, url, source, thread, debuggeeHost): ?Array<Entry> { + const parts = getPathParts(url, thread, debuggeeHost); + + // We're searching for the directory containing the file so we pop off the + // potential filename. This is because the tree has some logic to inject + // special entries when filename parts either conflict with directories, and + // so the last bit of removal needs to do a broad search to find the exact + // target location. + parts.pop(); + + const entries = []; + let currentNode = tree; + for (const { part } of parts) { + const { found: childFound, index: childIndex } = findNodeInContents( + currentNode, + createTreeNodeMatcher(part, true, debuggeeHost) + ); + + if (!childFound || currentNode.type !== "directory") { + return null; + } + + entries.push({ + node: currentNode, + index: childIndex, + }); + + currentNode = currentNode.contents[childIndex]; + } + + // From this point, we do a depth-first search for a node containing the + // specified source, as mentioned above. + const found = (function search(node) { + if (node.type !== "directory") { + if (node.contents.id === source.id) { + return []; + } + return null; + } + + for (let i = 0; i < node.contents.length; i++) { + const child = node.contents[i]; + const result = search(child); + if (result) { + result.unshift({ + node, + index: i, + }); + return result; + } + } + + return null; + })(currentNode); + + return found ? [...entries, ...found] : null; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/utils.js b/devtools/client/debugger/src/utils/sources-tree/utils.js new file mode 100644 index 0000000000..f893fa76a5 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/utils.js @@ -0,0 +1,292 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { parse } from "../../utils/url"; + +import type { TreeNode, TreeSource, TreeDirectory, ParentMap } from "./types"; +import type { Source, Thread, URL } from "../../types"; +import type { SourcesMapByThread } from "../../reducers/types"; +import { isPretty } from "../source"; +import { getURL, type ParsedURL } from "./getURL"; +const IGNORED_URLS = ["debugger eval code", "XStringBundle"]; + +export type PathPart = { + part: string, + path: string, + debuggeeHostIfRoot: ?string, +}; +export function getPathParts( + url: ParsedURL, + thread: string, + debuggeeHost: ?string +): Array<PathPart> { + const parts = url.path.split("/"); + if (parts.length > 1 && parts[parts.length - 1] === "") { + parts.pop(); + if (url.search) { + parts.push(url.search); + } + } else { + parts[parts.length - 1] += url.search; + } + + parts[0] = url.group; + if (thread) { + parts.unshift(thread); + } + + let path = ""; + return parts.map((part, index) => { + if (index == 0 && thread) { + path = thread; + } else { + path = `${path}/${part}`; + } + + const debuggeeHostIfRoot = index === 1 ? debuggeeHost : null; + + return { + part, + path, + debuggeeHostIfRoot, + }; + }); +} + +export function nodeHasChildren(item: TreeNode): boolean { + return item.type == "directory" && Array.isArray(item.contents); +} + +export function isExactUrlMatch(pathPart: string, debuggeeUrl: URL): boolean { + // compare to hostname with an optional 'www.' prefix + const { host } = parse(debuggeeUrl); + if (!host) { + return false; + } + return ( + host === pathPart || + host.replace(/^www\./, "") === pathPart.replace(/^www\./, "") + ); +} + +export function isPathDirectory(path: string): boolean { + // Assume that all urls point to files except when they end with '/' + // Or directory node has children + + if (path.endsWith("/")) { + return true; + } + + let separators = 0; + for (let i = 0; i < path.length - 1; ++i) { + if (path[i] === "/") { + if (path[i + i] !== "/") { + return false; + } + + ++separators; + } + } + + switch (separators) { + case 0: { + return false; + } + case 1: { + return !path.startsWith("/"); + } + default: { + return true; + } + } +} + +export function isDirectory(item: TreeNode): boolean { + return ( + (item.type === "directory" || isPathDirectory(item.path)) && + item.name != "(index)" + ); +} + +export function getSourceFromNode(item: TreeNode): ?Source { + const { contents } = item; + if (!isDirectory(item) && !Array.isArray(contents)) { + return contents; + } +} + +export function isSource(item: TreeNode): boolean { + return item.type === "source"; +} + +export function getFileExtension(source: Source): string { + const { path } = getURL(source); + if (!path) { + return ""; + } + + const lastIndex = path.lastIndexOf("."); + return lastIndex !== -1 ? path.slice(lastIndex + 1) : ""; +} + +export function isNotJavaScript(source: Source): boolean { + return ["css", "svg", "png"].includes(getFileExtension(source)); +} + +export function isInvalidUrl(url: ParsedURL, source: Source): boolean { + return ( + !source.url || + !url.group || + isNotJavaScript(source) || + IGNORED_URLS.includes(url) || + isPretty(source) + ); +} + +export function partIsFile( + index: number, + parts: Array<PathPart>, + url: Object +): boolean { + const isLastPart = index === parts.length - 1; + return isLastPart && !isDirectory(url); +} + +export function createDirectoryNode( + name: string, + path: string, + contents: TreeNode[] +): TreeDirectory { + return { + type: "directory", + name, + path, + contents, + }; +} + +export function createSourceNode( + name: string, + path: string, + contents: Source +): TreeSource { + return { + type: "source", + name, + path, + contents, + }; +} + +export function createParentMap(tree: TreeNode): ParentMap { + const map = new WeakMap(); + + function _traverse(subtree) { + if (subtree.type === "directory") { + for (const child of subtree.contents) { + map.set(child, subtree); + _traverse(child); + } + } + } + + if (tree.type === "directory") { + // Don't link each top-level path to the "root" node because the + // user never sees the root + tree.contents.forEach(_traverse); + } + + return map; +} + +export function getRelativePath(url: URL): string { + const { pathname } = parse(url); + if (!pathname) { + return url; + } + const index = pathname.indexOf("/"); + + return index !== -1 ? pathname.slice(index + 1) : ""; +} + +export function getPathWithoutThread(path: string): string { + const pathParts = path.split(/(context\d+?\/)/).splice(2); + if (pathParts && pathParts.length > 0) { + return pathParts.join(""); + } + return ""; +} + +export function findSource( + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread }, + itemPath: string, + source: ?Source +): ?Source { + const targetThread = threads.find(thread => itemPath.includes(thread.actor)); + if (targetThread && source) { + const { actor } = targetThread; + if (sources[actor]) { + return sources[actor][source.id]; + } + } + return source; +} + +// NOTE: we get the source from sources because item.contents is cached +export function getSource( + item: TreeNode, + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread } +): ?Source { + const source = getSourceFromNode(item); + return findSource({ threads, sources }, item.path, source); +} + +export function getChildren(item: $Shape<TreeDirectory>) { + return nodeHasChildren(item) ? item.contents : []; +} + +export function getAllSources({ + threads, + sources, +}: { + threads: Thread[], + sources: SourcesMapByThread, +}): Source[] { + const sourcesAll = []; + threads.forEach(thread => { + const { actor } = thread; + + for (const source in sources[actor]) { + sourcesAll.push(sources[actor][source]); + } + }); + return sourcesAll; +} + +export function getSourcesInsideGroup( + item: TreeNode, + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread } +): Source[] { + const sourcesInsideDirectory = []; + + const findAllSourcesInsideDirectory = (directoryToSearch: TreeDirectory) => { + const childrenItems = getChildren(directoryToSearch); + + childrenItems.forEach((itemChild: TreeNode) => { + if (itemChild.type === "directory") { + findAllSourcesInsideDirectory(itemChild); + } else { + const source = getSource(itemChild, { threads, sources }); + if (source) { + sourcesInsideDirectory.push(source); + } + } + }); + }; + if (item.type === "directory") { + findAllSourcesInsideDirectory(item); + } + return sourcesInsideDirectory; +} diff --git a/devtools/client/debugger/src/utils/tabs.js b/devtools/client/debugger/src/utils/tabs.js new file mode 100644 index 0000000000..12349c2faa --- /dev/null +++ b/devtools/client/debugger/src/utils/tabs.js @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { PersistedTab, VisibleTab } from "../reducers/tabs"; +import type { TabList, Tab, TabsSources } from "../reducers/types"; +import type { URL } from "../types"; + +/* + * Finds the hidden tabs by comparing the tabs' top offset. + * hidden tabs will have a great top offset. + * + * @param sourceTabs Array + * @param sourceTabEls HTMLCollection + * + * @returns Array + */ + +export function getHiddenTabs( + sourceTabs: TabsSources, + sourceTabEls: Array<any> +): TabsSources { + sourceTabEls = [].slice.call(sourceTabEls); + function getTopOffset(): number { + const topOffsets = sourceTabEls.map(t => t.getBoundingClientRect().top); + return Math.min(...topOffsets); + } + + function hasTopOffset(el): boolean { + // adding 10px helps account for cases where the tab might be offset by + // styling such as selected tabs which don't have a border. + const tabTopOffset = getTopOffset(); + return el.getBoundingClientRect().top > tabTopOffset + 10; + } + + return sourceTabs.filter((tab, index: number) => { + const element = sourceTabEls[index]; + return element && hasTopOffset(element); + }); +} + +export function getFramework(tabs: TabList, url: URL): string { + const tab = tabs.find(t => t.url === url); + return tab?.framework ?? ""; +} + +export function getTabMenuItems(): Object { + return { + closeTab: { + id: "node-menu-close-tab", + label: L10N.getStr("sourceTabs.closeTab"), + accesskey: L10N.getStr("sourceTabs.closeTab.accesskey"), + disabled: false, + }, + closeOtherTabs: { + id: "node-menu-close-other-tabs", + label: L10N.getStr("sourceTabs.closeOtherTabs"), + accesskey: L10N.getStr("sourceTabs.closeOtherTabs.accesskey"), + disabled: false, + }, + closeTabsToEnd: { + id: "node-menu-close-tabs-to-end", + label: L10N.getStr("sourceTabs.closeTabsToEnd"), + accesskey: L10N.getStr("sourceTabs.closeTabsToEnd.accesskey"), + disabled: false, + }, + closeAllTabs: { + id: "node-menu-close-all-tabs", + label: L10N.getStr("sourceTabs.closeAllTabs"), + accesskey: L10N.getStr("sourceTabs.closeAllTabs.accesskey"), + disabled: false, + }, + showSource: { + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: false, + }, + copySource: { + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + }, + copySourceUri2: { + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: false, + }, + toggleBlackBox: { + id: "node-menu-blackbox", + label: L10N.getStr("ignoreContextItem.ignore"), + accesskey: L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: false, + }, + prettyPrint: { + id: "node-menu-pretty-print", + label: L10N.getStr("sourceTabs.prettyPrint"), + accesskey: L10N.getStr("sourceTabs.prettyPrint.accesskey"), + disabled: false, + }, + }; +} + +export function isSimilarTab(tab: Tab, url: URL, isOriginal: boolean): boolean { + return tab.url === url && tab.isOriginal === isOriginal; +} + +export function persistTabs(tabs: VisibleTab[]): PersistedTab[] { + return [...tabs] + .filter(tab => tab.url) + .map(tab => ({ ...tab, sourceId: null })); +} diff --git a/devtools/client/debugger/src/utils/task.js b/devtools/client/debugger/src/utils/task.js new file mode 100644 index 0000000000..012baaba37 --- /dev/null +++ b/devtools/client/debugger/src/utils/task.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * This object provides the public module functions. + */ +export const Task = { + // XXX: Not sure if this works in all cases... + async: function(task: any) { + return function() { + return Task.spawn(task, this, arguments); + }; + }, + + /** + * Creates and starts a new task. + * @param task A generator function + * @return A promise, resolved when the task terminates + */ + spawn: function(task: any, scope: any, args: any): Promise<any> { + return new Promise(function(resolve, reject) { + const iterator = task.apply(scope, args); + + const callNext = lastValue => { + const iteration = iterator.next(lastValue); + Promise.resolve(iteration.value) + .then(value => { + if (iteration.done) { + resolve(value); + } else { + callNext(value); + } + }) + .catch(error => { + reject(error); + iterator.throw(error); + }); + }; + + callNext(undefined); + }); + }, +}; diff --git a/devtools/client/debugger/src/utils/telemetry.js b/devtools/client/debugger/src/utils/telemetry.js new file mode 100644 index 0000000000..b48b9d30c9 --- /dev/null +++ b/devtools/client/debugger/src/utils/telemetry.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Usage: + * + * import { recordEvent } from "src/utils/telemetry"; + * + * // Event without extra properties + * recordEvent("add_breakpoint"); + * + * // Event with extra properties + * recordEvent("pause", { + * "reason": "debugger-statement", + * "collapsed_callstacks": 1 + * }); + * + * // If the properties are in multiple code paths and you can't send them all + * // in one go you will need to use the full telemetry API. + * + * const Telemetry = require("devtools/client/shared/telemetry"); + * + * const telemetry = new Telemetry(); + * + * // Prepare the event and define which properties to expect. + * // + * // NOTE: You CAN send properties before preparing the event. + * // + * telemetry.preparePendingEvent(this, "pause", "debugger", null, [ + * "reason", "collapsed_callstacks" + * ]); + * + * // Elsewhere in another codepath send the reason property + * telemetry.addEventProperty( + * this, "pause", "debugger", null, "reason", "debugger-statement" + * ); + * + * // Elsewhere in another codepath send the collapsed_callstacks property + * telemetry.addEventProperty( + * this, "pause", "debugger", null, "collapsed_callstacks", 1 + * ); + */ + +// @flow + +// $FlowIgnore +const Telemetry = require("devtools/client/shared/telemetry"); + +import { isNode } from "./environment"; + +const telemetry = new Telemetry(); + +/** + * @memberof utils/telemetry + * @static + */ +export function recordEvent(eventName: string, fields: {} = {}): void { + let sessionId = -1; + + if (typeof window !== "object") { + return; + } + + if (window.parent.frameElement) { + sessionId = window.parent.frameElement.getAttribute("session_id"); + } + + /* eslint-disable camelcase */ + telemetry.recordEvent(eventName, "debugger", null, { + session_id: sessionId, + ...fields, + }); + /* eslint-enable camelcase */ + + if (isNode()) { + const { events } = window.dbg._telemetry; + if (!events[eventName]) { + events[eventName] = []; + } + events[eventName].push(fields); + } +} diff --git a/devtools/client/debugger/src/utils/test-head.js b/devtools/client/debugger/src/utils/test-head.js new file mode 100644 index 0000000000..270b86f5ac --- /dev/null +++ b/devtools/client/debugger/src/utils/test-head.js @@ -0,0 +1,301 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Utils for Jest + * @module utils/test-head + */ + +import { combineReducers, type Store } from "redux"; +import sourceMaps from "devtools-source-map"; +import reducers from "../reducers"; +import actions from "../actions"; +import * as selectors from "../selectors"; +import { parserWorker, evaluationsParser } from "../test/tests-setup"; +import configureStore from "../actions/utils/create-store"; +import sourceQueue from "../utils/source-queue"; +import type { + ThreadContext, + Source, + OriginalSourceData, + GeneratedSourceData, +} from "../types"; +import type { State } from "../reducers/types"; +import type { Action } from "../actions/types"; + +type TestStore = Store<State, Action, any> & { + thunkArgs: () => { + dispatch: any, + getState: () => State, + client: any, + sourceMaps: any, + panel: {||}, + }, + cx: ThreadContext, +}; + +/** + * This file contains older interfaces used by tests that have not been + * converted to use test-mockup.js + */ + +/** + * @memberof utils/test-head + * @static + */ +function createStore( + client: any, + initialState: any = {}, + sourceMapsMock: any +): TestStore { + const store: any = configureStore({ + log: false, + makeThunkArgs: args => { + return { + ...args, + client, + sourceMaps: sourceMapsMock !== undefined ? sourceMapsMock : sourceMaps, + parser: parserWorker, + evaluationsParser, + }; + }, + })(combineReducers(reducers), initialState); + sourceQueue.clear(); + sourceQueue.initialize({ + newQueuedSources: sources => + store.dispatch(actions.newQueuedSources(sources)), + }); + + store.thunkArgs = () => ({ + dispatch: store.dispatch, + getState: store.getState, + client, + sourceMaps, + panel: {}, + }); + + // Put the initial context in the store, for convenience to unit tests. + store.cx = selectors.getThreadContext(store.getState()); + + return store; +} + +/** + * @memberof utils/test-head + * @static + */ +function commonLog(msg: string, data: any = {}) { + console.log(`[INFO] ${msg} ${JSON.stringify(data)}`); +} + +function makeFrame({ id, sourceId, thread }: Object, opts: Object = {}) { + return { + id, + scope: { bindings: { variables: {}, arguments: [] } }, + location: { sourceId, line: 4 }, + thread: thread || "FakeThread", + ...opts, + }; +} + +function createSourceObject( + filename: string, + props: { + isBlackBoxed?: boolean, + } = {} +): Source { + return ({ + id: filename, + url: makeSourceURL(filename), + isBlackBoxed: !!props.isBlackBoxed, + isPrettyPrinted: false, + isExtension: false, + isOriginal: filename.includes("originalSource"), + }: any); +} + +function createOriginalSourceObject(generated: Source): Source { + const rv = { + ...generated, + id: `${generated.id}/originalSource`, + }; + + return (rv: any); +} + +function makeSourceURL(filename: string) { + return `http://localhost:8000/examples/${filename}`; +} + +type MakeSourceProps = { + sourceMapBaseURL?: string, + sourceMapURL?: string, + introductionType?: string, + isBlackBoxed?: boolean, +}; +function createMakeSource(): ( + // The name of the file that this actor is part of. + name: string, + props?: MakeSourceProps +) => GeneratedSourceData { + const indicies = {}; + + return function(name, props = {}) { + const index = (indicies[name] | 0) + 1; + indicies[name] = index; + + return { + id: name, + thread: "FakeThread", + source: { + actor: `${name}-${index}-actor`, + url: `http://localhost:8000/examples/${name}`, + sourceMapBaseURL: props.sourceMapBaseURL || null, + sourceMapURL: props.sourceMapURL || null, + introductionType: props.introductionType || null, + isBlackBoxed: !!props.isBlackBoxed, + extensionName: null, + }, + isServiceWorker: false, + }; + }; +} + +/** + * @memberof utils/test-head + * @static + */ +let creator; +beforeEach(() => { + creator = createMakeSource(); +}); +afterEach(() => { + creator = null; +}); +function makeSource(name: string, props?: MakeSourceProps) { + if (!creator) { + throw new Error("makeSource() cannot be called outside of a test"); + } + + return creator(name, props); +} + +function makeOriginalSource(source: Source): OriginalSourceData { + return { + id: `${source.id}/originalSource`, + url: `${source.url}-original`, + }; +} + +function makeFuncLocation(startLine, endLine) { + if (!endLine) { + endLine = startLine + 1; + } + return { + start: { + line: startLine, + }, + end: { + line: endLine, + }, + }; +} + +function makeSymbolDeclaration( + name: string, + start: number, + end: ?number, + klass: ?string +) { + return { + id: `${name}:${start}`, + name, + location: makeFuncLocation(start, end), + klass, + }; +} + +/** + * @memberof utils/test-head + * @static + */ +function waitForState(store: any, predicate: any): Promise<void> { + return new Promise(resolve => { + let ret = predicate(store.getState()); + if (ret) { + resolve(ret); + } + + const unsubscribe = store.subscribe(() => { + ret = predicate(store.getState()); + if (ret) { + unsubscribe(); + // NOTE: memoizableAction adds an additional tick for validating context + setTimeout(() => resolve(ret)); + } + }); + }); +} + +function watchForState(store: any, predicate: any): () => boolean { + let sawState = false; + const checkState = function() { + if (!sawState && predicate(store.getState())) { + sawState = true; + } + return sawState; + }; + + let unsubscribe; + if (!checkState()) { + unsubscribe = store.subscribe(() => { + if (checkState()) { + unsubscribe(); + } + }); + } + + return function read() { + if (unsubscribe) { + unsubscribe(); + } + + return sawState; + }; +} + +function getTelemetryEvents(eventName: string) { + return window.dbg._telemetry.events[eventName] || []; +} + +function waitATick(callback: Function): Promise<*> { + return new Promise(resolve => { + setTimeout(() => { + callback(); + resolve(); + }); + }); +} + +export { + actions, + selectors, + reducers, + createStore, + commonLog, + getTelemetryEvents, + makeFrame, + createSourceObject, + createOriginalSourceObject, + createMakeSource, + makeSourceURL, + makeSource, + makeOriginalSource, + makeSymbolDeclaration, + waitForState, + watchForState, + waitATick, +}; diff --git a/devtools/client/debugger/src/utils/test-mockup.js b/devtools/client/debugger/src/utils/test-mockup.js new file mode 100644 index 0000000000..ff120bf157 --- /dev/null +++ b/devtools/client/debugger/src/utils/test-mockup.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * This file is for use by unit tests for isolated debugger components that do + * not need to interact with the redux store. When these tests need to construct + * debugger objects, these interfaces should be used instead of plain object + * literals. + */ + +import type { + ActorId, + Breakpoint, + DisplaySource, + Expression, + Frame, + FrameId, + Scope, + Source, + SourceId, + SourceWithContentAndType, + SourceWithContent, + TextSourceContent, + URL, + WasmSourceContent, + Why, + Thread, +} from "../types"; +import * as asyncValue from "./async-value"; + +import { initialState } from "../reducers/index"; + +import type { SourceBase } from "../reducers/sources"; +import type { State } from "../reducers/types"; +import type { FulfilledValue } from "./async-value"; + +function makeMockSource(url: URL = "url", id: SourceId = "source"): SourceBase { + return { + id, + url, + isBlackBoxed: false, + isPrettyPrinted: false, + relativeUrl: url, + isWasm: false, + extensionName: null, + isExtension: false, + isOriginal: id.includes("originalSource"), + }; +} + +function makeMockDisplaySource( + url: URL = "url", + id: SourceId = "source" +): DisplaySource { + return { + ...makeMockSource(url, id), + displayURL: url, + }; +} + +function makeMockSourceWithContent( + url?: string, + id?: SourceId, + contentType?: string = "text/javascript", + text?: string = "" +): SourceWithContent { + const source = makeMockSource(url, id); + + return { + ...source, + content: text + ? asyncValue.fulfilled({ + type: "text", + value: text, + contentType, + }) + : null, + }; +} + +function makeMockSourceAndContent( + url?: string, + id?: SourceId, + contentType?: string = "text/javascript", + text: string = "" +): { ...SourceBase, content: TextSourceContent } { + const source = makeMockSource(url, id); + + return { + ...source, + content: { + type: "text", + value: text, + contentType, + }, + }; +} + +function makeFullfilledMockSourceContent( + text: string = "", + contentType?: string = "text/javascript" +): FulfilledValue<TextSourceContent> { + return asyncValue.fulfilled({ + type: "text", + value: text, + contentType, + }); +} + +function makeMockWasmSource(): SourceBase { + return { + id: "wasm-source-id", + url: "url", + isBlackBoxed: false, + isPrettyPrinted: false, + relativeUrl: "url", + isWasm: true, + extensionName: null, + isExtension: false, + isOriginal: false, + }; +} + +function makeMockWasmSourceWithContent(text: {| + binary: Object, +|}): SourceWithContentAndType<WasmSourceContent> { + const source = makeMockWasmSource(); + + return { + ...source, + content: asyncValue.fulfilled({ + type: "wasm", + value: text, + }), + }; +} + +function makeMockScope( + actor: ActorId = "scope-actor", + type: string = "block", + parent: ?Scope = null +): Scope { + return { + actor, + parent, + bindings: { + arguments: [], + variables: {}, + }, + object: null, + function: null, + type, + scopeKind: "", + }; +} + +function mockScopeAddVariable(scope: Scope, name: string) { + if (!scope.bindings) { + throw new Error("no scope bindings"); + } + scope.bindings.variables[name] = { value: null }; +} + +function makeMockBreakpoint( + source: Source = makeMockSource(), + line: number = 1, + column: ?number +): Breakpoint { + const location = column + ? { sourceId: source.id, line, column } + : { sourceId: source.id, line }; + return { + id: "breakpoint", + location, + astLocation: null, + generatedLocation: location, + disabled: false, + text: "text", + originalText: "text", + options: {}, + }; +} + +function makeMockFrame( + id: FrameId = "frame", + source: Source = makeMockSource("url"), + scope: Scope = makeMockScope(), + line: number = 4, + displayName: string = `display-${id}`, + index: number = 0 +): Frame { + const location = { sourceId: source.id, line }; + return { + id, + thread: "FakeThread", + displayName, + location, + generatedLocation: location, + source, + scope, + this: {}, + index, + asyncCause: null, + state: "on-stack", + type: "call", + }; +} + +function makeMockFrameWithURL(url: URL): Frame { + return makeMockFrame(undefined, makeMockSource(url)); +} + +function makeWhyNormal(frameReturnValue: any = undefined): Why { + if (frameReturnValue) { + return { type: "why-normal", frameFinished: { return: frameReturnValue } }; + } + return { type: "why-normal" }; +} + +function makeWhyThrow(frameThrowValue: any): Why { + return { type: "why-throw", frameFinished: { throw: frameThrowValue } }; +} + +function makeMockExpression(value: Object): Expression { + return { + input: "input", + value, + from: "from", + updating: false, + }; +} + +// Mock contexts for use in tests that do not create a redux store. +const mockcx = { navigateCounter: 0 }; +const mockthreadcx = { + navigateCounter: 0, + thread: "FakeThread", + pauseCounter: 0, + isPaused: false, +}; + +function makeMockThread(fields: $Shape<Thread>) { + return { + actor: "test", + url: "example.com", + type: "worker", + name: "test", + ...fields, + }; +} + +function makeMockState(state: $Shape<State>) { + return { + ...initialState(), + ...state, + }; +} + +export { + makeMockDisplaySource, + makeMockSource, + makeMockSourceWithContent, + makeMockSourceAndContent, + makeMockWasmSource, + makeMockWasmSourceWithContent, + makeMockScope, + mockScopeAddVariable, + makeMockBreakpoint, + makeMockFrame, + makeMockFrameWithURL, + makeWhyNormal, + makeWhyThrow, + makeMockExpression, + mockcx, + mockthreadcx, + makeMockState, + makeMockThread, + makeFullfilledMockSourceContent, +}; diff --git a/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js new file mode 100644 index 0000000000..796c752453 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { reportException, executeSoon } from "../DevToolsUtils.js"; + +describe("DevToolsUtils", () => { + describe("reportException", () => { + beforeEach(() => { + global.console = { error: jest.fn() }; + }); + + it("calls console.error", () => { + reportException("caller", ["you broke it"]); + expect(console.error).toHaveBeenCalled(); + }); + + it("returns a description of caller and exception text", () => { + const who = "who", + exception = "this is an error", + msgTxt = " threw an exception: "; + + reportException(who, [exception]); + + expect(console.error).toHaveBeenCalledWith(`${who}${msgTxt}`, [ + exception, + ]); + }); + }); + + describe("executeSoon", () => { + it("calls setTimeout with 0 ms", () => { + global.setTimeout = jest.fn(); + const fnc = () => {}; + + executeSoon(fnc); + + expect(setTimeout).toHaveBeenCalledWith(fnc, 0); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap new file mode 100644 index 0000000000..7fe8b4f716 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`find the best expression for the token should find the expression for the property 1`] = ` +Object { + "computed": false, + "expression": "obj.b", + "location": Object { + "end": Position { + "column": 17, + "line": 6, + }, + "start": Position { + "column": 16, + "line": 6, + }, + }, + "name": "b", +} +`; + +exports[`find the best expression for the token should find the identifier 1`] = ` +Object { + "expression": "key", + "location": Object { + "end": Position { + "column": 13, + "line": 1, + }, + "start": Position { + "column": 10, + "line": 1, + }, + }, + "name": "key", +} +`; + +exports[`find the best expression for the token should find the identifier for computed member expressions 1`] = ` +Object { + "expression": "key", + "location": Object { + "end": Position { + "column": 9, + "line": 5, + }, + "start": Position { + "column": 6, + "line": 5, + }, + }, + "name": "key", +} +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap new file mode 100644 index 0000000000..68dd5fad1c --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expressions wrap exxpression should wrap an expression 1`] = ` +"try { + foo +} catch (e) { + e +}" +`; + +exports[`expressions wrap exxpression should wrap expression with a comment 1`] = ` +"try { + foo // yo yo +} catch (e) { + e +}" +`; + +exports[`expressions wrap exxpression should wrap quotes 1`] = ` +"try { + \\"2\\" +} catch (e) { + e +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap new file mode 100644 index 0000000000..8d83a48d3b --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`function findFunctionText finds class function 1`] = ` +"bar() { + 2 + 2; +}" +`; + +exports[`function findFunctionText finds function 1`] = ` +"async function exSlowFoo() { + return \\"yay in a bit\\"; +}" +`; + +exports[`function findFunctionText finds function signature 1`] = ` +"async function exSlowFoo() { + return \\"yay in a bit\\"; +}" +`; + +exports[`function findFunctionText finds property function 1`] = ` +"function name() { + 2 + 2; +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap new file mode 100644 index 0000000000..883d48a7ff --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`indentation mad indentation 1`] = ` +"try { +console.log(\\"yo\\") +} catch (e) { +console.log(\\"yo\\") + }" +`; + +exports[`indentation one function 1`] = ` +"function foo() { + console.log(\\"yo\\") +}" +`; + +exports[`indentation one line 1`] = `"foo"`; + +exports[`indentation simple 1`] = `"foo"`; + +exports[`indentation try catch 1`] = ` +"try { + console.log(\\"yo\\") +} catch (e) { + console.log(\\"yo\\") +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/project-search.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/project-search.spec.js.snap new file mode 100644 index 0000000000..e022edd6ee --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/project-search.spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`project search - highlightMatches simple 1`] = ` +<span + className="line-value" +> + <span + className="line-match" + > + This is a sample + </span> + <span + className="query-match" + > + sentence + </span> + <span + className="line-match" + > + + </span> +</span> +`; diff --git a/devtools/client/debugger/src/utils/tests/assert.spec.js b/devtools/client/debugger/src/utils/tests/assert.spec.js new file mode 100644 index 0000000000..5a3ff546b4 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/assert.spec.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import assert from "../assert.js"; + +let testAssertMessageHead, testAssertMessage; + +describe("assert", () => { + beforeEach(() => { + testAssertMessageHead = "Assertion failure: "; + testAssertMessage = "Test assert.js Message"; + }); + + describe("when condition is truthy", () => { + it("does not throw an Error", () => { + expect(() => { + assert(true, testAssertMessage); + }).not.toThrow(); + }); + }); + + describe("when condition is falsy", () => { + it("throws an Error displaying the passed message", () => { + expect(() => { + assert(false, testAssertMessage); + }).toThrow(new Error(testAssertMessageHead + testAssertMessage)); + }); + }); + + describe("when not in Node test", () => { + it("does not throw an Error", () => { + process.env.NODE_ENV = "production"; + expect(() => assert(false, testAssertMessage)).not.toThrow(); + delete process.env.NODE_ENV; + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/ast.spec.js b/devtools/client/debugger/src/utils/tests/ast.spec.js new file mode 100644 index 0000000000..0f00ae4fda --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/ast.spec.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { findBestMatchExpression } from "../ast"; + +import { getSymbols } from "../../workers/parser/getSymbols"; +import { populateSource } from "../../workers/parser/tests/helpers"; + +describe("find the best expression for the token", () => { + const source = populateSource("computed-props"); + const symbols = getSymbols(source.id); + + it("should find the identifier", () => { + const expression = findBestMatchExpression(symbols, { + line: 1, + column: 13, + }); + expect(expression).toMatchSnapshot(); + }); + + it("should find the expression for the property", () => { + const expression = findBestMatchExpression(symbols, { + line: 6, + column: 16, + }); + expect(expression).toMatchSnapshot(); + }); + + it("should find the identifier for computed member expressions", () => { + const expression = findBestMatchExpression(symbols, { line: 5, column: 6 }); + expect(expression).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/build-query.spec.js b/devtools/client/debugger/src/utils/tests/build-query.spec.js new file mode 100644 index 0000000000..57fda592d3 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/build-query.spec.js @@ -0,0 +1,259 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { escapeRegExp } from "lodash"; +import buildQuery from "../build-query"; + +describe("build-query", () => { + it("case-sensitive, whole-word, regex search", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: true, + }, + {} + ); + + expect(query.source).toBe("\\bhi.*\\b"); + expect(query.flags).toBe(""); + expect(query.ignoreCase).toBe(false); + }); + + it("case-sensitive, whole-word, regex search, global", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: true, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi.*\\b"); + expect(query.flags).toBe("g"); + expect(query.ignoreCase).toBe(false); + }); + + it("case-insensitive, non-whole, string search", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: false, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("i"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-insensitive, non-whole, string search, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("gi"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-sensitive string search", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe(""); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with wholeWord", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe("i"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-insensitive, regex search", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: false, + wholeWord: false, + regexMatch: true, + }, + {} + ); + + expect(query.source).toBe("hi.*"); + expect(query.flags).toBe("i"); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(true); + }); + + it("string search with wholeWord and case sensitivity", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with wholeWord and case sensitivity, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with regex chars escaped", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\.\\*\\b"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with regex chars escaped, global", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi\\.\\*\\b"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/o spaces", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { ignoreSpaces: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/o spaces, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true, ignoreSpaces: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/ spaces", () => { + const query = buildQuery( + " ", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { ignoreSpaces: true } + ); + + expect(query.source).toBe(escapeRegExp("(?!\\s*.*)")); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/ spaces, global", () => { + const query = buildQuery( + " ", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true, ignoreSpaces: true } + ); + + expect(query.source).toBe(escapeRegExp("(?!\\s*.*)")); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/clipboard.spec.js b/devtools/client/debugger/src/utils/tests/clipboard.spec.js new file mode 100644 index 0000000000..3f804a817d --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/clipboard.spec.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { copyToTheClipboard } from "../clipboard"; + +let clipboardTestCopyString, expectedCopyEvent; +const addEventListener = jest.fn(); +const execCommand = jest.fn(); +const removeEventListener = jest.fn(); + +describe("copyToTheClipboard()", () => { + beforeEach(() => { + expectedCopyEvent = "copy"; + clipboardTestCopyString = "content intended for clipboard"; + + global.document.addEventListener = addEventListener; + global.document.execCommand = execCommand; + global.document.removeEventListener = removeEventListener; + }); + + it("listens for 'copy' event", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(document.addEventListener).toHaveBeenCalledWith( + expectedCopyEvent, + expect.anything() + ); + }); + + it("calls document.execCommand() with 'copy' command", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(execCommand).toHaveBeenCalledWith(expectedCopyEvent, false, null); + }); + + it("removes event listener for 'copy' event", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(document.removeEventListener).toHaveBeenCalledWith( + expectedCopyEvent, + expect.anything() + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/expressions.spec.js b/devtools/client/debugger/src/utils/tests/expressions.spec.js new file mode 100644 index 0000000000..c2760c2d15 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/expressions.spec.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { wrapExpression, getValue } from "../expressions"; +import { makeMockExpression } from "../test-mockup"; + +function createError(type, preview) { + return makeMockExpression({ + result: { getGrip: () => ({ class: type, isError: true, preview }) }, + }); +} + +describe("expressions", () => { + describe("wrap exxpression", () => { + it("should wrap an expression", () => { + expect(wrapExpression("foo")).toMatchSnapshot(); + }); + + it("should wrap expression with a comment", () => { + expect(wrapExpression("foo // yo yo")).toMatchSnapshot(); + }); + + it("should wrap quotes", () => { + expect(wrapExpression('"2"')).toMatchSnapshot(); + }); + }); + + describe("sanitize input", () => { + it("sanitizes quotes", () => { + expect('foo"').toEqual('foo"'); + }); + + it("sanitizes 2 quotes", () => { + expect('"3"').toEqual('"3"'); + }); + + it("evaluates \\u{61} as a", () => { + expect("\u{61}").toEqual("a"); + }); + + it("evaluates N\\u{61}N as NaN", () => { + expect("N\u{61}N").toEqual("NaN"); + }); + }); + + describe("getValue", () => { + it("Reference Errors should be shown as (unavailable)", () => { + expect( + getValue(createError("ReferenceError", { name: "ReferenceError" })) + ).toEqual({ + unavailable: true, + }); + }); + + it("Errors messages should be shown", () => { + expect( + getValue(createError("Error", { name: "Foo", message: "YO" })) + ).toEqual("Foo: YO"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/function.spec.js b/devtools/client/debugger/src/utils/tests/function.spec.js new file mode 100644 index 0000000000..abb65b47c3 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/function.spec.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { findFunctionText } from "../function"; + +import { getSymbols } from "../../workers/parser/getSymbols"; +import { populateOriginalSource } from "../../workers/parser/tests/helpers"; + +describe("function", () => { + describe("findFunctionText", () => { + it("finds function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + const text = findFunctionText(14, source, symbols); + expect(text).toMatchSnapshot(); + }); + + it("finds function signature", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(13, source, symbols); + expect(text).toMatchSnapshot(); + }); + + it("misses function closing brace", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(15, source, symbols); + + // TODO: we should try and match the closing bracket. + expect(text).toEqual(null); + }); + + it("finds property function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(29, source, symbols); + expect(text).toMatchSnapshot(); + }); + + it("finds class function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(33, source, symbols); + expect(text).toMatchSnapshot(); + }); + + it("cant find function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(20, source, symbols); + expect(text).toEqual(null); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/indentation.spec.js b/devtools/client/debugger/src/utils/tests/indentation.spec.js new file mode 100644 index 0000000000..3681f59707 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/indentation.spec.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { correctIndentation, getIndentation } from "../indentation"; + +describe("indentation", () => { + it("simple", () => { + expect( + correctIndentation(` + foo + `) + ).toMatchSnapshot(); + }); + + it("one line", () => { + expect(correctIndentation("foo")).toMatchSnapshot(); + }); + + it("one function", () => { + const text = ` + function foo() { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); + + it("try catch", () => { + const text = ` + try { + console.log("yo") + } catch (e) { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); + + it("mad indentation", () => { + const text = ` + try { + console.log("yo") + } catch (e) { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); +}); + +describe("indentation length", () => { + it("leading spaces", () => { + const line = " console.log('Hello World');"; + + expect(getIndentation(line)).toEqual(16); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/isMinified.spec.js b/devtools/client/debugger/src/utils/tests/isMinified.spec.js new file mode 100644 index 0000000000..b4f73669ab --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/isMinified.spec.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { isMinified } from "../isMinified"; +import { makeMockSourceWithContent } from "../test-mockup"; + +describe("isMinified", () => { + it("no indents", () => { + const sourceWithContent = makeMockSourceWithContent( + undefined, + undefined, + undefined, + "function base(boo) {\n}" + ); + expect(isMinified(sourceWithContent)).toBe(true); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/location.spec.js b/devtools/client/debugger/src/utils/tests/location.spec.js new file mode 100644 index 0000000000..2be71f3cae --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/location.spec.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { sortSelectedLocations } from "../location"; + +function loc(line, column) { + return { + location: { sourceId: "foo", line, column }, + generatedLocation: { sourceId: "foo", line, column }, + }; +} +describe("location.spec.js", () => { + it("sorts lines", () => { + const a = loc(3, 5); + const b = loc(1, 10); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); + + it("sorts columns", () => { + const a = loc(3, 10); + const b = loc(3, 5); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); + + it("prioritizes undefined columns", () => { + const a = loc(3, 10); + const b = loc(3, undefined); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/log.spec.js b/devtools/client/debugger/src/utils/tests/log.spec.js new file mode 100644 index 0000000000..645a1054bd --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/log.spec.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { prefs } from "../prefs"; +import { log } from "../log.js"; + +let logArgFirst, logArgSecond, logArgArray; + +describe("log()", () => { + beforeEach(() => { + logArgFirst = "my info"; + logArgSecond = "my other info"; + logArgArray = [logArgFirst, logArgSecond]; + global.console = { log: jest.fn() }; + }); + + afterEach(() => { + prefs.logging = false; + }); + + describe("when logging pref is true", () => { + it("prints arguments", () => { + prefs.logging = true; + log(logArgArray); + + expect(global.console.log).toHaveBeenCalledWith(logArgArray); + }); + + it("does not print by default", () => { + log(logArgArray); + expect(global.console.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/memoize.spec.js b/devtools/client/debugger/src/utils/tests/memoize.spec.js new file mode 100644 index 0000000000..309e12c88d --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/memoize.spec.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import memoize from "../memoize"; + +const a = { number: 3 }; +const b = { number: 4 }; +const c = { number: 5 }; +const d = { number: 6 }; + +function add(...numberObjects) { + return numberObjects.reduce((prev, cur) => prev + cur.number, 0); +} + +describe("memozie", () => { + it("should work for one arg as key", () => { + const mAdd = memoize(add); + mAdd(a); + expect(mAdd(a)).toEqual(3); + mAdd(b); + expect(mAdd(b)).toEqual(4); + }); + + it("should only be called once", () => { + const spy = jest.fn(() => 2); + const mAdd = memoize(spy); + + mAdd(a); + mAdd(a); + mAdd(a); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("should work for two args as key", () => { + const mAdd = memoize(add); + expect(mAdd(a, b)).toEqual(7); + expect(mAdd(a, b)).toEqual(7); + expect(mAdd(a, c)).toEqual(8); + }); + + it("should work with many args as key", () => { + const mAdd = memoize(add); + expect(mAdd(a, b, c)).toEqual(12); + expect(mAdd(a, b, d)).toEqual(13); + expect(mAdd(a, b, c)).toEqual(12); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js new file mode 100644 index 0000000000..53426c8aa4 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { memoizeLast } from "../memoizeLast"; + +const a = { number: 3 }; +const b = { number: 4 }; + +function add(...numberObjects) { + return numberObjects.reduce((prev, cur) => prev + cur.number, 0); +} + +describe("memozie", () => { + it("should re-calculate when a value changes", () => { + const mAdd = memoizeLast(add); + mAdd(a); + expect(mAdd(a)).toEqual(3); + mAdd(b); + expect(mAdd(b)).toEqual(4); + }); + + it("should only run once", () => { + const mockAdd = jest.fn(add); + const mAdd = memoizeLast(mockAdd); + mAdd(a); + mAdd(a); + + expect(mockAdd.mock.calls[0]).toEqual([{ number: 3 }]); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/path.spec.js b/devtools/client/debugger/src/utils/tests/path.spec.js new file mode 100644 index 0000000000..c691bb1ebf --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/path.spec.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { basename, dirname, isURL, isAbsolute, join } from "../path"; + +const fullTestURL = "https://www.example.com/some/endpoint"; +const absoluteTestPath = "/some/absolute/path/to/resource"; +const aTestName = "name"; + +describe("basename()", () => { + it("returns the basename of the path", () => { + expect(basename(fullTestURL)).toBe("endpoint"); + }); +}); + +describe("dirname()", () => { + it("returns the current directory in a path", () => { + expect(dirname(fullTestURL)).toBe("https://www.example.com/some"); + }); +}); + +describe("isURL()", () => { + it("returns true if a string contains characters denoting a scheme", () => { + expect(isURL(fullTestURL)).toBe(true); + }); + + it("returns false if string does not denote a scheme", () => { + expect(isURL(absoluteTestPath)).toBe(false); + }); +}); + +describe("isAbsolute()", () => { + it("returns true if a string begins with a slash", () => { + expect(isAbsolute(absoluteTestPath)).toBe(true); + }); + + it("returns false if a string does not begin with a slash", () => { + expect(isAbsolute(fullTestURL)).toBe(false); + }); +}); + +describe("join()", () => { + it("concatenates a base path and a directory name", () => { + expect(join(absoluteTestPath, aTestName)).toBe( + "/some/absolute/path/to/resource/name" + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/project-search.spec.js b/devtools/client/debugger/src/utils/tests/project-search.spec.js new file mode 100644 index 0000000000..62c1c58aeb --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/project-search.spec.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { highlightMatches } from "../project-search"; + +describe("project search - highlightMatches", () => { + it("simple", () => { + const lineMatch = { + type: "MATCH", + value: "This is a sample sentence", + line: 1, + column: 17, + matchIndex: 17, + match: "sentence", + sourceId: "source", + text: "text", + }; + + expect(highlightMatches(lineMatch)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/quick-open.spec.js b/devtools/client/debugger/src/utils/tests/quick-open.spec.js new file mode 100644 index 0000000000..517a7f53ab --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/quick-open.spec.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import cases from "jest-in-case"; +import { parseQuickOpenQuery, parseLineColumn } from "../quick-open"; + +cases( + "parseQuickOpenQuery utility", + ({ type, query }) => expect(parseQuickOpenQuery(query)).toEqual(type), + [ + { name: "empty query defaults to sources", type: "sources", query: "" }, + { name: "sources query", type: "sources", query: "test" }, + { name: "functions query", type: "functions", query: "@test" }, + { name: "variables query", type: "variables", query: "#test" }, + { name: "goto line", type: "goto", query: ":30" }, + { name: "goto line:column", type: "goto", query: ":30:60" }, + { name: "goto source line", type: "gotoSource", query: "test:30:60" }, + { name: "shortcuts", type: "shortcuts", query: "?" }, + ] +); + +cases( + "parseLineColumn utility", + ({ query, location }) => expect(parseLineColumn(query)).toEqual(location), + [ + { name: "empty query", query: "", location: undefined }, + { name: "just line", query: ":30", location: { line: 30 } }, + { + name: "line and column", + query: ":30:90", + location: { column: 90, line: 30 }, + }, + ] +); diff --git a/devtools/client/debugger/src/utils/tests/result-list.spec.js b/devtools/client/debugger/src/utils/tests/result-list.spec.js new file mode 100644 index 0000000000..d72b4f42a2 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/result-list.spec.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { scrollList } from "../result-list.js"; + +describe("scrollList", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it("just returns if element not found", () => { + const li = document.createElement("li"); + scrollList([li], 1); + }); + + it("calls scrollIntoView ", () => { + const ul = document.createElement("ul"); + const li = document.createElement("li"); + + (li: any).scrollIntoView = jest.fn(); + ul.appendChild(li); + + scrollList([li], 0); + + jest.runAllTimers(); + + expect(li.scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/source.spec.js b/devtools/client/debugger/src/utils/tests/source.spec.js new file mode 100644 index 0000000000..86d21c5997 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/source.spec.js @@ -0,0 +1,602 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + getFilename, + getTruncatedFileName, + getFileURL, + getDisplayPath, + getMode, + getSourceLineCount, + isThirdParty, + isJavaScript, + underRoot, + isUrlExtension, + isExtensionDirectoryPath, + getLineText, +} from "../source.js"; + +import { + makeMockSource, + makeMockSourceWithContent, + makeMockSourceAndContent, + makeMockWasmSourceWithContent, + makeMockThread, + makeFullfilledMockSourceContent, +} from "../test-mockup"; +import { isFulfilled } from "../async-value.js"; + +import type { Source } from "../../types"; + +const defaultSymbolDeclarations = { + classes: [], + functions: [], + memberExpressions: [], + callExpressions: [], + objectProperties: [], + identifiers: [], + imports: [], + comments: [], + literals: [], + hasJsx: false, + hasTypes: false, + loading: false, + framework: undefined, +}; + +describe("sources", () => { + const unicode = "\u6e2c"; + const encodedUnicode = encodeURIComponent(unicode); + + describe("getFilename", () => { + it("should give us a default of (index)", () => { + expect( + getFilename(makeMockSource("http://localhost.com:7999/increment/")) + ).toBe("(index)"); + }); + it("should give us the filename", () => { + expect( + getFilename( + makeMockSource("http://localhost.com:7999/increment/hello.html") + ) + ).toBe("hello.html"); + }); + it("should give us the readable Unicode filename if encoded", () => { + expect( + getFilename( + makeMockSource( + `http://localhost.com:7999/increment/${encodedUnicode}.html` + ) + ) + ).toBe(`${unicode}.html`); + }); + it("should give us the filename excluding the query strings", () => { + expect( + getFilename( + makeMockSource( + "http://localhost.com:7999/increment/hello.html?query_strings" + ) + ) + ).toBe("hello.html"); + }); + it("should give us the proper filename for pretty files", () => { + expect( + getFilename( + makeMockSource( + "http://localhost.com:7999/increment/hello.html:formatted" + ) + ) + ).toBe("hello.html"); + }); + }); + + describe("getTruncatedFileName", () => { + it("should truncate the file name when it is more than 30 chars", () => { + expect( + getTruncatedFileName( + makeMockSource( + "really-really-really-really-really-really-long-name.html" + ), + "", + 30 + ) + ).toBe("really-really…long-name.html"); + }); + it("should first decode the filename and then truncate it", () => { + expect( + getTruncatedFileName( + makeMockSource(`${encodedUnicode.repeat(30)}.html`), + "", + 30 + ) + ).toBe("測測測測測測測測測測測測測…測測測測測測測測測.html"); + }); + }); + + describe("getDisplayPath", () => { + it("should give us the path for files with same name", () => { + const sources: Source[] = [ + makeMockSource("http://localhost.com:7999/increment/xyz/hello.html"), + makeMockSource("http://localhost.com:7999/increment/abc/hello.html"), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource("http://localhost.com:7999/increment/abc/hello.html"), + sources + ) + ).toBe("abc"); + }); + + it(`should give us the path for files with same name + in directories with same name`, () => { + const sources: Source[] = [ + makeMockSource( + "http://localhost.com:7999/increment/xyz/web/hello.html" + ), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + sources + ) + ).toBe("abc/web"); + }); + + it("should give no path for files with unique name", () => { + const sources: Source[] = [ + makeMockSource("http://localhost.com:7999/increment/xyz.html"), + makeMockSource("http://localhost.com:7999/increment/abc.html"), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource("http://localhost.com:7999/increment/abc/web.html"), + sources + ) + ).toBe(undefined); + }); + it("should not show display path for pretty file", () => { + const sources: Source[] = [ + makeMockSource("http://localhost.com:7999/increment/abc/web/hell.html"), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + makeMockSource( + "http://localhost.com:7999/increment/xyz.html:formatted" + ), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + sources + ) + ).toBe(undefined); + }); + it(`should give us the path for files with same name when both + are pretty and different path`, () => { + const sources: Source[] = [ + makeMockSource( + "http://localhost.com:7999/increment/xyz/web/hello.html:formatted" + ), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + makeMockSource( + "http://localhost.com:7999/increment/hello.html:formatted" + ), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + sources + ) + ).toBe("abc/web"); + }); + }); + + describe("getFileURL", () => { + it("should give us the file url", () => { + expect( + getFileURL( + makeMockSource("http://localhost.com:7999/increment/hello.html") + ) + ).toBe("http://localhost.com:7999/increment/hello.html"); + }); + it("should truncate the file url when it is more than 50 chars", () => { + expect( + getFileURL( + makeMockSource("http://localhost-long.com:7999/increment/hello.html") + ) + ).toBe("…ttp://localhost-long.com:7999/increment/hello.html"); + }); + it("should first decode the file URL and then truncate it", () => { + expect( + getFileURL(makeMockSource(`http://${encodedUnicode.repeat(39)}.html`)) + ).toBe(`…ttp://${unicode.repeat(39)}.html`); + }); + }); + + describe("isJavaScript", () => { + it("is not JavaScript", () => { + { + const source = makeMockSourceAndContent("foo.html", undefined, ""); + expect(isJavaScript(source, source.content)).toBe(false); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/html" + ); + expect(isJavaScript(source, source.content)).toBe(false); + } + }); + + it("is JavaScript", () => { + { + const source = makeMockSourceAndContent("foo.js"); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent("bar.jsm"); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/javascript" + ); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "application/javascript" + ); + expect(isJavaScript(source, source.content)).toBe(true); + } + }); + }); + + describe("isThirdParty", () => { + it("node_modules", () => { + expect(isThirdParty(makeMockSource("/node_modules/foo.js"))).toBe(true); + }); + + it("bower_components", () => { + expect(isThirdParty(makeMockSource("/bower_components/foo.js"))).toBe( + true + ); + }); + + it("not third party", () => { + expect(isThirdParty(makeMockSource("/bar/foo.js"))).toBe(false); + }); + }); + + describe("getMode", () => { + it("//@flow", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/javascript", + "// @flow" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("/* @flow */", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/javascript", + " /* @flow */" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("mixed html", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "", + " <html" + ); + expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" }); + }); + + it("elm", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/x-elm", + 'main = text "Hello, World!"' + ); + expect(getMode(source, source.content)).toEqual({ name: "elm" }); + }); + + it("returns jsx if contentType jsx is given", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns jsx if sourceMetaData says it's a react component", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "", + "<h1></h1>" + ); + expect( + getMode(source, source.content, { + ...defaultSymbolDeclarations, + hasJsx: true, + }) + ).toEqual({ name: "jsx" }); + }); + + it("returns jsx if the fileExtension is .jsx", () => { + const source = makeMockSourceAndContent( + "myComponent.jsx", + undefined, + "", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns text/x-haxe if the file extension is .hx", () => { + const source = makeMockSourceAndContent( + "myComponent.hx", + undefined, + "", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" }); + }); + + it("typescript", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/typescript", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("typescript-jsx", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/typescript-jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content).base).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("cross-platform clojure(script) with reader conditionals", () => { + const source = makeMockSourceAndContent( + "my-clojurescript-source-with-reader-conditionals.cljc", + undefined, + "text/x-clojure", + "(defn str->int [s] " + + " #?(:clj (java.lang.Integer/parseInt s) " + + " :cljs (js/parseInt s)))" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("clojurescript", () => { + const source = makeMockSourceAndContent( + "my-clojurescript-source.cljs", + undefined, + "text/x-clojurescript", + "(+ 1 2 3)" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("coffeescript", () => { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/coffeescript", + "x = (a) -> 3" + ); + expect(getMode(source, source.content)).toEqual({ name: "coffeescript" }); + }); + + it("wasm", () => { + const source = makeMockWasmSourceWithContent({ + binary: "\x00asm\x01\x00\x00\x00", + }); + expect(getMode(source, source.content.value)).toEqual({ name: "text" }); + }); + + it("marko", () => { + const source = makeMockSourceAndContent( + "http://localhost.com:7999/increment/sometestfile.marko", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("es6", () => { + const source = makeMockSourceAndContent( + "http://localhost.com:7999/increment/sometestfile.es6", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("vue", () => { + const source = makeMockSourceAndContent( + "http://localhost.com:7999/increment/sometestfile.vue?query=string", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + }); + + describe("getSourceLineCount", () => { + it("should give us the amount bytes for wasm source", () => { + const { content } = makeMockWasmSourceWithContent({ + binary: "\x00asm\x01\x00\x00\x00", + }); + expect(getSourceLineCount(content.value)).toEqual(8); + }); + + it("should give us the amout of lines for js source", () => { + const { content } = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + "function foo(){\n}" + ); + if (!content || !isFulfilled(content)) { + throw new Error("Unexpected content value"); + } + expect(getSourceLineCount(content.value)).toEqual(2); + }); + }); + + describe("underRoot", () => { + const threads = [ + makeMockThread({ actor: "server0.conn1.child1/thread19" }), + ]; + + it("should detect normal source urls", () => { + const source = makeMockSource( + "resource://activity-stream/vendor/react.js" + ); + expect(underRoot(source, "resource://activity-stream", threads)).toBe( + true + ); + }); + + it("should detect source urls under chrome:// as root", () => { + const source = makeMockSource( + "chrome://browser/content/contentSearchUI.js" + ); + expect(underRoot(source, "chrome://", threads)).toBe(true); + }); + + it("should detect source urls if root is a thread actor Id", () => { + const source = makeMockSource( + "resource://activity-stream/vendor/react-dom.js" + ); + expect(underRoot(source, "server0.conn1.child1/thread19", threads)).toBe( + true + ); + }); + }); + + describe("isUrlExtension", () => { + it("should detect mozilla extension", () => { + expect(isUrlExtension("moz-extension://id/js/content.js")).toBe(true); + }); + it("should detect chrome extension", () => { + expect(isUrlExtension("chrome-extension://id/js/content.js")).toBe(true); + }); + it("should return false for non-extension assets", () => { + expect(isUrlExtension("https://example.org/init.js")).toBe(false); + }); + }); + + describe("isExtensionDirectoryPath", () => { + it("should detect mozilla extension directory", () => { + expect(isExtensionDirectoryPath("moz-extension://id")).toBe(true); + }); + it("should detect chrome extension directory", () => { + expect(isExtensionDirectoryPath("chrome-extension://id")).toBe(true); + }); + it("should return false for child file within the extension directory", () => { + expect(isExtensionDirectoryPath("moz-extension://id/js/content.js")).toBe( + false + ); + }); + }); + + describe("getLineText", () => { + it("first line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + 1 + ); + + expect(text).toEqual("aaa"); + }); + + it("last line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + 3 + ); + + expect(text).toEqual("ccc"); + }); + + it("one line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa"), + 1 + ); + + expect(text).toEqual("aaa"); + }); + + it("bad line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + + 5 + ); + + expect(text).toEqual(""); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/telemetry.spec.js b/devtools/client/debugger/src/utils/tests/telemetry.spec.js new file mode 100644 index 0000000000..d23d4facce --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/telemetry.spec.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +jest.mock("devtools/client/shared/telemetry", () => { + function MockTelemetry() {} + MockTelemetry.prototype.recordEvent = jest.fn(); + + return MockTelemetry; +}); + +// $FlowIgnore +const Telemetry = require("devtools/client/shared/telemetry"); + +import { recordEvent } from "../telemetry"; + +const telemetry = new Telemetry(); + +describe("telemetry.recordEvent()", () => { + it("Receives the correct telemetry information", () => { + recordEvent("foo", { bar: 1 }); + + expect(telemetry.recordEvent).toHaveBeenCalledWith( + "foo", + "debugger", + null, + { + // eslint-disable-next-line camelcase + session_id: -1, + bar: 1, + } + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/text.spec.js b/devtools/client/debugger/src/utils/tests/text.spec.js new file mode 100644 index 0000000000..52d1ca1b98 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/text.spec.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { truncateMiddleText } from "../text"; + +describe("text", () => { + it("should truncate the text in the middle", () => { + const sourceText = "this is a very long text and ends here"; + expect(truncateMiddleText(sourceText, 30)).toMatch( + "this is a ver… and ends here" + ); + }); + it("should keep the text as it is", () => { + const sourceText = "this is a short text ends here"; + expect(truncateMiddleText(sourceText, 30)).toMatch( + "this is a short text ends here" + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/ui.spec.js b/devtools/client/debugger/src/utils/tests/ui.spec.js new file mode 100644 index 0000000000..1e3baaf4a4 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/ui.spec.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { isVisible } from "../ui"; + +describe("ui", () => { + it("should return #mount width", () => { + if (!document.body) { + throw new Error("no document body"); + } + document.body.innerHTML = "<div id='mount'></div>"; + expect(isVisible()).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/url.spec.js b/devtools/client/debugger/src/utils/tests/url.spec.js new file mode 100644 index 0000000000..ed3749a4b0 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/url.spec.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { stripQuery, parse } from "../url"; + +describe("url", () => { + describe("stripQuery", () => { + it("strips properly", () => { + expect(stripQuery("/file/path")).toBe("/file/path"); + expect(stripQuery("/file/path?param")).toBe("/file/path"); + expect(stripQuery("/file/path#hash")).toBe("/file/path#hash"); + expect(stripQuery("/file/path?param#hash")).toBe("/file/path#hash"); + }); + }); + + describe("parse", () => { + it("parses an absolute URL", () => { + const val = parse("http://example.com:8080/path/file.js"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe(""); + }); + + it("parses an absolute URL with query params", () => { + const val = parse("http://example.com:8080/path/file.js?param"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe(""); + }); + + it("parses an absolute URL with a fragment", () => { + const val = parse("http://example.com:8080/path/file.js#hash"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe("#hash"); + }); + + it("parses an absolute URL with query params and a fragment", () => { + const val = parse("http://example.com:8080/path/file.js?param#hash"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe("#hash"); + }); + + it("parses a partial URL", () => { + const val = parse("/path/file.js"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe(""); + }); + + it("parses a partial URL with query params", () => { + const val = parse("/path/file.js?param"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe(""); + }); + + it("parses a partial URL with a fragment", () => { + const val = parse("/path/file.js#hash"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe("#hash"); + }); + + it("parses a partial URL with query params and a fragment", () => { + const val = parse("/path/file.js?param#hash"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe("#hash"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/utils.spec.js b/devtools/client/debugger/src/utils/tests/utils.spec.js new file mode 100644 index 0000000000..342adee549 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/utils.spec.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { handleError, promisify, endTruncateStr, waitForMs } from "../utils"; + +describe("handleError()", () => { + const testErrorText = "ERROR: "; + const testErrorObject = { oh: "noes" }; + + beforeEach(() => { + global.console = { log: jest.fn() }; + }); + + it("logs error text with error value", () => { + handleError(testErrorObject); + + expect(console.log).toHaveBeenCalledWith(testErrorText, testErrorObject); + }); +}); + +describe("promisify()", () => { + let testPromise, testContext, testMethod, testArgs; + + beforeEach(() => { + testContext = {}; + testMethod = jest.fn(); + testArgs = []; + }); + + it("returns a Promise", () => { + testPromise = promisify(testContext, testMethod, testArgs); + + expect(testPromise instanceof Promise).toBe(true); + }); + + it("applies promisified method", () => { + testPromise = promisify(testContext, testMethod, testArgs); + + expect(testMethod).toHaveBeenCalledWith(testArgs, expect.anything()); + }); +}); + +describe("endTruncateStr()", () => { + let testString; + const testSize = 11; + + describe("when the string is larger than the specified size", () => { + it("returns an elipsis and characters at the end of the string", () => { + testString = "Mozilla Firefox is my favorite web browser"; + + expect(endTruncateStr(testString, testSize)).toBe("…web browser"); + }); + }); + + describe("when the string is not larger than the specified size", () => { + it("returns the string unchanged", () => { + testString = "Firefox"; + + expect(endTruncateStr(testString, testSize)).toBe(testString); + }); + }); +}); + +describe("waitForMs()", () => { + let testPromise; + const testMilliseconds = 10; + + beforeEach(() => { + global.setTimeout = jest.fn(); + }); + + it("returns a Promise", () => { + testPromise = waitForMs(testMilliseconds); + + expect(testPromise instanceof Promise).toBe(true); + }); + + it("calls setTimeout() on the resolve of the Promise", () => { + testPromise = waitForMs(testMilliseconds); + + expect(setTimeout).toHaveBeenCalledWith( + expect.anything(), + testMilliseconds + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/wasm.spec.js b/devtools/client/debugger/src/utils/tests/wasm.spec.js new file mode 100644 index 0000000000..f3509a1a62 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/wasm.spec.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + isWasm, + lineToWasmOffset, + wasmOffsetToLine, + clearWasmStates, + renderWasmText, +} from "../wasm.js"; + +import { makeMockWasmSourceWithContent } from "../test-mockup"; + +describe("wasm", () => { + // Compiled version of `(module (func (nop)))` + const SIMPLE_WASM = { + binary: + "\x00asm\x01\x00\x00\x00\x01\x84\x80\x80\x80\x00\x01`\x00\x00" + + "\x03\x82\x80\x80\x80\x00\x01\x00\x06\x81\x80\x80\x80\x00\x00" + + "\n\x89\x80\x80\x80\x00\x01\x83\x80\x80\x80\x00\x00\x01\v", + }; + const SIMPLE_WASM_TEXT = `(module + (func $func0 + nop + ) +)`; + const SIMPLE_WASM_NOP_TEXT_LINE = 2; + const SIMPLE_WASM_NOP_OFFSET = 46; + + describe("isWasm", () => { + it("should give us the false when wasm text was not registered", () => { + const sourceId = "source.0"; + expect(isWasm(sourceId)).toEqual(false); + }); + it("should give us the true when wasm text was registered", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + expect(isWasm(source.id)).toEqual(true); + // clear shall remove + clearWasmStates(); + expect(isWasm(source.id)).toEqual(false); + }); + }); + + describe("renderWasmText", () => { + it("render simple wasm", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + const lines = renderWasmText(source.id, source.content.value); + expect(lines.join("\n")).toEqual(SIMPLE_WASM_TEXT); + clearWasmStates(); + }); + }); + + describe("lineToWasmOffset", () => { + // Test data sanity check: checking if 'nop' is found in the SIMPLE_WASM. + expect(SIMPLE_WASM.binary[SIMPLE_WASM_NOP_OFFSET]).toEqual("\x01"); + + it("get simple wasm nop offset", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + const offset = lineToWasmOffset(source.id, SIMPLE_WASM_NOP_TEXT_LINE); + expect(offset).toEqual(SIMPLE_WASM_NOP_OFFSET); + clearWasmStates(); + }); + }); + + describe("wasmOffsetToLine", () => { + it("get simple wasm nop line", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + const line = wasmOffsetToLine(source.id, SIMPLE_WASM_NOP_OFFSET); + expect(line).toEqual(SIMPLE_WASM_NOP_TEXT_LINE); + clearWasmStates(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/text.js b/devtools/client/debugger/src/utils/text.js new file mode 100644 index 0000000000..b29c97b4ae --- /dev/null +++ b/devtools/client/debugger/src/utils/text.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Utils for keyboard command strings + * @module utils/text + */ +import Services from "devtools-services"; +const { appinfo } = Services; + +const isMacOS = appinfo.OS === "Darwin"; + +/** + * Formats key for use in tooltips + * For macOS we use the following unicode + * + * cmd ⌘ = \u2318 + * shift ⇧ – \u21E7 + * option (alt) ⌥ \u2325 + * + * For Win/Lin this replaces CommandOrControl or CmdOrCtrl with Ctrl + * + * @memberof utils/text + * @static + */ +export function formatKeyShortcut(shortcut: string): string { + if (isMacOS) { + return shortcut + .replace(/Shift\+/g, "\u21E7") + .replace(/Command\+|Cmd\+/g, "\u2318") + .replace(/CommandOrControl\+|CmdOrCtrl\+/g, "\u2318") + .replace(/Alt\+/g, "\u2325"); + } + return shortcut + .replace(/CommandOrControl\+|CmdOrCtrl\+/g, `${L10N.getStr("ctrl")}+`) + .replace(/Shift\+/g, "Shift+"); +} + +/** + * Truncates the received text to the maxLength in the format: + * Original: 'this is a very long text and ends here' + * Truncated: 'this is a ver...and ends here' + * @param {String} sourceText - Source text + * @param {Number} maxLength - Max allowed length + * @memberof utils/text + * @static + */ +export function truncateMiddleText( + sourceText: string, + maxLength: number +): string { + let truncatedText = sourceText; + if (sourceText.length > maxLength) { + truncatedText = `${sourceText.substring( + 0, + Math.round(maxLength / 2) - 2 + )}…${sourceText.substring( + sourceText.length - Math.round(maxLength / 2 - 1) + )}`; + } + return truncatedText; +} diff --git a/devtools/client/debugger/src/utils/timings.js b/devtools/client/debugger/src/utils/timings.js new file mode 100644 index 0000000000..c042bc0f22 --- /dev/null +++ b/devtools/client/debugger/src/utils/timings.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { zip } from "lodash"; + +export function getAsyncTimes(name: string): number[] { + return zip( + window.performance.getEntriesByName(`${name}_start`), + window.performance.getEntriesByName(`${name}_end`) + ).map(([start, end]) => +(end.startTime - start.startTime).toPrecision(2)); +} + +function getTimes(name): number[] { + return window.performance + .getEntriesByName(name) + .map(time => +time.duration.toPrecision(2)); +} + +function getStats( + times: number[] +): {| times: number[], avg: ?number, median: ?number |} { + if (times.length == 0) { + return { times: [], avg: null, median: null }; + } + const avg = times.reduce((sum, time) => time + sum, 0) / times.length; + const sortedtimings = [...times].sort((a, b) => a - b); + const median = sortedtimings[times.length / 2]; + return { + times, + avg: +avg.toPrecision(2), + median: +median.toPrecision(2), + }; +} + +export function steppingTimings(): any { + const commandTimings = getAsyncTimes("COMMAND"); + const pausedTimings = getTimes("PAUSED"); + + return { + commands: getStats(commandTimings), + paused: getStats(pausedTimings), + }; +} + +// console.log("..", asyncTimes("COMMAND")); diff --git a/devtools/client/debugger/src/utils/ui.js b/devtools/client/debugger/src/utils/ui.js new file mode 100644 index 0000000000..ef258dc052 --- /dev/null +++ b/devtools/client/debugger/src/utils/ui.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ +// @flow + +/* Checks to see if the root element is available and + * if the element is visible. We check the width of the element + * because it is more reliable than either checking a focus state or + * the visibleState or hidden property. + */ +export function isVisible(): boolean { + const el = document.querySelector("#mount"); + return !!(el && el.getBoundingClientRect().width > 0); +} + +/* Gets the line numbers width in the code editor + */ +export function getLineNumberWidth(editor: Object): number { + const { gutters } = editor.display; + const lineNumbers = gutters.querySelector(".CodeMirror-linenumbers"); + return lineNumbers?.clientWidth; +} + +/** + * Forces the breakpoint gutter to be the same size as the line + * numbers gutter. Editor CSS will absolutely position the gutter + * beneath the line numbers. This makes it easy to be flexible with + * how we overlay breakpoints. + */ +export function resizeBreakpointGutter(editor: Object): void { + const { gutters } = editor.display; + const breakpoints = gutters.querySelector(".breakpoints"); + if (breakpoints) { + breakpoints.style.width = `${getLineNumberWidth(editor)}px`; + } +} + +/** + * Forces the left toggle button in source header to be the same size + * as the line numbers gutter. + */ +export function resizeToggleButton(editor: Object): void { + const toggleButton = document.querySelector( + ".source-header .toggle-button-start" + ); + if (toggleButton) { + toggleButton.style.width = `${getLineNumberWidth(editor)}px`; + } +} diff --git a/devtools/client/debugger/src/utils/url.js b/devtools/client/debugger/src/utils/url.js new file mode 100644 index 0000000000..b421a95e9c --- /dev/null +++ b/devtools/client/debugger/src/utils/url.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { memoize } from "lodash"; +import { URL as URLParser } from "whatwg-url"; +import type { URL } from "../types"; + +const defaultUrl = { + hash: "", + host: "", + hostname: "", + href: "", + origin: "null", + password: "", + path: "", + pathname: "", + port: "", + protocol: "", + search: "", + // This should be a "URLSearchParams" object + searchParams: {}, + username: "", +}; + +export const stripQuery = memoize(function stripQueryAndHash(url: URL): URL { + let queryStart = url.indexOf("?"); + + let before = url; + let after = ""; + if (queryStart >= 0) { + const hashStart = url.indexOf("#"); + if (hashStart >= 0) { + if (hashStart < queryStart) { + queryStart = hashStart; + } + + after = url.slice(hashStart); + } + + before = url.slice(0, queryStart); + } + + return before + after; +}); + +export const parse = memoize(function parse(url: URL): any { + let urlObj; + try { + urlObj = new URLParser(url); + } catch (err) { + urlObj = { ...defaultUrl }; + // If we're given simply a filename... + if (url) { + const hashStart = url.indexOf("#"); + if (hashStart >= 0) { + urlObj.hash = url.slice(hashStart); + url = url.slice(0, hashStart); + + if (urlObj.hash === "#") { + // The standard URL parser does not include the ? unless there are + // parameters included in the search value. + urlObj.hash = ""; + } + } + + const queryStart = url.indexOf("?"); + if (queryStart >= 0) { + urlObj.search = url.slice(queryStart); + url = url.slice(0, queryStart); + + if (urlObj.search === "?") { + // The standard URL parser does not include the ? unless there are + // parameters included in the search value. + urlObj.search = ""; + } + } + + urlObj.pathname = url; + } + } + (urlObj: any).path = urlObj.pathname + urlObj.search; + + return urlObj; +}); + +export function sameOrigin(firstUrl: URL, secondUrl: URL): boolean { + return parse(firstUrl).origin == parse(secondUrl).origin; +} diff --git a/devtools/client/debugger/src/utils/utils.js b/devtools/client/debugger/src/utils/utils.js new file mode 100644 index 0000000000..0327a4f495 --- /dev/null +++ b/devtools/client/debugger/src/utils/utils.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { SourceContent } from "../types"; + +// $FlowIgnore +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +/** + * Utils for utils, by utils + * @module utils/utils + */ + +/** + * @memberof utils/utils + * @static + */ +export function handleError(err: any): void { + console.log("ERROR: ", err); +} + +/** + * @memberof utils/utils + * @static + */ +export function promisify( + context: any, + method: any, + ...args: any +): Promise<mixed> { + return new Promise((resolve, reject) => { + args.push(response => { + if (response.error) { + reject(response); + } else { + resolve(response); + } + }); + method.apply(context, args); + }); +} + +/** + * @memberof utils/utils + * @static + */ +export function endTruncateStr(str: any, size: number): string { + if (str.length > size) { + return `…${str.slice(str.length - size)}`; + } + return str; +} + +export function waitForMs(ms: number): Promise<void> { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function downloadFile(content: SourceContent, fileName: string): void { + if (content.type !== "text") { + return; + } + + const data = new TextEncoder().encode(content.value); + DevToolsUtils.saveAs(window, data, fileName); +} diff --git a/devtools/client/debugger/src/utils/wasm.js b/devtools/client/debugger/src/utils/wasm.js new file mode 100644 index 0000000000..78e667cf85 --- /dev/null +++ b/devtools/client/debugger/src/utils/wasm.js @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* @flow */ + +import { BinaryReader } from "wasmparser/dist/cjs/WasmParser"; +import { + WasmDisassembler, + NameSectionReader, +} from "wasmparser/dist/cjs/WasmDis"; + +import type { SourceId, WasmSourceContent } from "../types"; +type WasmState = { + lines: Array<number>, + offsets: Array<number>, +}; + +var wasmStates: { [string]: WasmState } = (Object.create(null): any); + +function maybeWasmSectionNameResolver(data: Uint8Array) { + try { + const parser = new BinaryReader(); + parser.setData(data.buffer, 0, data.length); + const reader = new NameSectionReader(); + reader.read(parser); + return reader.hasValidNames() ? reader.getNameResolver() : null; + } catch (ex) { + // Ignoring any errors during names section retrival. + return null; + } +} + +/** + * @memberof utils/wasm + * @static + */ +export function getWasmText(sourceId: SourceId, data: Uint8Array) { + const nameResolver = maybeWasmSectionNameResolver(data); + const parser = new BinaryReader(); + parser.setData(data.buffer, 0, data.length); + const dis = new WasmDisassembler(); + if (nameResolver) { + dis.nameResolver = nameResolver; + } + dis.addOffsets = true; + const done = dis.disassembleChunk(parser); + let result = dis.getResult(); + if (result.lines.length === 0) { + result = { lines: ["No luck with wast conversion"], offsets: [0], done }; + } + + const { offsets } = result; + const lines = []; + for (let i = 0; i < offsets.length; i++) { + lines[offsets[i]] = i; + } + + wasmStates[sourceId] = { offsets, lines }; + + return { lines: result.lines, done: result.done }; +} + +/** + * @memberof utils/wasm + * @static + */ +export function getWasmLineNumberFormatter(sourceId: SourceId) { + const codeOf0 = 48, + codeOfA = 65; + const buffer = [ + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + ]; + let last0 = 7; + return function(number: number) { + const offset = lineToWasmOffset(sourceId, number - 1); + if (offset == undefined) { + return ""; + } + let i = 7; + for (let n = offset; n !== 0 && i >= 0; n >>= 4, i--) { + const nibble = n & 15; + buffer[i] = nibble < 10 ? codeOf0 + nibble : codeOfA - 10 + nibble; + } + for (let j = i; j > last0; j--) { + buffer[j] = codeOf0; + } + last0 = i; + return String.fromCharCode.apply(null, buffer); + }; +} + +/** + * @memberof utils/wasm + * @static + */ +export function isWasm(sourceId: SourceId) { + return sourceId in wasmStates; +} + +/** + * @memberof utils/wasm + * @static + */ +export function lineToWasmOffset(sourceId: SourceId, number: number): ?number { + const wasmState = wasmStates[sourceId]; + if (!wasmState) { + return undefined; + } + let offset = wasmState.offsets[number]; + while (offset === undefined && number > 0) { + offset = wasmState.offsets[--number]; + } + return offset; +} + +/** + * @memberof utils/wasm + * @static + */ +export function wasmOffsetToLine(sourceId: SourceId, offset: number): ?number { + const wasmState = wasmStates[sourceId]; + if (!wasmState) { + return undefined; + } + return wasmState.lines[offset]; +} + +/** + * @memberof utils/wasm + * @static + */ +export function clearWasmStates() { + wasmStates = (Object.create(null): any); +} + +const wasmLines: WeakMap<WasmSourceContent, string[]> = new WeakMap(); +export function renderWasmText( + sourceId: SourceId, + content: WasmSourceContent +): string[] { + if (wasmLines.has(content)) { + return wasmLines.get(content) || []; + } + + // binary does not survive as Uint8Array, converting from string + const { binary } = content.value; + const data = new Uint8Array(binary.length); + for (let i = 0; i < data.length; i++) { + data[i] = binary.charCodeAt(i); + } + const { lines } = getWasmText(sourceId, data); + const MAX_LINES = 1000000; + if (lines.length > MAX_LINES) { + lines.splice(MAX_LINES, lines.length - MAX_LINES); + lines.push(";; .... text is truncated due to the size"); + } + + wasmLines.set(content, lines); + return lines; +} diff --git a/devtools/client/debugger/src/utils/worker.js b/devtools/client/debugger/src/utils/worker.js new file mode 100644 index 0000000000..09b6c1e59b --- /dev/null +++ b/devtools/client/debugger/src/utils/worker.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +export type Message = { + data: { + id: string, + method: string, + args: Array<any>, + }, +}; + +let msgId = 1; +/** + * @memberof utils/utils + * @static + */ +function workerTask(worker: any, method: string): Function { + return function(...args: any): Promise<any> { + return new Promise((resolve, reject) => { + const id = msgId++; + worker.postMessage({ id, method, args }); + + const listener = ({ data: result }) => { + if (result.id !== id) { + return; + } + + worker.removeEventListener("message", listener); + if (result.error) { + reject(result.error); + } else { + resolve(result.response); + } + }; + + worker.addEventListener("message", listener); + }); + }; +} + +function workerHandler(publicInterface: any): Function { + return function onTask(msg: Message) { + const { id, method, args } = msg.data; + const response = publicInterface[method].apply(null, args); + + if (response instanceof Promise) { + response + .then(val => self.postMessage({ id, response: val })) + .catch(error => self.postMessage({ id, error })); + } else { + self.postMessage({ id, response }); + } + }; +} + +export { workerTask, workerHandler }; |