diff options
Diffstat (limited to 'devtools/client/debugger/src/utils/pause')
34 files changed, 2912 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js new file mode 100644 index 0000000000..ad5af11980 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFrameUrl } from "./getFrameUrl"; +import { getLibraryFromUrl } from "./getLibraryFromUrl"; + +export function annotateFrames(frames) { + const annotatedFrames = frames.map(f => annotateFrame(f, frames)); + return annotateBabelAsyncFrames(annotatedFrames); +} + +function annotateFrame(frame, frames) { + const library = getLibraryFromUrl(frame, frames); + if (library) { + return { ...frame, library }; + } + + return frame; +} + +function annotateBabelAsyncFrames(frames) { + const babelFrameIndexes = getBabelFrameIndexes(frames); + const isBabelFrame = frameIndex => babelFrameIndexes.includes(frameIndex); + + return frames.map((frame, frameIndex) => + isBabelFrame(frameIndex) ? { ...frame, library: "Babel" } : frame + ); +} + +/** + * Returns all the indexes that are part of a babel async call stack. + * + * @param {Array<Object>} frames + * @returns Array<Integer> + */ +function getBabelFrameIndexes(frames) { + const startIndexes = []; + const endIndexes = []; + + frames.forEach((frame, index) => { + const frameUrl = getFrameUrl(frame); + + if ( + frameUrl.match(/regenerator-runtime/i) && + frame.displayName === "tryCatch" + ) { + startIndexes.push(index); + } + if (frame.displayName === "flush" && frameUrl.match(/_microtask/i)) { + endIndexes.push(index); + } + if (frame.displayName === "_asyncToGenerator/<") { + endIndexes.push(index + 1); + } + }); + + if (startIndexes.length != endIndexes.length || startIndexes.length === 0) { + return []; + } + + const babelFrameIndexes = []; + // We have the same number of start and end indexes, we can loop through one of them to + // build our async call stack index ranges + // e.g. if we have startIndexes: [1,5] and endIndexes: [3,8], we want to return [1,2,3,5,6,7,8] + startIndexes.forEach((startIndex, index) => { + const matchingEndIndex = endIndexes[index]; + for (let i = startIndex; i <= matchingEndIndex; i++) { + babelFrameIndexes.push(i); + } + }); + return babelFrameIndexes; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js new file mode 100644 index 0000000000..e497933e7f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// eslint-disable-next-line max-len +import { getFrameUrl } from "./getFrameUrl"; + +function collapseLastFrames(frames) { + const index = frames.findIndex(frame => + getFrameUrl(frame).match(/webpack\/bootstrap/i) + ); + + if (index == -1) { + return { newFrames: frames, lastGroup: [] }; + } + + const newFrames = frames.slice(0, index); + const lastGroup = frames.slice(index); + return { newFrames, lastGroup }; +} + +export function collapseFrames(frames) { + // We collapse groups of one so that user frames + // are not in a group of one + function addGroupToList(group, list) { + if (!group) { + return list; + } + + if (group.length > 1) { + list.push(group); + } else { + list = list.concat(group); + } + + return list; + } + const { newFrames, lastGroup } = collapseLastFrames(frames); + frames = newFrames; + let items = []; + let currentGroup = null; + let prevItem = null; + for (const frame of frames) { + const prevLibrary = prevItem?.library; + + if (!currentGroup) { + currentGroup = [frame]; + } else if (prevLibrary && prevLibrary == frame.library) { + currentGroup.push(frame); + } else { + items = addGroupToList(currentGroup, items); + currentGroup = [frame]; + } + + prevItem = frame; + } + + items = addGroupToList(currentGroup, items); + items = addGroupToList(lastGroup, items); + return items; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/displayName.js b/devtools/client/debugger/src/utils/pause/frames/displayName.js new file mode 100644 index 0000000000..fa261b9d78 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/displayName.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// eslint-disable-next-line max-len + +// Decodes an anonymous naming scheme that +// spider monkey implements based on "Naming Anonymous JavaScript Functions" +// http://johnjbarton.github.io/nonymous/index.html +const objectProperty = /([\w\d\$#]+)$/; +const arrayProperty = /\[(.*?)\]$/; +const functionProperty = /([\w\d]+)[\/\.<]*?$/; +const annonymousProperty = /([\w\d]+)\(\^\)$/; + +export function simplifyDisplayName(displayName) { + // if the display name has a space it has already been mapped + if (!displayName || /\s/.exec(displayName)) { + return displayName; + } + + const scenarios = [ + objectProperty, + arrayProperty, + functionProperty, + annonymousProperty, + ]; + + for (const reg of scenarios) { + const match = reg.exec(displayName); + if (match) { + return match[1]; + } + } + + return displayName; +} + +const displayNameMap = { + Babel: { + tryCatch: "Async", + }, + Backbone: { + "extend/child": "Create Class", + ".create": "Create Model", + }, + jQuery: { + "jQuery.event.dispatch": "Dispatch Event", + }, + React: { + // eslint-disable-next-line max-len + "ReactCompositeComponent._renderValidatedComponentWithoutOwnerOrContext/renderedElement<": + "Render", + _renderValidatedComponentWithoutOwnerOrContext: "Render", + }, + VueJS: { + "renderMixin/Vue.prototype._render": "Render", + }, + Webpack: { + // eslint-disable-next-line camelcase + __webpack_require__: "Bootstrap", + }, +}; + +function mapDisplayNames(frame, library) { + const { displayName } = frame; + return displayNameMap[library]?.[displayName] || displayName; +} + +function getFrameDisplayName(frame) { + const { displayName, originalDisplayName, userDisplayName, name } = frame; + return originalDisplayName || userDisplayName || displayName || name; +} + +export function formatDisplayName( + frame, + { shouldMapDisplayName = true } = {}, + l10n +) { + const { library } = frame; + let displayName = getFrameDisplayName(frame); + if (library && shouldMapDisplayName) { + displayName = mapDisplayNames(frame, library); + } + + return simplifyDisplayName(displayName) || l10n.getStr("anonymousFunction"); +} + +export function formatCopyName(frame, l10n) { + const displayName = formatDisplayName(frame, undefined, l10n); + if (!frame.source) { + throw new Error("no frame source"); + } + const fileName = frame.source.url || frame.source.id; + const frameLocation = frame.location.line; + + return `${displayName} (${fileName}#${frameLocation})`; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js new file mode 100644 index 0000000000..b03a4fd30f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getFrameUrl(frame) { + return frame?.source?.url ?? ""; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js new file mode 100644 index 0000000000..17e294f258 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFrameUrl } from "./getFrameUrl"; + +const libraryMap = [ + { + label: "Backbone", + pattern: /backbone/i, + }, + { + label: "Babel", + pattern: /node_modules\/@babel/i, + }, + { + label: "jQuery", + pattern: /jquery/i, + }, + { + label: "Preact", + pattern: /preact/i, + }, + { + label: "React", + pattern: + /(node_modules\/(?:react(-dom)?(-dev)?\/))|(react(-dom)?(-dev)?(\.[a-z]+)*\.js$)/, + }, + { + label: "Immutable", + pattern: /immutable/i, + }, + { + label: "Webpack", + pattern: /webpack\/bootstrap/i, + }, + { + label: "Express", + pattern: /node_modules\/express/, + }, + { + label: "Pug", + pattern: /node_modules\/pug/, + }, + { + label: "ExtJS", + pattern: /\/ext-all[\.\-]/, + }, + { + label: "MobX", + pattern: /mobx/i, + }, + { + label: "Underscore", + pattern: /underscore/i, + }, + { + label: "Lodash", + pattern: /lodash/i, + }, + { + label: "Ember", + pattern: /ember/i, + }, + { + label: "Choo", + pattern: /choo/i, + }, + { + label: "VueJS", + pattern: /vue(?:\.[a-z]+)*\.js/i, + }, + { + label: "RxJS", + pattern: /rxjs/i, + }, + { + label: "Angular", + pattern: /angular(?!.*\/app\/)/i, + contextPattern: /zone\.js/, + }, + { + label: "Redux", + pattern: /redux/i, + }, + { + label: "Dojo", + pattern: /dojo/i, + }, + { + label: "Marko", + pattern: /marko/i, + }, + { + label: "NuxtJS", + pattern: /[\._]nuxt/i, + }, + { + label: "Aframe", + pattern: /aframe/i, + }, + { + label: "NextJS", + pattern: /[\._]next/i, + }, +]; + +export function getLibraryFromUrl(frame, callStack = []) { + // @TODO each of these fns calls getFrameUrl, just call it once + // (assuming there's not more complex logic to identify a lib) + const frameUrl = getFrameUrl(frame); + + // Let's first check if the frame match a defined pattern. + let match = libraryMap.find(o => o.pattern.test(frameUrl)); + if (match) { + return match.label; + } + + // If it does not, it might still be one of the case where the file is used + // by a library but the name has not enough specificity. In such case, we want + // to only return the library name if there are frames matching the library + // pattern in the callStack (e.g. `zone.js` is used by Angular, but the name + // could be quite common and return false positive if evaluated alone. So we + // only return Angular if there are other frames matching Angular). + match = libraryMap.find( + o => o.contextPattern && o.contextPattern.test(frameUrl) + ); + if (match) { + const contextMatch = callStack.some(f => { + const url = getFrameUrl(f); + if (!url) { + return false; + } + + return libraryMap.some(o => o.pattern.test(url)); + }); + + if (contextMatch) { + return match.label; + } + } + + return null; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/index.js b/devtools/client/debugger/src/utils/pause/frames/index.js new file mode 100644 index 0000000000..0912be05d5 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/index.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./annotateFrames"; +export * from "./collapseFrames"; +export * from "./displayName"; +export * from "./getFrameUrl"; +export * from "./getLibraryFromUrl"; diff --git a/devtools/client/debugger/src/utils/pause/frames/moz.build b/devtools/client/debugger/src/utils/pause/frames/moz.build new file mode 100644 index 0000000000..5bb330a57f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "annotateFrames.js", + "collapseFrames.js", + "displayName.js", + "getFrameUrl.js", + "getLibraryFromUrl.js", + "index.js", +) diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap new file mode 100644 index 0000000000..89dbccd374 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collapseFrames default 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], +] +`; + +exports[`collapseFrames promises 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], + Object { + "asyncCause": "promise callback", + "displayName": "d", + "library": undefined, + }, + Array [ + Object { + "displayName": "e", + "library": "React", + }, + Object { + "displayName": "f", + "library": "React", + }, + ], + Object { + "asyncCause": null, + "displayName": "g", + "library": undefined, + }, +] +`; diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js new file mode 100644 index 0000000000..6579293b5f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { annotateFrames } from "../annotateFrames"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("annotateFrames", () => { + it("should return Angular", () => { + const callstack = [ + makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ), + makeMockFrameWithURL("/node_modules/zone/zone.js"), + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + const frames = annotateFrames(callstack); + expect(frames).toEqual(callstack.map(f => ({ ...f, library: "Angular" }))); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js new file mode 100644 index 0000000000..15210e0437 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { collapseFrames } from "../collapseFrames"; + +describe("collapseFrames", () => { + it("default", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + ]); + + expect(groups).toMatchSnapshot(); + }); + + it("promises", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + { + displayName: "d", + library: undefined, + asyncCause: "promise callback", + }, + { displayName: "e", library: "React" }, + { displayName: "f", library: "React" }, + { displayName: "g", library: undefined, asyncCause: null }, + ]); + + expect(groups).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js new file mode 100644 index 0000000000..d969c18753 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + formatCopyName, + formatDisplayName, + simplifyDisplayName, +} from "../displayName"; + +import { makeMockFrame, makeMockSource } from "../../../test-mockup"; + +describe("formatCopyName", () => { + it("simple", () => { + const source = makeMockSource("todo-view.js"); + const frame = makeMockFrame(undefined, source, undefined, 12, "child"); + + expect(formatCopyName(frame, L10N)).toEqual("child (todo-view.js#12)"); + }); +}); + +describe("formatting display names", () => { + it("uses a library description", () => { + const source = makeMockSource("assets/backbone.js"); + const frame = { + ...makeMockFrame(undefined, source, undefined, undefined, "extend/child"), + library: "Backbone", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("Create Class"); + }); + + it("shortens an anonymous function", () => { + const source = makeMockSource("assets/bar.js"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "extend/child/bar/baz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("baz"); + }); + + it("does not truncates long function names", () => { + const source = makeMockSource("extend/child/bar/baz"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual( + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + }); + + it("returns the original function name when present", () => { + const source = makeMockSource("entry.js"); + const frame = { + ...makeMockFrame(undefined, source), + originalDisplayName: "originalFn", + displayName: "fn", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("originalFn"); + }); + + it("returns anonymous when displayName is undefined", () => { + const frame = { ...makeMockFrame(), displayName: undefined }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is null", () => { + const frame = { ...makeMockFrame(), displayName: null }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is an empty string", () => { + const frame = { ...makeMockFrame(), displayName: "" }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); +}); + +describe("simplifying display names", () => { + const cases = { + defaultCase: [["define", "define"]], + + objectProperty: [ + ["z.foz", "foz"], + ["z.foz/baz", "baz"], + ["z.foz/baz/y.bay", "bay"], + ["outer/x.fox.bax.nx", "nx"], + ["outer/fow.baw", "baw"], + ["fromYUI._attach", "_attach"], + ["Y.ClassNameManager</getClassName", "getClassName"], + ["orion.textview.TextView</addHandler", "addHandler"], + ["this.eventPool_.createObject", "createObject"], + ], + + arrayProperty: [ + ["this.eventPool_[createObject]", "createObject"], + ["jQuery.each(^)/jQuery.fn[o]", "o"], + ["viewport[get+D]", "get+D"], + ["arr[0]", "0"], + ], + + functionProperty: [ + ["fromYUI._attach/<.", "_attach"], + ["Y.ClassNameManager<", "ClassNameManager"], + ["fromExtJS.setVisible/cb<", "cb"], + ["fromDojo.registerWin/<", "registerWin"], + ], + + annonymousProperty: [["jQuery.each(^)", "each"]], + + privateMethod: [["#privateFunc", "#privateFunc"]], + }; + + Object.keys(cases).forEach(type => { + cases[type].forEach(([kase, expected]) => { + it(`${type} - ${kase}`, () => + expect(simplifyDisplayName(kase)).toEqual(expected)); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js new file mode 100644 index 0000000000..ff5be43285 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getLibraryFromUrl } from "../getLibraryFromUrl"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("getLibraryFromUrl", () => { + describe("When Preact is on the frame", () => { + it("should return Preact and not React", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/preact/8.2.5/preact.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Preact"); + }); + }); + + describe("When Vue is on the frame", () => { + it("should return VueJS for different builds", () => { + const buildTypeList = [ + "vue.js", + "vue.common.js", + "vue.esm.js", + "vue.runtime.js", + "vue.runtime.common.js", + "vue.runtime.esm.js", + "vue.min.js", + "vue.runtime.min.js", + ]; + + buildTypeList.forEach(buildType => { + const frame = makeMockFrameWithURL( + `https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/${buildType}` + ); + expect(getLibraryFromUrl(frame)).toEqual("VueJS"); + }); + }); + }); + + describe("When React is in the URL", () => { + it("should not return React if it is not part of the filename", () => { + const notReactUrlList = [ + "https://react.js.com/test.js", + "https://debugger-example.com/test.js", + "https://debugger-react-example.com/test.js", + "https://debugger-react-example.com/react/test.js", + "https://debugger-example.com/react-contextmenu.js", + ]; + notReactUrlList.forEach(notReactUrl => { + const frame = makeMockFrameWithURL(notReactUrl); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + it("should return React if it is part of the filename", () => { + const reactUrlList = [ + "https://debugger-example.com/react.js", + "https://debugger-example.com/react.development.js", + "https://debugger-example.com/react.production.min.js", + "https://debugger-react-example.com/react.js", + "https://debugger-react-example.com/react/react.js", + "https://debugger-example.com/react-dom.js", + "https://debugger-example.com/react-dom.development.js", + "https://debugger-example.com/react-dom.production.min.js", + "https://debugger-react-example.com/react-dom.js", + "https://debugger-react-example.com/react/react-dom.js", + "https://debugger-react-example.com/react-dom-dev.js", + "/node_modules/react/test.js", + "/node_modules/react-dev/test.js", + "/node_modules/react-dom/test.js", + "/node_modules/react-dom-dev/test.js", + ]; + reactUrlList.forEach(reactUrl => { + const frame = makeMockFrameWithURL(reactUrl); + expect(getLibraryFromUrl(frame)).toEqual("React"); + }); + }); + }); + + describe("When Angular is in the URL", () => { + it("should return Angular for AngularJS (1.x)", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should return Angular for Angular (2.x)", () => { + const frame = makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should not return Angular for Angular components", () => { + const frame = makeMockFrameWithURL( + "https://firefox-devtools-angular-log.stackblitz.io/~/src/app/hello.component.ts" + ); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + + describe("When zone.js is on the frame", () => { + it("should not return Angular when no callstack", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + + it("should not return Angular when stack without Angular frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [frame]; + + expect(getLibraryFromUrl(frame, callstack)).toBeNull(); + }); + + it("should return Angular when stack with AngularJS (1.x) frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [ + frame, + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + + expect(getLibraryFromUrl(frame, callstack)).toEqual("Angular"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/index.js b/devtools/client/debugger/src/utils/pause/index.js new file mode 100644 index 0000000000..f6966999b0 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/index.js @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./why"; diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/README.md b/devtools/client/debugger/src/utils/pause/mapScopes/README.md new file mode 100644 index 0000000000..2f65b8e847 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/README.md @@ -0,0 +1,191 @@ +# mapScopes + +The files in this directory manage the Devtools logic for taking the original +JS file from the sourcemap and using the content, combined with source +mappings, to provide an enhanced user experience for debugging the code. + +In this document, we'll refer to the files as either: + +* `original` - The code that the user wrote originally. +* `generated` - The code actually executing in the engine, which may have been + transformed from the `original`, and if a bundler was used, may contain the + final output for several different `original` files. + +The enhancements implemented here break down into as two primary improvements: + +* Rendering the scopes as the user expects them, using the position in the + original file, rather than the content of the generated file. +* Allowing users to interact with the code in the console as if it were the + original code, rather than the generated file. + + +## Overall Approach + +The core goal of scope mapping is to parse the original and generated files, +and then infer a correlation between the bindings in the original file, +and the bindings in the generated file. This is correlation is done via the +source mappings provided alongside the original code in the sourcemap. + +The overall steps break down into: + + +### 1. Parsing + +First the generated and original files are parsed into ASTs, and then the ASTs +are each traversed in order to generate a full picture of the scopes that are +present in the file. This covers information for each binding in each scope, +including all metadata about the declarations themselves, and all references +to each of the bindings. The exact location of each binding reference is the +most important factor because these will be used when working with sourcemaps. + +Importantly, this scope tree's structure also mirrors the structure of the +scope data that the engine itself returns when paused. + + +### 2. Generated Binding -> Grip Correlation + +When the engine pauses, we get back a full description of the current scope, +as well as `grip` objects that can be used as proxy-like objects in order to +access the actual values in the engine itself. + +With this data, along with the location where the engine is paused, we can +use the AST-based scope information from step 1 to attach a line/column +range to each `grip`. Using those mappings we have enough information to get +the real engine value of any variable based on its position in the generated +code, as long as it is in scope at the paused location in the generated file. + +The generated and engine scopes are correlated depth-first, because in some +cases the scope data from the engine will be deeper that the data from the +parsed code, meaning there are additional unknown parent scopes. This can +happen, for instance, if the executing code is running inside of an `eval` +since the parser only knows about the scopes inside of `eval`, and cannot +know what context it is executed inside of. + + +### 3. Original Binding -> Generated Binding Correlation + +Now that we can get the value of a generated location, we need to decide which +generated location correlates with which original location. For each of +the original bindings in each original scope, we iterate through the +individual references to the binding in the file and: + +1. Use the sourcemap to convert the original location into a range on the + generated code. +2. Filter the available bindings in the generated file down to those that + overlap with this generated range. +3. Perform heuristics to decide if any of the bindings in the range appear + to be a valid and safe mapping. + +These steps allow us to build a datastructure that describes the scope +as it is declared in the original file, while rendering the value using the +`grip` objects, as returned from the engine itself. + +There is additional complexity here in the exact details of the heuristics +mentioned above. See the later Heuristics sections diving into these details. + +During this phase, one additional task is performed, which is the construction +of expressions that relate back to the original binding. After matching each +binding in a range, we know the name of the binding in the original scope, +and we _also_ know the name of the generated binding, or more generally the +expression to evaluate in order to get the value of the original binding. + +These expression values are important for ensuring that the developer console +is able to allow users to interact with the original code. If a user types +a variable into the console, it can be translated into the expression +correlated with that original variable, allowing the code to behave as it +would have, were it actually part of the original file. + + +### 4. Generate a Scope Tree + +The structure generated in step 3 is converted into a structure compatible +with the standard scope data returned from the engine itself in order to allow +for consistent usage of scope data, irrespective of the scope data source. +This stage also re-attaches the global scope data, which is otherwise ignored +during the correlation process. + + +### 5. Validation + +As a simple form of validation, we ensure that a large percentage of bindings +were actually resolved to a `grip` object. This offers some amount of +protection, if the sourcemap itself turned out to be somewhat low-quality. + +This happens often with tooling that generates maps that simply map an original +line to a generated line. While line-to-line mappings still enable step +debugging, they do not provide enough information for original-scope mapping. + +On the other hand, generally line-to-line mappings are usually generated by +tooling that performs minimal transformations on their own, so it is often +acceptable to fall back to the engine-only generated-file scope data in this +case. + + +## Range Mapping Heuristics + +Since we know lots of information about the original bindings when performing +matches, it is possible to make educated guesses about how their generated +code will have likely been transformed. This allows us to perform more +aggressive matching logic what would normally be too false-positive-heavy +for generic use, when it is deemed safe enough. + + +### Standard Matching + +In the general case, we iterate through the bindings that were found in the +generated range, and consider it a match if the binding overlaps with the +start of the range. One extra case here is that ranges at the start of +a line often point to the first binding in a range and do not overlap the +first binding, so there is special-casing for the first binding in each range. + + +### ES6 Import Reference Special Case + +References to ES6 imports are often transformed into property accesses on objects +in order to emulate the "live-binding" behavior defined by ES6. The standard +logic here would end up always resolving the imported value to be the import +namespace object itself, rather than reading the value of the property. + +To support this, specifically for ES6 imports, we walk outward from the matched +binding itself, taking property accesses into account, as long as the +property access itself is also within the mapped range. + +While decently effective, there are currently two downsides to this approach: + +* The "live" behavior of imports is often implemented using accessor + properties, which as of the time of this writing, cannot be evaluated to + retrieve their real value. +* The "live" behavior of imports is sometimes implemented with function calls, + which also also cannot be evaluated, causing their value to be + unknown. + + +### ES6 Import Declaration Special Case + +If there are no references to an imported value, or matching based on the +reference failed, we fall back to a second case. + +ES6 import declarations themselves often map back to the location of the +declaration of the imported module's namespace object. By getting the range for +the import declaration itself, we can infer which generated binding is the +namespace object. Alongside that, we already know the name of the property on +the namespace itself because it is statically knowable by analyzing the +import declaration. + +By combining both of those pieces of information, we can access the namespace's +property to get the imported value. + + +### Typescript Classes + +Typescript have several non-ideal ways in which they output source maps, most +of which center around classes that are either exported, or decorated. These +issues are currently tracked in [Typescript issue #22833][1]. + +To work around this issue, we use a method similar to the import declaration +case above. While the class name itself often maps to unhelpful locations, +the class declaration itself generally maps to the class's transformed binding, +so we make use of the class declaration location instead of the location of +the class's declared name in these cases. + + [1]: https://github.com/Microsoft/TypeScript/issues/22833 diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js new file mode 100644 index 0000000000..5f90138013 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { clientCommands } from "../../../client/firefox"; + +import { locColumn } from "./locColumn"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +export function buildGeneratedBindingList( + scopes, + generatedAstScopes, + thisBinding +) { + // The server's binding data doesn't include general 'this' binding + // information, so we manually inject the one 'this' binding we have into + // the normal binding data we are working with. + const frameThisOwner = generatedAstScopes.find( + generated => "this" in generated.bindings + ); + + let globalScope = null; + const clientScopes = []; + for (let s = scopes; s; s = s.parent) { + const bindings = s.bindings + ? Object.assign({}, ...s.bindings.arguments, s.bindings.variables) + : {}; + + clientScopes.push(bindings); + globalScope = s; + } + + const generatedMainScopes = generatedAstScopes.slice(0, -2); + const generatedGlobalScopes = generatedAstScopes.slice(-2); + + const clientMainScopes = clientScopes.slice(0, generatedMainScopes.length); + const clientGlobalScopes = clientScopes.slice(generatedMainScopes.length); + + // Map the main parsed script body using the nesting hierarchy of the + // generated and client scopes. + const generatedBindings = generatedMainScopes.reduce((acc, generated, i) => { + const bindings = clientMainScopes[i]; + + if (generated === frameThisOwner && thisBinding) { + bindings.this = { + value: thisBinding, + }; + } + + for (const name of Object.keys(generated.bindings)) { + // If there is no 'this' value, we exclude the binding entirely. + // Otherwise it would pass through as found, but "(unscoped)", causing + // the search logic to stop with a match. + if (name === "this" && !bindings[name]) { + continue; + } + + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name] || null), + }); + } + } + return acc; + }, []); + + // Bindings in the global/lexical global of the generated code may or + // may not be the real global if the generated code is running inside + // of an evaled context. To handle this, we just look up the client scope + // hierarchy to find the closest binding with that name. + for (const generated of generatedGlobalScopes) { + for (const name of Object.keys(generated.bindings)) { + const { refs } = generated.bindings[name]; + const bindings = clientGlobalScopes.find(b => name in b); + + for (const loc of refs) { + if (bindings) { + generatedBindings.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name]), + }); + } else { + const globalGrip = globalScope?.object; + if (globalGrip) { + // Should always exist, just checking to keep Flow happy. + + generatedBindings.push({ + name, + loc, + desc: async () => { + const objectFront = + clientCommands.createObjectFront(globalGrip); + return (await objectFront.getProperty(name)).descriptor; + }, + }); + } + } + } + } + } + + // Sort so we can binary-search. + return sortBindings(generatedBindings); +} + +export function buildFakeBindingList(generatedAstScopes) { + // TODO if possible, inject real bindings for the global scope + const generatedBindings = generatedAstScopes.reduce((acc, generated) => { + for (const name of Object.keys(generated.bindings)) { + if (name === "this") { + continue; + } + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(getOptimizedOutGrip()), + }); + } + } + return acc; + }, []); + return sortBindings(generatedBindings); +} + +function sortBindings(generatedBindings) { + return generatedBindings.sort((a, b) => { + const aStart = a.loc.start; + const bStart = b.loc.start; + + if (aStart.line === bStart.line) { + return locColumn(aStart) - locColumn(bStart); + } + return aStart.line - bStart.line; + }); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js new file mode 100644 index 0000000000..dec3772e82 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function findInsertionLocation(array, callback) { + let left = 0; + let right = array.length; + while (left < right) { + const mid = Math.floor((left + right) / 2); + const item = array[mid]; + + const result = callback(item); + if (result === 0) { + left = mid; + break; + } + if (result >= 0) { + right = mid; + } else { + left = mid + 1; + } + } + + // Ensure the value is the start of any set of matches. + let i = left; + if (i < array.length) { + while (i >= 0 && callback(array[i]) >= 0) { + i--; + } + return i + 1; + } + + return i; +} + +export function filterSortedArray(array, callback) { + const start = findInsertionLocation(array, callback); + + const results = []; + for (let i = start; i < array.length && callback(array[i]) === 0; i++) { + results.push(array[i]); + } + + return results; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js new file mode 100644 index 0000000000..6e833959f4 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js @@ -0,0 +1,305 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; +import { mappingContains } from "./mappingContains"; + +// eslint-disable-next-line max-len + +import { clientCommands } from "../../../client/firefox"; + +/** + * Given a mapped range over the generated source, attempt to resolve a real + * binding descriptor that can be used to access the value. + */ +export async function findGeneratedReference(applicableBindings) { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 4) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapBindingReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + return null; +} + +export async function findGeneratedImportReference(applicableBindings) { + // When wrapped, for instance as `Object(ns.default)`, the `Object` binding + // will be the first in the list. To avoid resolving `Object` as the + // value of the import itself, we potentially skip the first binding. + applicableBindings = applicableBindings.filter((applicable, i) => { + if ( + !applicable.firstInRange || + applicable.binding.loc.type !== "ref" || + applicable.binding.loc.meta + ) { + return true; + } + + const next = + i + 1 < applicableBindings.length ? applicableBindings[i + 1] : null; + + return !next || next.binding.loc.type !== "ref" || !next.binding.loc.meta; + }); + + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 2) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapImportReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + + return null; +} + +/** + * Given a mapped range over the generated source and the name of the imported + * value that is referenced, attempt to resolve a binding descriptor for + * the import's value. + */ +export async function findGeneratedImportDeclaration( + applicableBindings, + importName +) { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 10) { + // Import declarations tend to have a large number of bindings for + // for things like 'require' and 'interop', so this number is larger + // than other binding count checks. + applicableBindings = []; + } + + let result = null; + + for (const { binding } of applicableBindings) { + if (binding.loc.type === "ref") { + continue; + } + + const namespaceDesc = await binding.desc(); + if (isPrimitiveValue(namespaceDesc)) { + continue; + } + if (!isObjectValue(namespaceDesc)) { + // We want to handle cases like + // + // var _mod = require(...); + // var _mod2 = _interopRequire(_mod); + // + // where "_mod" is optimized out because it is only referenced once. To + // allow that, we track the optimized-out value as a possible result, + // but allow later binding values to overwrite the result. + result = { + name: binding.name, + desc: namespaceDesc, + expression: binding.name, + }; + continue; + } + + const desc = await readDescriptorProperty(namespaceDesc, importName); + const expression = `${binding.name}.${importName}`; + + if (desc) { + result = { + name: binding.name, + desc, + expression, + }; + break; + } + } + + return result; +} + +/** + * Given a generated binding, and a range over the generated code, statically + * check if the given binding matches the range. + */ +async function mapBindingReferenceToDescriptor({ + binding, + range, + firstInRange, + firstOnLine, +}) { + // Allow the mapping to point anywhere within the generated binding + // location to allow for less than perfect sourcemaps. Since you also + // need at least one character between identifiers, we also give one + // characters of space at the front the generated binding in order + // to increase the probability of finding the right mapping. + if ( + range.start.line === binding.loc.start.line && + // If a binding is the first on a line, Babel will extend the mapping to + // include the whitespace between the newline and the binding. To handle + // that, we skip the range requirement for starting location. + (firstInRange || + firstOnLine || + locColumn(range.start) >= locColumn(binding.loc.start)) && + locColumn(range.start) <= locColumn(binding.loc.end) + ) { + return { + name: binding.name, + desc: await binding.desc(), + expression: binding.name, + }; + } + + return null; +} + +/** + * Given an generated binding, and a range over the generated code, statically + * evaluate accessed properties within the mapped range to resolve the actual + * imported value. + */ +async function mapImportReferenceToDescriptor({ binding, range }) { + if (binding.loc.type !== "ref") { + return null; + } + + // Expression matches require broader searching because sourcemaps usage + // varies in how they map certain things. For instance given + // + // import { bar } from "mod"; + // bar(); + // + // The "bar()" expression is generally expanded into one of two possibly + // forms, both of which map the "bar" identifier in different ways. See + // the "^^" markers below for the ranges. + // + // (0, foo.bar)() // Babel + // ^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // __webpack_require__.i(foo.bar)() // Webpack 2 + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // Object(foo.bar)() // Webpack >= 3 + // ^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // + // Unfortunately, Webpack also has a tendancy to over-map past the call + // expression to the start of the next line, at least when there isn't + // anything else on that line that is mapped, e.g. + // + // Object(foo.bar)() + // ^^^^^^^^^^^^^^^^^ + // ^ // wrapped to column 0 of next line + + if (!mappingContains(range, binding.loc)) { + return null; + } + + // Webpack 2's import declarations wrap calls with an identity fn, so we + // need to make sure to skip that binding because it is mapped to the + // location of the original binding usage. + if ( + binding.name === "__webpack_require__" && + binding.loc.meta && + binding.loc.meta.type === "member" && + binding.loc.meta.property === "i" + ) { + return null; + } + + let expression = binding.name; + let desc = await binding.desc(); + + if (binding.loc.type === "ref") { + const { meta } = binding.loc; + + // Limit to 2 simple property or inherits operartions, since it would + // just be more work to search more and it is very unlikely that + // bindings would be mapped to more than a single member + inherits + // wrapper. + for ( + let op = meta, index = 0; + op && mappingContains(range, op) && desc && index < 2; + index++, op = op?.parent + ) { + // Calling could potentially trigger side-effects, which would not + // be ideal for this case. + if (op.type === "call") { + return null; + } + + if (op.type === "inherit") { + continue; + } + + desc = await readDescriptorProperty(desc, op.property); + expression += `.${op.property}`; + } + } + + return desc + ? { + name: binding.name, + desc, + expression, + } + : null; +} + +function isPrimitiveValue(desc) { + return desc && (!desc.value || typeof desc.value !== "object"); +} +function isObjectValue(desc) { + return ( + desc && + !isPrimitiveValue(desc) && + desc.value.type === "object" && + // Note: The check for `.type` might already cover the optimizedOut case + // but not 100% sure, so just being cautious. + !desc.value.optimizedOut + ); +} + +async function readDescriptorProperty(desc, property) { + if (!desc) { + return null; + } + + if (typeof desc.value !== "object" || !desc.value) { + // If accessing a property on a primitive type, just return 'undefined' + // as the value. + return { + value: { + type: "undefined", + }, + }; + } + + if (!isObjectValue(desc)) { + // If we got a non-primitive descriptor but it isn't an object, then + // it's definitely not the namespace and it is probably an error. + return desc; + } + + const objectFront = clientCommands.createObjectFront(desc.value); + return (await objectFront.getProperty(property)).descriptor; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js new file mode 100644 index 0000000000..c1a64ddfa0 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; +import { mappingContains } from "./mappingContains"; +import { getGeneratedLocation } from "../../source-maps"; + +export async function originalRangeStartsInside({ start, end }, thunkArgs) { + const endPosition = await getGeneratedLocation(end, thunkArgs); + const startPosition = await getGeneratedLocation(start, thunkArgs); + + // If the start and end positions collapse into eachother, it means that + // the range in the original content didn't _start_ at the start position. + // Since this likely means that the range doesn't logically apply to this + // binding location, we skip it. + return positionCmp(startPosition, endPosition) !== 0; +} + +export async function getApplicableBindingsForOriginalPosition( + generatedAstBindings, + source, + { start, end }, + bindingType, + locationType, + thunkArgs +) { + const { sourceMapLoader } = thunkArgs; + const ranges = await sourceMapLoader.getGeneratedRanges(start); + + const resultRanges = ranges.map(mapRange => ({ + start: { + line: mapRange.line, + column: mapRange.columnStart, + }, + end: { + line: mapRange.line, + // SourceMapConsumer's 'lastColumn' is inclusive, so we add 1 to make + // it exclusive like all other locations. + column: mapRange.columnEnd + 1, + }, + })); + + // When searching for imports, we expand the range to up to the next available + // mapping to allow for import declarations that are composed of multiple + // variable statements, where the later ones are entirely unmapped. + // Babel 6 produces imports in this style, e.g. + // + // var _mod = require("mod"); // mapped from import statement + // var _mod2 = interop(_mod); // entirely unmapped + if (bindingType === "import" && locationType !== "ref") { + const endPosition = await getGeneratedLocation(end, thunkArgs); + const startPosition = await getGeneratedLocation(start, thunkArgs); + + for (const range of resultRanges) { + if ( + mappingContains(range, { start: startPosition, end: startPosition }) && + positionCmp(range.end, endPosition) < 0 + ) { + range.end = { + line: endPosition.line, + column: endPosition.column, + }; + break; + } + } + } + + return filterApplicableBindings(generatedAstBindings, resultRanges); +} + +function filterApplicableBindings(bindings, ranges) { + const result = []; + for (const range of ranges) { + // Any binding overlapping a part of the mapping range. + const filteredBindings = filterSortedArray(bindings, binding => { + if (positionCmp(binding.loc.end, range.start) <= 0) { + return -1; + } + if (positionCmp(binding.loc.start, range.end) >= 0) { + return 1; + } + + return 0; + }); + + let firstInRange = true; + let firstOnLine = true; + let line = -1; + + for (const binding of filteredBindings) { + if (binding.loc.start.line === line) { + firstOnLine = false; + } else { + line = binding.loc.start.line; + firstOnLine = true; + } + + result.push({ + binding, + range, + firstOnLine, + firstInRange, + }); + + firstInRange = false; + } + } + + return result; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/index.js b/devtools/client/debugger/src/utils/pause/mapScopes/index.js new file mode 100644 index 0000000000..8736c42218 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js @@ -0,0 +1,583 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "../../location"; +import { locColumn } from "./locColumn"; +import { loadRangeMetadata, findMatchingRange } from "./rangeMetadata"; + +// eslint-disable-next-line max-len +import { + findGeneratedReference, + findGeneratedImportReference, + findGeneratedImportDeclaration, +} from "./findGeneratedBindingFromPosition"; +import { + buildGeneratedBindingList, + buildFakeBindingList, +} from "./buildGeneratedBindingList"; +import { + originalRangeStartsInside, + getApplicableBindingsForOriginalPosition, +} from "./getApplicableBindingsForOriginalPosition"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +import { log } from "../../log"; + +// Create real location objects for all location start and end. +// +// Parser worker returns scopes with location having a sourceId +// instead of a source object as it doesn't know about main thread source objects. +function updateLocationsInScopes(state, scopes) { + for (const item of scopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + const locs = [ref]; + if (ref.type !== "ref") { + locs.push(ref.declaration); + } + for (const loc of locs) { + loc.start = sourceMapToDebuggerLocation(state, loc.start); + loc.end = sourceMapToDebuggerLocation(state, loc.end); + } + } + } + } +} + +export async function buildMappedScopes( + source, + content, + frame, + scopes, + thunkArgs +) { + const { getState, parserWorker } = thunkArgs; + if (!parserWorker.isLocationSupported(frame.location)) { + return null; + } + const originalAstScopes = await parserWorker.getScopes(frame.location); + updateLocationsInScopes(getState(), originalAstScopes); + const generatedAstScopes = await parserWorker.getScopes( + frame.generatedLocation + ); + updateLocationsInScopes(getState(), generatedAstScopes); + + if (!originalAstScopes || !generatedAstScopes) { + return null; + } + + const originalRanges = await loadRangeMetadata( + frame.location, + originalAstScopes, + thunkArgs + ); + + if (hasLineMappings(originalRanges)) { + return null; + } + + let generatedAstBindings; + if (scopes) { + generatedAstBindings = buildGeneratedBindingList( + scopes, + generatedAstScopes, + frame.this + ); + } else { + generatedAstBindings = buildFakeBindingList(generatedAstScopes); + } + + const { mappedOriginalScopes, expressionLookup } = + await mapOriginalBindingsToGenerated( + source, + content, + originalRanges, + originalAstScopes, + generatedAstBindings, + thunkArgs + ); + + const globalLexicalScope = scopes + ? getGlobalFromScope(scopes) + : generateGlobalFromAst(generatedAstScopes); + const mappedGeneratedScopes = generateClientScope( + globalLexicalScope, + mappedOriginalScopes + ); + + return isReliableScope(mappedGeneratedScopes) + ? { mappings: expressionLookup, scope: mappedGeneratedScopes } + : null; +} + +async function mapOriginalBindingsToGenerated( + source, + content, + originalRanges, + originalAstScopes, + generatedAstBindings, + thunkArgs +) { + const expressionLookup = {}; + const mappedOriginalScopes = []; + + const cachedSourceMaps = batchScopeMappings( + originalAstScopes, + source, + thunkArgs + ); + // Override sourceMapLoader attribute with the special cached SourceMapLoader instance + // in order to make it used by all functions used in this method. + thunkArgs = { ...thunkArgs, sourceMapLoader: cachedSourceMaps }; + + for (const item of originalAstScopes) { + const generatedBindings = {}; + + for (const name of Object.keys(item.bindings)) { + const binding = item.bindings[name]; + + const result = await findGeneratedBinding( + source, + content, + name, + binding, + originalRanges, + generatedAstBindings, + thunkArgs + ); + + if (result) { + generatedBindings[name] = result.grip; + + if ( + binding.refs.length !== 0 && + // These are assigned depth-first, so we don't want shadowed + // bindings in parent scopes overwriting the expression. + !Object.prototype.hasOwnProperty.call(expressionLookup, name) + ) { + expressionLookup[name] = result.expression; + } + } + } + + mappedOriginalScopes.push({ + ...item, + generatedBindings, + }); + } + + return { + mappedOriginalScopes, + expressionLookup, + }; +} + +/** + * Consider a scope and its parents reliable if the vast majority of its + * bindings were successfully mapped to generated scope bindings. + */ +function isReliableScope(scope) { + let totalBindings = 0; + let unknownBindings = 0; + + for (let s = scope; s; s = s.parent) { + const vars = s.bindings?.variables || {}; + for (const key of Object.keys(vars)) { + const binding = vars[key]; + + totalBindings += 1; + if ( + binding.value && + typeof binding.value === "object" && + (binding.value.type === "unscoped" || binding.value.type === "unmapped") + ) { + unknownBindings += 1; + } + } + } + + // As determined by fair dice roll. + return totalBindings === 0 || unknownBindings / totalBindings < 0.25; +} + +function hasLineMappings(ranges) { + return ranges.every( + range => range.columnStart === 0 && range.columnEnd === Infinity + ); +} + +/** + * Build a special SourceMapLoader instance, based on the one passed in thunkArgs, + * which will both: + * - preload generated ranges/locations for original locations mentioned + * in originalAstScopes + * - cache the requests to fetch these genereated ranges/locations + */ +function batchScopeMappings(originalAstScopes, source, thunkArgs) { + const { sourceMapLoader } = thunkArgs; + const precalculatedRanges = new Map(); + const precalculatedLocations = new Map(); + + // Explicitly dispatch all of the sourcemap requests synchronously up front so + // that they will be batched into a single request for the worker to process. + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + const locs = [ref]; + if (ref.type !== "ref") { + locs.push(ref.declaration); + } + + for (const loc of locs) { + precalculatedRanges.set( + buildLocationKey(loc.start), + sourceMapLoader.getGeneratedRanges( + debuggerToSourceMapLocation(loc.start) + ) + ); + precalculatedLocations.set( + buildLocationKey(loc.start), + sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(loc.start) + ) + ); + precalculatedLocations.set( + buildLocationKey(loc.end), + sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(loc.end) + ) + ); + } + } + } + } + + return { + async getGeneratedRanges(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedRanges.has(key)) { + log("Bad precalculated mapping"); + return sourceMapLoader.getGeneratedRanges( + debuggerToSourceMapLocation(pos) + ); + } + return precalculatedRanges.get(key); + }, + + async getGeneratedLocation(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedLocations.has(key)) { + log("Bad precalculated mapping"); + return sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(pos) + ); + } + return precalculatedLocations.get(key); + }, + }; +} +function buildLocationKey(loc) { + return `${loc.line}:${locColumn(loc)}`; +} + +function generateClientScope(globalLexicalScope, originalScopes) { + // Build a structure similar to the client's linked scope object using + // the original AST scopes, but pulling in the generated bindings + // linked to each scope. + const result = originalScopes + .slice(0, -2) + .reverse() + .reduce((acc, orig, i) => { + const { + // The 'this' binding data we have is handled independently, so + // the binding data is not included here. + // eslint-disable-next-line no-unused-vars + this: _this, + ...variables + } = orig.generatedBindings; + + return { + parent: acc, + actor: `originalActor${i}`, + type: orig.type, + scopeKind: orig.scopeKind, + bindings: { + arguments: [], + variables, + }, + ...(orig.type === "function" + ? { + function: { + displayName: orig.displayName, + }, + } + : null), + ...(orig.type === "block" + ? { + block: { + displayName: orig.displayName, + }, + } + : null), + }; + }, globalLexicalScope); + + // The rendering logic in getScope 'this' bindings only runs on the current + // selected frame scope, so we pluck out the 'this' binding that was mapped, + // and put it in a special location + const thisScope = originalScopes.find(scope => scope.bindings.this); + if (result.bindings && thisScope) { + result.bindings.this = thisScope.generatedBindings.this || null; + } + + return result; +} + +function getGlobalFromScope(scopes) { + // Pull the root object scope and root lexical scope to reuse them in + // our mapped scopes. This assumes that file being processed is + // a CommonJS or ES6 module, which might not be ideal. Potentially + // should add some logic to try to detect those cases? + let globalLexicalScope = null; + for (let s = scopes; s.parent; s = s.parent) { + globalLexicalScope = s; + } + if (!globalLexicalScope) { + throw new Error("Assertion failure - there should always be a scope"); + } + return globalLexicalScope; +} + +function generateGlobalFromAst(generatedScopes) { + const globalLexicalAst = generatedScopes[generatedScopes.length - 2]; + if (!globalLexicalAst) { + throw new Error("Assertion failure - there should always be a scope"); + } + return { + actor: "generatedActor1", + type: "block", + scopeKind: "", + bindings: { + arguments: [], + variables: Object.fromEntries( + Object.keys(globalLexicalAst).map(key => [key, getOptimizedOutGrip()]) + ), + }, + parent: { + actor: "generatedActor0", + object: getOptimizedOutGrip(), + scopeKind: "", + type: "object", + }, + }; +} + +function hasValidIdent(range, pos) { + return ( + range.type === "match" || + // For declarations, we allow the range on the identifier to be a + // more general "contains" to increase the chances of a match. + (pos.type !== "ref" && range.type === "contains") + ); +} + +// eslint-disable-next-line complexity +async function findGeneratedBinding( + source, + content, + name, + originalBinding, + originalRanges, + generatedAstBindings, + thunkArgs +) { + // If there are no references to the implicits, then we have no way to + // even attempt to map it back to the original since there is no location + // data to use. Bail out instead of just showing it as unmapped. + if ( + originalBinding.type === "implicit" && + !originalBinding.refs.some(item => item.type === "ref") + ) { + return null; + } + + const loadApplicableBindings = async (pos, locationType) => { + let applicableBindings = await getApplicableBindingsForOriginalPosition( + generatedAstBindings, + source, + pos, + originalBinding.type, + locationType, + thunkArgs + ); + if (applicableBindings.length) { + hadApplicableBindings = true; + } + if (locationType === "ref") { + // Some tooling creates ranges that map a line as a whole, which is useful + // for step-debugging, but can easily lead to finding the wrong binding. + // To avoid these false-positives, we entirely ignore bindings matched + // by ranges that cover full lines. + applicableBindings = applicableBindings.filter( + ({ range }) => + !(range.start.column === 0 && range.end.column === Infinity) + ); + } + if ( + locationType !== "ref" && + !(await originalRangeStartsInside(pos, thunkArgs)) + ) { + applicableBindings = []; + } + return applicableBindings; + }; + + const { refs } = originalBinding; + + let hadApplicableBindings = false; + let genContent = null; + for (const pos of refs) { + const applicableBindings = await loadApplicableBindings(pos, pos.type); + + const range = findMatchingRange(originalRanges, pos); + if (range && hasValidIdent(range, pos)) { + if (originalBinding.type === "import") { + genContent = await findGeneratedImportReference(applicableBindings); + } else { + genContent = await findGeneratedReference(applicableBindings); + } + } + + if ( + (pos.type === "class-decl" || pos.type === "class-inner") && + content.contentType && + content.contentType.match(/\/typescript/) + ) { + const declRange = findMatchingRange(originalRanges, pos.declaration); + if (declRange && declRange.type !== "multiple") { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // Resolve to first binding in the range + const declContent = await findGeneratedReference( + applicableDeclBindings + ); + + if (declContent) { + // Prefer the declaration mapping in this case because TS sometimes + // maps class declaration names to "export.Foo = Foo;" or to + // the decorator logic itself + genContent = declContent; + } + } + } + + if ( + !genContent && + pos.type === "import-decl" && + typeof pos.importName === "string" + ) { + const { importName } = pos; + const declRange = findMatchingRange(originalRanges, pos.declaration); + + // The import declaration should have an original position mapping, + // but otherwise we don't really have preferences on the range type + // because it can have multiple bindings, but we do want to make sure + // that all of the bindings that match the range are part of the same + // import declaration. + if (declRange?.singleDeclaration) { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // match the import declaration location + genContent = await findGeneratedImportDeclaration( + applicableDeclBindings, + importName + ); + } + } + + if (genContent) { + break; + } + } + + if (genContent && genContent.desc) { + return { + grip: genContent.desc, + expression: genContent.expression, + }; + } else if (genContent) { + // If there is no descriptor for 'this', then this is not the top-level + // 'this' that the server gave us a binding for, and we can just ignore it. + if (name === "this") { + return null; + } + + // If the location is found but the descriptor is not, then it + // means that the server scope information didn't match the scope + // information from the DevTools parsed scopes. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unscoped", + unscoped: true, + + // HACK: Until support for "unscoped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; + } else if (!hadApplicableBindings && name !== "this") { + // If there were no applicable bindings to consider while searching for + // matching bindings, then the source map for this file didn't make any + // attempt to map the binding, and that most likely means that the + // code was entirely emitted from the output code. + return { + grip: getOptimizedOutGrip(), + expression: ` + (() => { + throw new Error('"' + ${JSON.stringify( + name + )} + '" has been optimized out.'); + })() + `, + }; + } + + // If no location mapping is found, then the map is bad, or + // the map is okay but it original location is inside + // of some scope, but the generated location is outside, leading + // us to search for bindings that don't technically exist. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unmapped", + unmapped: true, + + // HACK: Until support for "unmapped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js new file mode 100644 index 0000000000..075e902299 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function locColumn(loc) { + if (typeof loc.column !== "number") { + // This shouldn't really happen with locations from the AST, but + // the datatype we are using allows null/undefined column. + return 0; + } + + return loc.column; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js new file mode 100644 index 0000000000..ca82fe42ec --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { positionCmp } from "./positionCmp"; + +export function mappingContains(mapped, item) { + return ( + positionCmp(item.start, mapped.start) >= 0 && + positionCmp(item.end, mapped.end) <= 0 + ); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/moz.build b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build new file mode 100644 index 0000000000..05f2b7e3d8 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "buildGeneratedBindingList.js", + "filtering.js", + "findGeneratedBindingFromPosition.js", + "getApplicableBindingsForOriginalPosition.js", + "index.js", + "locColumn.js", + "mappingContains.js", + "optimizedOut.js", + "positionCmp.js", + "rangeMetadata.js", +) diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js new file mode 100644 index 0000000000..755b308a2d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getOptimizedOutGrip() { + return { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "null", + optimizedOut: true, + }, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js new file mode 100644 index 0000000000..5d53fb933e --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; + +/** + * * === 0 - Positions are equal. + * * < 0 - first position before second position + * * > 0 - first position after second position + */ +export function positionCmp(p1, p2) { + if (p1.line === p2.line) { + const l1 = locColumn(p1); + const l2 = locColumn(p2); + + if (l1 === l2) { + return 0; + } + return l1 < l2 ? -1 : 1; + } + + return p1.line < p2.line ? -1 : 1; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js new file mode 100644 index 0000000000..7a22313aca --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; + +// * match - Range contains a single identifier with matching start location +// * contains - Range contains a single identifier with non-matching start +// * multiple - Range contains multiple identifiers +// * empty - Range contains no identifiers + +export async function loadRangeMetadata( + location, + originalAstScopes, + { sourceMapLoader } +) { + const originalRanges = await sourceMapLoader.getOriginalRanges( + location.sourceId + ); + + const sortedOriginalAstBindings = []; + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + sortedOriginalAstBindings.push(ref); + } + } + } + sortedOriginalAstBindings.sort((a, b) => positionCmp(a.start, b.start)); + + let i = 0; + + return originalRanges.map(range => { + const bindings = []; + + while ( + i < sortedOriginalAstBindings.length && + (sortedOriginalAstBindings[i].start.line < range.line || + (sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) < range.columnStart)) + ) { + i++; + } + + while ( + i < sortedOriginalAstBindings.length && + sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) >= range.columnStart && + locColumn(sortedOriginalAstBindings[i].start) < range.columnEnd + ) { + const lastBinding = bindings[bindings.length - 1]; + // Only add bindings when they're in new positions + if ( + !lastBinding || + positionCmp(lastBinding.start, sortedOriginalAstBindings[i].start) !== 0 + ) { + bindings.push(sortedOriginalAstBindings[i]); + } + i++; + } + + let type = "empty"; + let singleDeclaration = true; + if (bindings.length === 1) { + const binding = bindings[0]; + if ( + binding.start.line === range.line && + binding.start.column === range.columnStart + ) { + type = "match"; + } else { + type = "contains"; + } + } else if (bindings.length > 1) { + type = "multiple"; + const binding = bindings[0]; + const declStart = + binding.type !== "ref" ? binding.declaration.start : null; + + singleDeclaration = bindings.every(b => { + return ( + declStart && + b.type !== "ref" && + positionCmp(declStart, b.declaration.start) === 0 + ); + }); + } + + return { + type, + singleDeclaration, + ...range, + }; + }); +} + +export function findMatchingRange(sortedOriginalRanges, bindingRange) { + return filterSortedArray(sortedOriginalRanges, range => { + if (range.line < bindingRange.start.line) { + return -1; + } + if (range.line > bindingRange.start.line) { + return 1; + } + + if (range.columnEnd <= locColumn(bindingRange.start)) { + return -1; + } + if (range.columnStart > locColumn(bindingRange.start)) { + return 1; + } + + return 0; + }).pop(); +} diff --git a/devtools/client/debugger/src/utils/pause/moz.build b/devtools/client/debugger/src/utils/pause/moz.build new file mode 100644 index 0000000000..e0705d3115 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "frames", + "mapScopes", + "scopes", +] + +CompiledModules( + "index.js", + "why.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/getScope.js b/devtools/client/debugger/src/utils/pause/scopes/getScope.js new file mode 100644 index 0000000000..549e387d51 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getScope.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { objectInspector } from "devtools/client/shared/components/reps/index"; +import { getBindingVariables } from "./getVariables"; +import { getFramePopVariables, getThisVariable } from "./utils"; +import { simplifyDisplayName } from "../../pause/frames"; + +const { + utils: { + node: { NODE_TYPES }, + }, +} = objectInspector; + +function getScopeTitle(type, scope) { + if (type === "block" && scope.block && scope.block.displayName) { + return scope.block.displayName; + } + + if (type === "function" && scope.function) { + return scope.function.displayName + ? simplifyDisplayName(scope.function.displayName) + : L10N.getStr("anonymousFunction"); + } + return L10N.getStr("scopes.block"); +} + +export function getScope(scope, selectedFrame, frameScopes, why, scopeIndex) { + const { type, actor } = scope; + + const isLocalScope = scope.actor === frameScopes.actor; + + const key = `${actor}-${scopeIndex}`; + if (type === "function" || type === "block") { + const { bindings } = scope; + + let vars = getBindingVariables(bindings, key); + + // show exception, return, and this variables in innermost scope + if (isLocalScope) { + vars = vars.concat(getFramePopVariables(why, key)); + + let thisDesc_ = selectedFrame.this; + + if (bindings && "this" in bindings) { + // The presence of "this" means we're rendering a "this" binding + // generated from mapScopes and this can override the binding + // provided by the current frame. + thisDesc_ = bindings.this ? bindings.this.value : null; + } + + const this_ = getThisVariable(thisDesc_, key); + + if (this_) { + vars.push(this_); + } + } + + if (vars?.length) { + const title = getScopeTitle(type, scope) || ""; + vars.sort((a, b) => a.name.localeCompare(b.name)); + return { + name: title, + path: key, + contents: vars, + type: NODE_TYPES.BLOCK, + }; + } + } else if (type === "object" && scope.object) { + let value = scope.object; + // If this is the global window scope, mark it as such so that it will + // preview Window: Global instead of Window: Window + if (value.class === "Window") { + value = { ...scope.object, displayClass: "Global" }; + } + return { + name: scope.object.class, + path: key, + contents: { value }, + }; + } + + return null; +} + +export function mergeScopes(scope, parentScope, item, parentItem) { + if (scope.scopeKind == "function lexical" && parentScope.type == "function") { + const contents = item.contents.concat(parentItem.contents); + contents.sort((a, b) => a.name.localeCompare(b.name)); + + return { + name: parentItem.name, + path: parentItem.path, + contents, + type: NODE_TYPES.BLOCK, + }; + } + + return null; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/getVariables.js b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js new file mode 100644 index 0000000000..3346a99cdb --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// VarAndBindingsPair actually is [name: string, contents: BindingContents] + +// Scope's bindings field which holds variables and arguments + +// Create the tree nodes representing all the variables and arguments +// for the bindings from a scope. +export function getBindingVariables(bindings, parentName) { + if (!bindings) { + return []; + } + + const nodes = []; + const addNode = (name, contents) => + nodes.push({ name, contents, path: `${parentName}/${name}` }); + + for (const arg of bindings.arguments) { + // `arg` is an object which only has a single property whose name is the name of the + // argument. So here we can directly pick the first (and only) entry of `arg` + const [name, contents] = Object.entries(arg)[0]; + addNode(name, contents); + } + + for (const name in bindings.variables) { + addNode(name, bindings.variables[name]); + } + + return nodes; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/index.js b/devtools/client/debugger/src/utils/pause/scopes/index.js new file mode 100644 index 0000000000..2f24a41640 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/index.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getScope, mergeScopes } from "./getScope"; + +export function getScopes(why, selectedFrame, frameScopes) { + if (!why || !selectedFrame) { + return null; + } + + if (!frameScopes) { + return null; + } + + const scopes = []; + + let scope = frameScopes; + let scopeIndex = 1; + let prev = null, + prevItem = null; + + while (scope) { + let scopeItem = getScope( + scope, + selectedFrame, + frameScopes, + why, + scopeIndex + ); + + if (scopeItem) { + const mergedItem = + prev && prevItem ? mergeScopes(prev, scope, prevItem, scopeItem) : null; + if (mergedItem) { + scopeItem = mergedItem; + scopes.pop(); + } + scopes.push(scopeItem); + } + prev = scope; + prevItem = scopeItem; + scopeIndex++; + scope = scope.parent; + } + + return scopes; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/moz.build b/devtools/client/debugger/src/utils/pause/scopes/moz.build new file mode 100644 index 0000000000..059d187e3d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/moz.build @@ -0,0 +1,13 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "getScope.js", + "getVariables.js", + "index.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js new file mode 100644 index 0000000000..cf05c71345 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js @@ -0,0 +1,114 @@ +/* eslint max-nested-callbacks: ["error", 4] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFramePopVariables } from "../utils"; + +const errorGrip = { + type: "object", + actor: "server2.conn66.child1/obj243", + class: "Error", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "blah", + stack: + "onclick@http://localhost:8000/examples/doc-return-values.html:1:18\n", + fileName: "http://localhost:8000/examples/doc-return-values.html", + lineNumber: 1, + columnNumber: 18, + }, +}; + +function returnWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + return: grip, + }, + }; +} + +function throwWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + throw: grip, + }, + }; +} + +function getContentsValue(v) { + return v.contents.value; +} + +function getContentsClass(v) { + const value = getContentsValue(v); + return value ? value.class || undefined : ""; +} + +describe("pause - scopes", () => { + describe("getFramePopVariables", () => { + describe("falsey values", () => { + // NOTE: null and undefined are treated like objects and given a type + const falsey = { false: false, 0: 0, null: { type: "null" } }; + for (const test in falsey) { + const value = falsey[test]; + it(`shows ${test} returns`, () => { + const why = returnWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + + it(`shows ${test} throws`, () => { + const why = throwWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + } + }); + + describe("Error / Objects", () => { + it("shows Error returns", () => { + const why = returnWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + + it("shows error throws", () => { + const why = throwWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + }); + + describe("undefined", () => { + it("does not show undefined returns", () => { + const why = returnWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars).toHaveLength(0); + }); + + it("shows undefined throws", () => { + const why = throwWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual({ type: "undefined" }); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js new file mode 100644 index 0000000000..07a962dfab --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getScopes } from ".."; +import { + makeMockFrame, + makeMockScope, + makeWhyNormal, + makeWhyThrow, + mockScopeAddVariable, +} from "../../../test-mockup"; + +function convertScope(scope) { + return scope; +} + +describe("scopes", () => { + describe("getScopes", () => { + it("single scope", () => { + const pauseData = makeWhyNormal(); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[0].path).toEqual("actor1-1"); + expect(scopes[0].contents[0]).toEqual({ + name: "<this>", + path: "actor1-1/<this>", + contents: { value: {} }, + }); + }); + + it("second scope", () => { + const pauseData = makeWhyNormal(); + const scope0 = makeMockScope("actor2"); + const scope1 = makeMockScope("actor1", undefined, scope0); + const selectedFrame = makeMockFrame(undefined, undefined, scope1); + mockScopeAddVariable(scope0, "foo"); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[1].path).toEqual("actor2-2"); + expect(scopes[1].contents[0]).toEqual({ + name: "foo", + path: "actor2-2/foo", + contents: { value: null }, + }); + }); + + it("returning scope", () => { + const why = makeWhyNormal("to sender"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<return>", + path: "actor1-1/<return>", + contents: { + value: "to sender", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + + it("throwing scope", () => { + const why = makeWhyThrow("a party"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<exception>", + path: "actor1-1/<exception>", + contents: { + value: "a party", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/utils.js b/devtools/client/debugger/src/utils/pause/scopes/utils.js new file mode 100644 index 0000000000..16ebbf04a9 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/utils.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getFramePopVariables(why, path) { + const vars = []; + + if (why && why.frameFinished) { + const { frameFinished } = why; + + // Always display a `throw` property if present, even if it is falsy. + if (Object.prototype.hasOwnProperty.call(frameFinished, "throw")) { + vars.push({ + name: "<exception>", + path: `${path}/<exception>`, + contents: { value: frameFinished.throw }, + }); + } + + if (Object.prototype.hasOwnProperty.call(frameFinished, "return")) { + const returned = frameFinished.return; + + // Do not display undefined. Do display falsy values like 0 and false. The + // protocol grip for undefined is a JSON object: { type: "undefined" }. + if (typeof returned !== "object" || returned.type !== "undefined") { + vars.push({ + name: "<return>", + path: `${path}/<return>`, + contents: { value: returned }, + }); + } + } + } + + return vars; +} + +export function getThisVariable(this_, path) { + if (!this_) { + return null; + } + + return { + name: "<this>", + path: `${path}/<this>`, + contents: { value: this_ }, + }; +} + +// Get a string path for an scope item which can be used in different pauses for +// a thread. +export function getScopeItemPath(item) { + // Calling toString() on item.path allows symbols to be handled. + return item.path.toString(); +} diff --git a/devtools/client/debugger/src/utils/pause/why.js b/devtools/client/debugger/src/utils/pause/why.js new file mode 100644 index 0000000000..115d94873b --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/why.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { DEBUGGER_PAUSED_REASONS_L10N_MAPPING } from "devtools/shared/constants"; + +export function getPauseReason(why) { + if (!why) { + return null; + } + + const reasonType = why.type; + if (!DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType]) { + console.log("Please file an issue: reasonType=", reasonType); + } + + return DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType]; +} + +export function isException(why) { + return why?.type === "exception"; +} + +export function isInterrupted(why) { + return why?.type === "interrupted"; +} + +export function inDebuggerEval(why) { + if ( + why && + why.type === "exception" && + why.exception && + why.exception.preview && + why.exception.preview.fileName + ) { + return why.exception.preview.fileName === "debugger eval code"; + } + + return false; +} |