diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/debugger/src | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
609 files changed, 95020 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/.eslintignore b/devtools/client/debugger/src/.eslintignore new file mode 100644 index 0000000000..32aadf77f5 --- /dev/null +++ b/devtools/client/debugger/src/.eslintignore @@ -0,0 +1,5 @@ +test/examples/** +test/integration/** +test/unit-sources/** +**/fixtures/** +test/mochitest/** diff --git a/devtools/client/debugger/src/.eslintrc.js b/devtools/client/debugger/src/.eslintrc.js new file mode 100644 index 0000000000..c6ffc17a5c --- /dev/null +++ b/devtools/client/debugger/src/.eslintrc.js @@ -0,0 +1,372 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +module.exports = { + plugins: ["react", "mozilla", "@babel", "import", "file-header"], + globals: { + atob: true, + btoa: true, + Cc: true, + Ci: true, + Components: true, + console: true, + Cr: true, + Cu: true, + devtools: true, + dump: true, + EventEmitter: true, + isWorker: true, + loader: true, + Services: true, + Task: true, + XPCOMUtils: true, + _Iterator: true, + __dirname: true, + process: true, + global: true, + L10N: true, + }, + extends: ["prettier", "plugin:jest/recommended"], + parserOptions: { + ecmaVersion: 2016, + sourceType: "module", + ecmaFeatures: { jsx: true }, + + // When the linter runs from the MC root, it won't pick up this project's + // babel.config.js, so we explicitly set Babel's root location so that + // it knows where to look. + babelOptions: { + root: __dirname, + }, + }, + env: { + es6: true, + browser: true, + commonjs: true, + jest: true, + }, + rules: { + // These are the rules that have been configured so far to match the + // devtools coding style. + + // Rules from the mozilla plugin + "mozilla/mark-test-function-used": 1, + "mozilla/no-aArgs": 1, + // See bug 1224289. + "mozilla/reject-importGlobalProperties": 1, + "mozilla/var-only-at-top-level": 1, + + // Rules from the React plugin + "react/jsx-uses-react": [2], + "react/jsx-uses-vars": [2], + "react/no-danger": 1, + "react/no-did-mount-set-state": 1, + "react/no-did-update-set-state": 1, + "react/no-direct-mutation-state": 1, + "react/no-unknown-property": 1, + "react/prop-types": 1, + "react/sort-comp": [ + 1, + { + order: ["propTypes", "everything-else", "render"], + }, + ], + + // Check for import errors. + "import/no-duplicates": "error", + "import/named": "error", + "import/export": "error", + + // Incompatible with jest-in-case cases. See related GitHub issue + // https://github.com/jest-community/eslint-plugin-jest/issues/534 + "jest/no-standalone-expect": "off", + + // Disallow flow control that escapes from "finally". + "no-unsafe-finally": "error", + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": 2, + // Require camel case names + camelcase: ["error", { properties: "never" }], + // Warn about cyclomatic complexity in functions. + complexity: ["error", { max: 22 }], + // Don't warn for inconsistent naming when capturing this (not so important + // with auto-binding fat arrow functions). + "consistent-this": 0, + // Don't require a default case in switch statements. Avoid being forced to + // add a bogus default when you know all possible cases are handled. + "default-case": 0, + // Encourage the use of dot notation whenever possible. + "dot-notation": 2, + // Allow using == instead of ===, in the interest of landing something since + // the devtools codebase is split on convention here. + eqeqeq: 0, + // Don't require function expressions to have a name. + // This makes the code more verbose and hard to read. Our engine already + // does a fantastic job assigning a name to the function, which includes + // the enclosing function name, and worst case you have a line number that + // you can just look up. + "func-names": 0, + // Allow use of function declarations and expressions. + "func-style": 0, + // Deprecated, will be removed in 1.0. + "global-strict": 0, + // Only useful in a node environment. + "handle-callback-err": 0, + // Don't enforce the maximum depth that blocks can be nested. The complexity + // rule is a better rule to check this. + "max-depth": 0, + // Maximum depth callbacks can be nested. + "max-nested-callbacks": [2, 4], + // Don't limit the number of parameters that can be used in a function. + "max-params": 0, + // Don't limit the maximum number of statement allowed in a function. We + // already have the complexity rule that's a better measurement. + "max-statements": 0, + // Require a capital letter for constructors, only check if all new + // operators are followed by a capital letter. Don't warn when capitalized + // functions are used without the new operator. + "new-cap": [2, { capIsNew: false }], + // Disallow use of the Array constructor. + "no-array-constructor": 2, + // Allow use of bitwise operators. + "no-bitwise": 0, + // Disallow use of arguments.caller or arguments.callee. + "no-caller": 2, + // Disallow the catch clause parameter name being the same as a variable in + // the outer scope, to avoid confusion. + "no-catch-shadow": 2, + // Disallow assignment in conditional expressions. + "no-cond-assign": 2, + // Allow using the console API. + "no-console": 0, + // Allow using constant expressions in conditions like while (true) + "no-constant-condition": 0, + // Allow use of the continue statement. + "no-continue": 0, + // Disallow control characters in regular expressions. + "no-control-regex": 2, + // Disallow use of debugger. + "no-debugger": 2, + // Disallow deletion of variables (deleting properties is fine). + "no-delete-var": 2, + // Allow division operators explicitly at beginning of regular expression. + "no-div-regex": 0, + // Disallow duplicate arguments in functions. + "no-dupe-args": 2, + // Disallow duplicate keys when creating object literals. + "no-dupe-keys": 2, + // Disallow a duplicate case label. + "no-duplicate-case": 2, + // Disallow else after a return in an if. The else around the second return + // here is useless: + // if (something) { return false; } else { return true; } + "no-else-return": 2, + // Disallow empty statements. This will report an error for: + // try { something(); } catch (e) {} + // but will not report it for: + // try { something(); } catch (e) { /* Silencing the error because ...*/ } + // which is a valid use case. + "no-empty": 2, + // Disallow the use of empty character classes in regular expressions. + "no-empty-character-class": 2, + // Disallow use of labels for anything other then loops and switches. + "no-labels": 2, + // Disallow use of eval(). We have other APIs to evaluate code in content. + "no-eval": 2, + // Disallow assigning to the exception in a catch block. + "no-ex-assign": 2, + // Disallow adding to native types + "no-extend-native": 2, + // Disallow unnecessary function binding. + "no-extra-bind": 2, + // Disallow double-negation boolean casts in a boolean context. + "no-extra-boolean-cast": 2, + // Deprecated, will be removed in 1.0. + "no-extra-strict": 0, + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": 2, + // Disallow comments inline after code. + "no-inline-comments": 2, + // Disallow if as the only statement in an else block. + "no-lonely-if": 2, + // Allow mixing regular variable and require declarations (not a node env). + "no-mixed-requires": 0, + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": 2, + "prefer-template": "error", + "prefer-const": [ + "error", + { + destructuring: "all", + ignoreReadBeforeAssign: false, + }, + ], + // Disallow reassignments of native objects. + "no-native-reassign": 2, + // Disallow nested ternary expressions, they make the code hard to read. + "no-nested-ternary": 2, + // Allow use of new operator with the require function. + "no-new-require": 0, + // Disallow use of octal literals. + "no-octal": 2, + // Allow reassignment of function parameters. + "no-param-reassign": 0, + // Allow string concatenation with __dirname and __filename (not a node env). + "no-path-concat": 0, + // Allow use of unary operators, ++ and --. + "no-plusplus": 0, + // Allow using process.env (not a node environment). + "no-process-env": 0, + // Allow using process.exit (not a node environment). + "no-process-exit": 0, + // Disallow usage of __proto__ property. + "no-proto": 2, + // Disallow declaring the same variable more than once (we use let anyway). + "no-redeclare": 2, + // Disallow multiple spaces in a regular expression literal. + "no-regex-spaces": 2, + // Don't restrict usage of specified node modules (not a node environment). + "no-restricted-modules": 0, + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": 2, + // Allow use of javascript: urls. + "no-script-url": 0, + // Disallow comparisons where both sides are exactly the same. + "no-self-compare": 2, + // Disallow use of comma operator. + "no-sequences": 2, + // Warn about declaration of variables already declared in the outer scope. + // This isn't an error because it sometimes is useful to use the same name + // in a small helper function rather than having to come up with another + // random name. + // Still, making this a warning can help people avoid being confused. + "no-shadow": 2, + // Disallow shadowing of names such as arguments. + "no-shadow-restricted-names": 2, + // Disallow sparse arrays, eg. let arr = [,,2]. + // Array destructuring is fine though: + // for (let [, breakpointPromise] of aPromises) + "no-sparse-arrays": 2, + // Allow use of synchronous methods (not a node environment). + "no-sync": 0, + // Allow the use of ternary operators. + "no-ternary": 0, + // Disallow throwing literals (eg. throw "error" instead of + // throw new Error("error")). + "no-throw-literal": 2, + // Disallow use of undeclared variables unless mentioned in a /*global */ + // block. Note that globals from head.js are automatically imported in tests + // by the import-headjs-globals rule form the mozilla eslint plugin. + "no-undef": 2, + // Allow dangling underscores in identifiers (for privates). + "no-underscore-dangle": 0, + // Allow use of undefined variable. + "no-undefined": 0, + // Disallow the use of Boolean literals in conditional expressions. + "no-unneeded-ternary": 2, + // Disallow unreachable statements after a return, throw, continue, or break + // statement. + "no-unreachable": 2, + // Disallow global and local variables that arent used, but allow unused function arguments. + "no-unused-vars": [2, { vars: "all", args: "none" }], + // Allow using variables before they are defined. + "no-use-before-define": 0, + // We use var-only-at-top-level instead of no-var as we allow top level + // vars. + "no-var": 0, + // Allow using TODO/FIXME comments. + "no-warning-comments": 0, + // Disallow use of the with statement. + "no-with": 2, + // Dont require method and property shorthand syntax for object literals. + // We use this in the code a lot, but not consistently, and this seems more + // like something to check at code review time. + "object-shorthand": 0, + // Allow more than one variable declaration per function. + "one-var": 0, + // Require use of the second argument for parseInt(). + radix: 2, + // Dont require to sort variables within the same declaration block. + // Anyway, one-var is disabled. + "sort-vars": 0, + // Require "use strict" to be defined globally in the script. + strict: [2, "global"], + // Disallow comparisons with the value NaN. + "use-isnan": 2, + // Ensure that the results of typeof are compared against a valid string. + "valid-typeof": 2, + // Allow vars to be declared anywhere in the scope. + "vars-on-top": 0, + // Disallow Yoda conditions (where literal value comes first). + yoda: 2, + + // And these are the rules that haven't been discussed so far, and that are + // disabled for now until we introduce them, one at a time. + + // Require for-in loops to have an if statement. + "guard-for-in": 0, + // allow/disallow an empty newline after var statement + "newline-after-var": 0, + // disallow the use of alert, confirm, and prompt + "no-alert": 0, + // disallow comparisons to null without a type-checking operator + "no-eq-null": 0, + // disallow overwriting functions written as function declarations + "no-func-assign": 0, + // disallow use of eval()-like methods + "no-implied-eval": 0, + // disallow function or variable declarations in nested blocks + "no-inner-declarations": 0, + // disallow invalid regular expression strings in the RegExp constructor + "no-invalid-regexp": 0, + // disallow irregular whitespace outside of strings and comments + "no-irregular-whitespace": 0, + // disallow labels that share a name with a variable + "no-label-var": 0, + // disallow unnecessary nested blocks + "no-lone-blocks": 0, + // disallow creation of functions within loops + "no-loop-func": 0, + // disallow negation of the left operand of an in expression + "no-negated-in-lhs": 0, + // disallow use of new operator when not part of the assignment or + // comparison + "no-new": 0, + // disallow use of new operator for Function object + "no-new-func": 0, + // disallow use of the Object constructor + "no-new-object": 0, + // disallows creating new instances of String,Number, and Boolean + "no-new-wrappers": 0, + // disallow the use of object properties of the global object (Math and + // JSON) as functions + "no-obj-calls": 0, + // disallow use of octal escape sequences in string literals, such as + // var foo = "Copyright \251"; + "no-octal-escape": 0, + // disallow use of undefined when initializing variables + "no-undef-init": 0, + // disallow usage of expressions in statement position + "no-unused-expressions": 0, + // disallow use of void operator + "no-void": 0, + // require assignment operator shorthand where possible or prohibit it + // entirely + "operator-assignment": 0, + + "file-header/file-header": [ + "error", + [ + "This Source Code Form is subject to the terms of the Mozilla Public", + "License, v. 2.0. If a copy of the MPL was not distributed with this", + "file, You can obtain one at <http://mozilla.org/MPL/2.0/>.", + ], + "block", + ["-\\*-(.*)-\\*-", "eslint(.*)", "vim(.*)"], + ], + }, +}; diff --git a/devtools/client/debugger/src/actions/README.md b/devtools/client/debugger/src/actions/README.md new file mode 100644 index 0000000000..d919247838 --- /dev/null +++ b/devtools/client/debugger/src/actions/README.md @@ -0,0 +1,26 @@ +## Actions + +### Best Practices + +#### Scheduling Async Actions + +There are several use-cases with async actions that involve scheduling: + +* we do one action and cancel subsequent actions +* we do one action and subsequent calls wait on the initial call +* we start an action and show a loading state + +If you want to wait on subsequent calls you need to store action promises. +[ex][req] + +If you just want to cancel subsequent calls, you can keep track of a pending +state in the store. [ex][state] + +The advantage of adding the pending state to the store is that we can use that +in the UI: + +* disable/hide the pretty print button +* show a progress ui + +[req]: https://github.com/firefox-devtools/debugger/blob/master/src/actions/sources/loadSourceText.js +[state]: https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js diff --git a/devtools/client/debugger/src/actions/ast/index.js b/devtools/client/debugger/src/actions/ast/index.js new file mode 100644 index 0000000000..ec2c1ae84c --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/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 { setInScopeLines } from "./setInScopeLines"; diff --git a/devtools/client/debugger/src/actions/ast/moz.build b/devtools/client/debugger/src/actions/ast/moz.build new file mode 100644 index 0000000000..5b0152d2ad --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/moz.build @@ -0,0 +1,11 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "index.js", + "setInScopeLines.js", +) diff --git a/devtools/client/debugger/src/actions/ast/setInScopeLines.js b/devtools/client/debugger/src/actions/ast/setInScopeLines.js new file mode 100644 index 0000000000..a17510a507 --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + hasInScopeLines, + getSourceTextContent, + getVisibleSelectedFrame, +} from "../../selectors"; + +import { getSourceLineCount } from "../../utils/source"; + +import { isFulfilled } from "../../utils/async-value"; + +function getOutOfScopeLines(outOfScopeLocations) { + if (!outOfScopeLocations) { + return null; + } + + const uniqueLines = new Set(); + for (const location of outOfScopeLocations) { + for (let i = location.start.line; i < location.end.line; i++) { + uniqueLines.add(i); + } + } + + return uniqueLines; +} + +async function getInScopeLines( + cx, + location, + { dispatch, getState, parserWorker } +) { + const sourceTextContent = getSourceTextContent(getState(), location); + + let locations = null; + if (location.line && parserWorker.isLocationSupported(location)) { + locations = await parserWorker.findOutOfScopeLocations(location); + } + + const linesOutOfScope = getOutOfScopeLines(locations); + const sourceNumLines = + !sourceTextContent || !isFulfilled(sourceTextContent) + ? 0 + : getSourceLineCount(sourceTextContent.value); + + const noLinesOutOfScope = + linesOutOfScope == null || linesOutOfScope.size == 0; + + // This operation can be very costly for large files so we sacrifice a bit of readability + // for performance sake. + // We initialize an array with a fixed size and we'll directly assign value for lines + // that are not out of scope. This is much faster than having an empty array and pushing + // into it. + const sourceLines = new Array(sourceNumLines); + for (let i = 0; i < sourceNumLines; i++) { + const line = i + 1; + if (noLinesOutOfScope || !linesOutOfScope.has(line)) { + sourceLines[i] = line; + } + } + + // Finally we need to remove any undefined values, i.e. the ones that were matching + // out of scope lines. + return sourceLines.filter(i => i != undefined); +} + +export function setInScopeLines(cx) { + return async thunkArgs => { + const { getState, dispatch } = thunkArgs; + const visibleFrame = getVisibleSelectedFrame(getState()); + + if (!visibleFrame) { + return; + } + + const { location } = visibleFrame; + const sourceTextContent = getSourceTextContent(getState(), location); + + if (hasInScopeLines(getState(), location) || !sourceTextContent) { + return; + } + + const lines = await getInScopeLines(cx, location, thunkArgs); + + dispatch({ + type: "IN_SCOPE_LINES", + cx, + location, + lines, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap new file mode 100644 index 0000000000..1b9befc31b --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getInScopeLine with selected line 1`] = ` +Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, +] +`; diff --git a/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js new file mode 100644 index 0000000000..571dd84d6d --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js @@ -0,0 +1,79 @@ +/* eslint max-nested-callbacks: ["error", 6] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 readFixture from "../../tests/helpers/readFixture"; + +import { makeMockFrame, makeMockSource } from "../../../utils/test-mockup"; +import { + createStore, + selectors, + actions, + makeSource, + waitForState, +} from "../../../utils/test-head"; +import { createLocation } from "../../../utils/location"; + +const { getInScopeLines } = selectors; + +const sourceTexts = { + "scopes.js": readFixture("scopes.js"), +}; + +const mockCommandClient = { + sourceContents: async ({ source }) => ({ + source: sourceTexts[source], + contentType: "text/javascript", + }), + evaluateExpressions: async () => {}, + getFrameScopes: async () => {}, + getFrames: async () => [], + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +describe("getInScopeLine", () => { + it("with selected line", async () => { + const client = { ...mockCommandClient }; + const store = createStore(client); + const { dispatch, getState } = store; + const source = makeMockSource("scopes.js", "scopes.js"); + const frame = makeMockFrame("scopes-4", source); + client.getFrames = async () => [frame]; + + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("scopes.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + baseSource.id + ); + + await dispatch( + actions.selectLocation( + selectors.getContext(getState()), + createLocation({ + source: baseSource, + sourceActor, + line: 5, + }) + ) + ); + + await dispatch( + actions.paused({ + thread: "FakeThread", + why: { type: "debuggerStatement" }, + frame, + }) + ); + await dispatch(actions.setInScopeLines(selectors.getContext(getState()))); + + await waitForState(store, state => getInScopeLines(state, frame.location)); + + const lines = getInScopeLines(getState(), frame.location); + + expect(lines).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js new file mode 100644 index 0000000000..263b476364 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js @@ -0,0 +1,273 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + isOriginalId, + isGeneratedId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; + +import { + getSource, + getSourceFromId, + getBreakpointPositionsForSource, + getSourceActorsForSource, +} from "../../selectors"; + +import { makeBreakpointId } from "../../utils/breakpoint"; +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, + createLocation, +} from "../../utils/location"; + +async function mapLocations(generatedLocations, { getState, sourceMapLoader }) { + if (!generatedLocations.length) { + return []; + } + + const originalLocations = await sourceMapLoader.getOriginalLocations( + generatedLocations.map(debuggerToSourceMapLocation) + ); + return originalLocations.map((location, index) => ({ + // If location is null, this particular location doesn't map to any original source. + location: location + ? sourceMapToDebuggerLocation(getState(), location) + : generatedLocations[index], + generatedLocation: generatedLocations[index], + })); +} + +// Filter out positions, that are not in the original source Id +function filterBySource(positions, sourceId) { + if (!isOriginalId(sourceId)) { + return positions; + } + return positions.filter(position => position.location.sourceId == sourceId); +} + +/** + * Merge positions that refer to duplicated positions. + * Some sourcemaped positions might refer to the exact same source/line/column triple. + * + * @param {Array<{location, generatedLocation}>} positions: List of possible breakable positions + * @returns {Array<{location, generatedLocation}>} A new, filtered array. + */ +function filterByUniqLocation(positions) { + const handledBreakpointIds = new Set(); + return positions.filter(({ location }) => { + const breakpointId = makeBreakpointId(location); + if (handledBreakpointIds.has(breakpointId)) { + return false; + } + + handledBreakpointIds.add(breakpointId); + return true; + }); +} + +function convertToList(results, source) { + const positions = []; + + for (const line in results) { + for (const column of results[line]) { + positions.push( + createLocation({ + line: Number(line), + column, + source, + sourceUrl: source.url, + }) + ); + } + } + + return positions; +} + +function groupByLine(results, sourceId, line) { + const isOriginal = isOriginalId(sourceId); + const positions = {}; + + // Ensure that we have an entry for the line fetched + if (typeof line === "number") { + positions[line] = []; + } + + for (const result of results) { + const location = isOriginal ? result.location : result.generatedLocation; + + if (!positions[location.line]) { + positions[location.line] = []; + } + + positions[location.line].push(result); + } + + return positions; +} + +async function _setBreakpointPositions(cx, location, thunkArgs) { + const { client, dispatch, getState, sourceMapLoader } = thunkArgs; + const results = {}; + let generatedSource = location.source; + if (isOriginalId(location.sourceId)) { + const ranges = await sourceMapLoader.getGeneratedRangesForOriginal( + location.sourceId, + true + ); + const generatedSourceId = originalToGeneratedId(location.sourceId); + generatedSource = getSourceFromId(getState(), generatedSourceId); + + // Note: While looping here may not look ideal, in the vast majority of + // cases, the number of ranges here should be very small, and is quite + // likely to only be a single range. + for (const range of ranges) { + // Wrap infinite end positions to the next line to keep things simple + // and because we know we don't care about the end-line whitespace + // in this case. + if (range.end.column === Infinity) { + range.end = { + line: range.end.line + 1, + column: 0, + }; + } + + const actorBps = await Promise.all( + getSourceActorsForSource(getState(), generatedSourceId).map(actor => + client.getSourceActorBreakpointPositions(actor, range) + ) + ); + + for (const actorPositions of actorBps) { + for (const rangeLine of Object.keys(actorPositions)) { + let columns = actorPositions[parseInt(rangeLine, 10)]; + const existing = results[rangeLine]; + if (existing) { + columns = [...new Set([...existing, ...columns])]; + } + + results[rangeLine] = columns; + } + } + } + } else { + const { line } = location; + if (typeof line !== "number") { + throw new Error("Line is required for generated sources"); + } + + const actorColumns = await Promise.all( + getSourceActorsForSource(getState(), location.sourceId).map( + async actor => { + const positions = await client.getSourceActorBreakpointPositions( + actor, + { + start: { line: line, column: 0 }, + end: { line: line + 1, column: 0 }, + } + ); + return positions[line] || []; + } + ) + ); + + for (const columns of actorColumns) { + results[line] = (results[line] || []).concat(columns); + } + } + + let positions = convertToList(results, generatedSource); + positions = await mapLocations(positions, thunkArgs); + + positions = filterBySource(positions, location.sourceId); + positions = filterByUniqLocation(positions); + positions = groupByLine(positions, location.sourceId, location.line); + + const source = getSource(getState(), location.sourceId); + // NOTE: it's possible that the source was removed during a navigation + if (!source) { + return; + } + + dispatch({ + type: "ADD_BREAKPOINT_POSITIONS", + cx, + source, + positions, + }); +} + +function generatedSourceActorKey(state, sourceId) { + const generatedSource = getSource( + state, + isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId + ); + const actors = generatedSource + ? getSourceActorsForSource(state, generatedSource.id).map( + ({ actor }) => actor + ) + : []; + return [sourceId, ...actors].join(":"); +} + +/** + * This method will force retrieving the breakable positions for a given source, on a given line. + * If this data has already been computed, it will returned the cached data. + * + * For original sources, this will query the SourceMap worker. + * For generated sources, this will query the DevTools server and the related source actors. + * + * @param Object options + * Dictionary object with many arguments: + * @param String options.sourceId + * The source we want to fetch breakable positions + * @param Number options.line + * The line we want to know which columns are breakable. + * (note that this seems to be optional for original sources) + * @return Array<Object> + * The list of all breakable positions, each object of this array will be like this: + * { + * line: Number + * column: Number + * sourceId: String + * sourceUrl: String + * } + */ +export const setBreakpointPositions = memoizeableAction( + "setBreakpointPositions", + { + getValue: ({ location }, { getState }) => { + const positions = getBreakpointPositionsForSource( + getState(), + location.sourceId + ); + if (!positions) { + return null; + } + + if ( + isGeneratedId(location.sourceId) && + location.line && + !positions[location.line] + ) { + // We always return the full position dataset, but if a given line is + // not available, we treat the whole set as loading. + return null; + } + + return fulfilled(positions); + }, + createKey({ location }, { getState }) { + const key = generatedSourceActorKey(getState(), location.sourceId); + return isGeneratedId(location.sourceId) && location.line + ? `${key}-${location.line}` + : key; + }, + action: async ({ cx, location }, thunkArgs) => + _setBreakpointPositions(cx, location, thunkArgs), + } +); diff --git a/devtools/client/debugger/src/actions/breakpoints/index.js b/devtools/client/debugger/src/actions/breakpoints/index.js new file mode 100644 index 0000000000..d188af05dc --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/index.js @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for breakpoints + * @module actions/breakpoints + */ + +import { PROMISE } from "../utils/middleware/promise"; +import { asyncStore } from "../../utils/prefs"; +import { createLocation } from "../../utils/location"; +import { + getBreakpointsList, + getXHRBreakpoints, + getSelectedSource, + getBreakpointAtLocation, + getBreakpointsForSource, + getBreakpointsAtLine, +} from "../../selectors"; +import { createXHRBreakpoint } from "../../utils/breakpoint"; +import { + addBreakpoint, + removeBreakpoint, + enableBreakpoint, + disableBreakpoint, +} from "./modify"; +import { getOriginalLocation } from "../../utils/source-maps"; + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +// this will need to be changed so that addCLientBreakpoint is removed + +export * from "./breakpointPositions"; +export * from "./modify"; +export * from "./syncBreakpoint"; + +export function addHiddenBreakpoint(cx, location) { + return ({ dispatch }) => { + return dispatch(addBreakpoint(cx, location, { hidden: true })); + }; +} + +/** + * Disable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpointsInSource(cx, source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + if (!breakpoint.disabled) { + dispatch(disableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Enable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function enableBreakpointsInSource(cx, source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + if (breakpoint.disabled) { + dispatch(enableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Toggle All Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleAllBreakpoints(cx, shouldDisableBreakpoints) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsList(getState()); + + for (const breakpoint of breakpoints) { + if (shouldDisableBreakpoints) { + dispatch(disableBreakpoint(cx, breakpoint)); + } else { + dispatch(enableBreakpoint(cx, breakpoint)); + } + } + }; +} + +/** + * Toggle Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints) { + return async ({ dispatch }) => { + const promises = breakpoints.map(breakpoint => + shouldDisableBreakpoints + ? dispatch(disableBreakpoint(cx, breakpoint)) + : dispatch(enableBreakpoint(cx, breakpoint)) + ); + + await Promise.all(promises); + }; +} + +export function toggleBreakpointsAtLine(cx, shouldDisableBreakpoints, line) { + return async ({ dispatch, getState }) => { + const breakpoints = getBreakpointsAtLine(getState(), line); + return dispatch( + toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints) + ); + }; +} + +/** + * Removes all breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeAllBreakpoints(cx) { + return async ({ dispatch, getState }) => { + const breakpointList = getBreakpointsList(getState()); + + await Promise.all( + breakpointList.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + dispatch({ type: "CLEAR_BREAKPOINTS" }); + }; +} + +/** + * Removes breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoints(cx, breakpoints) { + return async ({ dispatch }) => { + return Promise.all( + breakpoints.map(bp => dispatch(removeBreakpoint(cx, bp))) + ); + }; +} + +/** + * Removes all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpointsInSource(cx, source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + dispatch(removeBreakpoint(cx, breakpoint)); + } + }; +} + +/** + * Update the original location information of breakpoints. + +/* + * Update breakpoints for a source that just got pretty printed. + * This method maps the breakpoints currently set only against the + * non-pretty-printed (generated) source to the related pretty-printed + * (original) source by querying the SourceMap service. + * + * @param {Objeect} cx + * @param {String} sourceId - the generated source id + */ +export function updateBreakpointsForNewPrettyPrintedSource(cx, sourceId) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + if (isOriginalId(sourceId)) { + console.error("Can't update breakpoints on original sources"); + return; + } + const breakpoints = getBreakpointsForSource(getState(), sourceId); + // Remap the breakpoints with the original location information from + // the pretty-printed source. + const newBreakpoints = await Promise.all( + breakpoints.map(async breakpoint => { + const location = await getOriginalLocation( + breakpoint.generatedLocation, + thunkArgs + ); + return { ...breakpoint, location }; + }) + ); + + // Normally old breakpoints will be clobbered if we re-add them, but when + // remapping we have changed the source maps and the old breakpoints will + // have different locations than the new ones. Manually remove the + // old breakpoints before adding the new ones. + for (const bp of breakpoints) { + dispatch(removeBreakpoint(cx, bp)); + } + + for (const bp of newBreakpoints) { + await dispatch(addBreakpoint(cx, bp.location, bp.options, bp.disabled)); + } + }; +} + +export function toggleBreakpointAtLine(cx, line) { + return ({ dispatch, getState }) => { + const state = getState(); + const selectedSource = getSelectedSource(state); + + if (!selectedSource) { + return null; + } + + const bp = getBreakpointAtLocation(state, { line, column: undefined }); + if (bp) { + return dispatch(removeBreakpoint(cx, bp)); + } + return dispatch( + addBreakpoint( + cx, + createLocation({ + source: selectedSource, + sourceUrl: selectedSource.url, + line, + }) + ) + ); + }; +} + +export function addBreakpointAtLine( + cx, + line, + shouldLog = false, + disabled = false +) { + return ({ dispatch, getState }) => { + const state = getState(); + const source = getSelectedSource(state); + + if (!source) { + return null; + } + const breakpointLocation = createLocation({ + source, + sourceUrl: source.url, + column: undefined, + line, + }); + + const options = {}; + if (shouldLog) { + options.logValue = "displayName"; + } + + return dispatch(addBreakpoint(cx, breakpointLocation, options, disabled)); + }; +} + +export function removeBreakpointsAtLine(cx, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(removeBreakpoints(cx, breakpointsAtLine)); + }; +} + +export function disableBreakpointsAtLine(cx, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, true, breakpointsAtLine)); + }; +} + +export function enableBreakpointsAtLine(cx, sourceId, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource( + getState(), + sourceId, + line + ); + return dispatch(toggleBreakpoints(cx, false, breakpointsAtLine)); + }; +} + +export function toggleDisabledBreakpoint(cx, breakpoint) { + return ({ dispatch, getState }) => { + if (!breakpoint.disabled) { + return dispatch(disableBreakpoint(cx, breakpoint)); + } + return dispatch(enableBreakpoint(cx, breakpoint)); + }; +} + +export function enableXHRBreakpoint(index, bp) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const enabledBreakpoint = { + ...breakpoint, + disabled: false, + }; + + return dispatch({ + type: "ENABLE_XHR_BREAKPOINT", + breakpoint: enabledBreakpoint, + index, + [PROMISE]: client.setXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function disableXHRBreakpoint(index, bp) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const disabledBreakpoint = { + ...breakpoint, + disabled: true, + }; + + return dispatch({ + type: "DISABLE_XHR_BREAKPOINT", + breakpoint: disabledBreakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function updateXHRBreakpoint(index, path, method) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + + const updatedBreakpoint = { + ...breakpoint, + path, + method, + text: L10N.getFormatStr("xhrBreakpoints.item.label", path), + }; + + return dispatch({ + type: "UPDATE_XHR_BREAKPOINT", + breakpoint: updatedBreakpoint, + index, + [PROMISE]: Promise.all([ + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + client.setXHRBreakpoint(path, method), + ]), + }); + }; +} +export function togglePauseOnAny() { + return ({ dispatch, getState }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const index = xhrBreakpoints.findIndex(({ path }) => path.length === 0); + if (index < 0) { + return dispatch(setXHRBreakpoint("", "ANY")); + } + + const bp = xhrBreakpoints[index]; + if (bp.disabled) { + return dispatch(enableXHRBreakpoint(index, bp)); + } + + return dispatch(disableXHRBreakpoint(index, bp)); + }; +} + +export function setXHRBreakpoint(path, method) { + return ({ dispatch, getState, client }) => { + const breakpoint = createXHRBreakpoint(path, method); + + return dispatch({ + type: "SET_XHR_BREAKPOINT", + breakpoint, + [PROMISE]: client.setXHRBreakpoint(path, method), + }); + }; +} + +export function removeAllXHRBreakpoints() { + return async ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const promises = xhrBreakpoints.map(breakpoint => + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method) + ); + await dispatch({ + type: "CLEAR_XHR_BREAKPOINTS", + [PROMISE]: Promise.all(promises), + }); + asyncStore.xhrBreakpoints = []; + }; +} + +export function removeXHRBreakpoint(index) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + return dispatch({ + type: "REMOVE_XHR_BREAKPOINT", + breakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/modify.js b/devtools/client/debugger/src/actions/breakpoints/modify.js new file mode 100644 index 0000000000..4576a61e27 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/modify.js @@ -0,0 +1,382 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createBreakpoint } from "../../client/firefox/create"; +import { + makeBreakpointServerLocation, + makeBreakpointId, +} from "../../utils/breakpoint"; +import { + getBreakpoint, + getBreakpointPositionsForLocation, + getFirstBreakpointPosition, + getSettledSourceTextContent, + getBreakpointsList, + getPendingBreakpointList, + isMapScopesEnabled, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; + +import { setBreakpointPositions } from "./breakpointPositions"; +import { setSkipPausing } from "../pause/skipPausing"; + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; +import { comparePosition } from "../../utils/location"; +import { getTextAtPosition, isLineBlackboxed } from "../../utils/source"; +import { getMappedScopesForLocation } from "../pause/mapScopes"; +import { validateNavigateContext } from "../../utils/context"; + +// This file has the primitive operations used to modify individual breakpoints +// and keep them in sync with the breakpoints installed on server threads. These +// are collected here to make it easier to preserve the following invariant: +// +// Breakpoints are included in reducer state if they are disabled or requests +// have been dispatched to set them in all server threads. +// +// To maintain this property, updates to the reducer and installed breakpoints +// must happen with no intervening await. Using await allows other operations to +// modify the breakpoint state in the interim and potentially cause breakpoint +// state to go out of sync. +// +// The reducer is optimistically updated when users set or remove a breakpoint, +// but it might take a little while before the breakpoints have been set or +// removed in each thread. Once all outstanding requests sent to a thread have +// been processed, the reducer and server threads will be in sync. +// +// There is another exception to the above invariant when first connecting to +// the server: breakpoints have been installed on all generated locations in the +// pending breakpoints, but no breakpoints have been added to the reducer. When +// a matching source appears, either the server breakpoint will be removed or a +// breakpoint will be added to the reducer, to restore the above invariant. +// See syncBreakpoint.js for more. + +async function clientSetBreakpoint( + client, + cx, + { getState, dispatch }, + breakpoint +) { + const breakpointServerLocation = makeBreakpointServerLocation( + getState(), + breakpoint.generatedLocation + ); + const shouldMapBreakpointExpressions = + isMapScopesEnabled(getState()) && + breakpoint.location.source.isOriginal && + (breakpoint.options.logValue || breakpoint.options.condition); + + if (shouldMapBreakpointExpressions) { + breakpoint = await dispatch(updateBreakpointSourceMapping(cx, breakpoint)); + } + return client.setBreakpoint(breakpointServerLocation, breakpoint.options); +} + +function clientRemoveBreakpoint(client, state, generatedLocation) { + const breakpointServerLocation = makeBreakpointServerLocation( + state, + generatedLocation + ); + return client.removeBreakpoint(breakpointServerLocation); +} + +export function enableBreakpoint(cx, initialBreakpoint) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + const state = getState(); + const breakpoint = getBreakpoint(state, initialBreakpoint.location); + const blackboxedRanges = getBlackBoxRanges(state); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, breakpoint.location.source); + if ( + !breakpoint || + !breakpoint.disabled || + isLineBlackboxed( + blackboxedRanges[breakpoint.location.source.url], + breakpoint.location.line, + isSourceOnIgnoreList + ) + ) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: createBreakpoint({ ...breakpoint, disabled: false }), + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +export function addBreakpoint( + cx, + initialLocation, + options = {}, + disabled, + shouldCancel = () => false +) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + recordEvent("add_breakpoint"); + + await dispatch( + setBreakpointPositions({ + cx, + location: initialLocation, + }) + ); + + const position = initialLocation.column + ? getBreakpointPositionsForLocation(getState(), initialLocation) + : getFirstBreakpointPosition(getState(), initialLocation); + + // No position is found if the `initialLocation` is on a non-breakable line or + // the line no longer exists. + if (!position) { + return null; + } + + const { location, generatedLocation } = position; + + if (!location.source || !generatedLocation.source) { + return null; + } + + const originalContent = getSettledSourceTextContent(getState(), location); + const originalText = getTextAtPosition( + location.source.id, + originalContent, + location + ); + + const content = getSettledSourceTextContent(getState(), generatedLocation); + const text = getTextAtPosition( + generatedLocation.source.id, + content, + generatedLocation + ); + + const id = makeBreakpointId(location); + const breakpoint = createBreakpoint({ + id, + disabled, + options, + location, + generatedLocation, + text, + originalText, + }); + + if (shouldCancel()) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint, + // If we just clobbered an enabled breakpoint with a disabled one, we need + // to remove any installed breakpoint in the server. + [PROMISE]: disabled + ? clientRemoveBreakpoint(client, getState(), generatedLocation) + : clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +/** + * Remove a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoint(cx, initialBreakpoint) { + return ({ dispatch, getState, client }) => { + recordEvent("remove_breakpoint"); + + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + breakpoint, + // If the breakpoint is disabled then it is not installed in the server. + [PROMISE]: breakpoint.disabled + ? Promise.resolve() + : clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Remove all installed, pending, and client breakpoints associated with a + * target generated location. + * + * @param {Object} target + * Location object where to remove breakpoints. + */ +export function removeBreakpointAtGeneratedLocation(cx, target) { + return ({ dispatch, getState, client }) => { + // remove breakpoint from the server + const onBreakpointRemoved = clientRemoveBreakpoint( + client, + getState(), + target + ); + // Remove any breakpoints matching the generated location. + const breakpoints = getBreakpointsList(getState()); + for (const breakpoint of breakpoints) { + const { generatedLocation } = breakpoint; + if ( + generatedLocation.sourceId == target.sourceId && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_BREAKPOINT", + cx, + breakpoint, + [PROMISE]: onBreakpointRemoved, + }); + } + } + + // Remove any remaining pending breakpoints matching the generated location. + const pending = getPendingBreakpointList(getState()); + for (const pendingBreakpoint of pending) { + const { generatedLocation } = pendingBreakpoint; + if ( + generatedLocation.sourceUrl == target.sourceUrl && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_PENDING_BREAKPOINT", + cx, + pendingBreakpoint, + }); + } + } + return onBreakpointRemoved; + }; +} + +/** + * Disable a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpoint(cx, initialBreakpoint) { + return ({ dispatch, getState, client }) => { + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || breakpoint.disabled) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint: createBreakpoint({ ...breakpoint, disabled: true }), + [PROMISE]: clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Update the options of a breakpoint. + * + * @throws {Error} "not implemented" + * @memberof actions/breakpoints + * @static + * @param {SourceLocation} location + * @see DebuggerController.Breakpoints.addBreakpoint + * @param {Object} options + * Any options to set on the breakpoint + */ +export function setBreakpointOptions(cx, location, options = {}) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + let breakpoint = getBreakpoint(getState(), location); + if (!breakpoint) { + return dispatch(addBreakpoint(cx, location, options)); + } + + // Note: setting a breakpoint's options implicitly enables it. + breakpoint = createBreakpoint({ ...breakpoint, disabled: false, options }); + + return dispatch({ + type: "SET_BREAKPOINT", + cx, + breakpoint, + [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint), + }); + }; +} + +async function updateExpression(parserWorker, mappings, originalExpression) { + const mapped = await parserWorker.mapExpression( + originalExpression, + mappings, + [], + false, + false + ); + if (!mapped) { + return originalExpression; + } + if (!originalExpression.trimEnd().endsWith(";")) { + return mapped.expression.replace(/;$/, ""); + } + return mapped.expression; +} + +function updateBreakpointSourceMapping(cx, breakpoint) { + return async ({ getState, dispatch, parserWorker }) => { + const options = { ...breakpoint.options }; + + const mappedScopes = await dispatch( + getMappedScopesForLocation(breakpoint.location) + ); + if (!mappedScopes) { + return breakpoint; + } + const { mappings } = mappedScopes; + + if (options.condition) { + options.condition = await updateExpression( + parserWorker, + mappings, + options.condition + ); + } + if (options.logValue) { + options.logValue = await updateExpression( + parserWorker, + mappings, + options.logValue + ); + } + + validateNavigateContext(getState(), cx); + return { ...breakpoint, options }; + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/moz.build b/devtools/client/debugger/src/actions/breakpoints/moz.build new file mode 100644 index 0000000000..65910c4ef2 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/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( + "breakpointPositions.js", + "index.js", + "modify.js", + "syncBreakpoint.js", +) diff --git a/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js new file mode 100644 index 0000000000..b52c0ddfb1 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { setBreakpointPositions } from "./breakpointPositions"; +import { + findPosition, + makeBreakpointServerLocation, +} from "../../utils/breakpoint"; + +import { comparePosition, createLocation } from "../../utils/location"; + +import { + originalToGeneratedId, + isOriginalId, +} from "devtools/client/shared/source-map-loader/index"; +import { getSource } from "../../selectors"; +import { addBreakpoint, removeBreakpointAtGeneratedLocation } from "."; + +async function findBreakpointPosition(cx, { getState, dispatch }, location) { + const positions = await dispatch(setBreakpointPositions({ cx, location })); + + const position = findPosition(positions, location); + return position; +} + +// Breakpoint syncing occurs when a source is found that matches either the +// original or generated URL of a pending breakpoint. A new breakpoint is +// constructed that might have a different original and/or generated location, +// if the original source has changed since the pending breakpoint was created. +// There are a couple subtle aspects to syncing: +// +// - We handle both the original and generated source because there is no +// guarantee that seeing the generated source means we will also see the +// original source. When connecting, a breakpoint will be installed in the +// client for the generated location in the pending breakpoint, and we need +// to make sure that either a breakpoint is added to the reducer or that this +// client breakpoint is deleted. +// +// - If we see both the original and generated sources and the source mapping +// has changed, we need to make sure that only a single breakpoint is added +// to the reducer for the new location corresponding to the original location +// in the pending breakpoint. +export function syncPendingBreakpoint(cx, sourceId, pendingBreakpoint) { + return async thunkArgs => { + const { getState, client, dispatch } = thunkArgs; + + const source = getSource(getState(), sourceId); + + const generatedSourceId = isOriginalId(sourceId) + ? originalToGeneratedId(sourceId) + : sourceId; + + const generatedSource = getSource(getState(), generatedSourceId); + + if (!source || !generatedSource) { + return null; + } + + // /!\ Pending breakpoint locations come only with sourceUrl, line and column attributes. + // We have to map it to a specific source object and avoid trying to query its non-existent 'source' attribute. + const { location, generatedLocation } = pendingBreakpoint; + const isPendingBreakpointWithSourceMap = + location.sourceUrl != generatedLocation.sourceUrl; + const sourceGeneratedLocation = createLocation({ + ...generatedLocation, + source: generatedSource, + }); + + if (source == generatedSource && isPendingBreakpointWithSourceMap) { + // We are handling the generated source and the pending breakpoint has a + // source mapping. Supply a cancellation callback that will abort the + // breakpoint if the original source was synced to a different location, + // in which case the client breakpoint has been removed. + const breakpointServerLocation = makeBreakpointServerLocation( + getState(), + sourceGeneratedLocation + ); + return dispatch( + addBreakpoint( + cx, + sourceGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled, + () => !client.hasBreakpoint(breakpointServerLocation) + ) + ); + } + + const originalLocation = createLocation({ + ...location, + source, + }); + + const newPosition = await findBreakpointPosition( + cx, + thunkArgs, + originalLocation + ); + + const newGeneratedLocation = newPosition?.generatedLocation; + if (!newGeneratedLocation) { + // We couldn't find a new mapping for the breakpoint. If there is a source + // mapping, remove any breakpoints for the generated location, as if the + // breakpoint moved. If the old generated location still maps to an + // original location then we don't want to add a breakpoint for it. + if (isPendingBreakpointWithSourceMap) { + dispatch( + removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation) + ); + } + return null; + } + + const isSameLocation = comparePosition( + generatedLocation, + newGeneratedLocation + ); + + // If the new generated location has changed from that in the pending + // breakpoint, remove any breakpoint associated with the old generated + // location. + if (!isSameLocation) { + dispatch( + removeBreakpointAtGeneratedLocation(cx, sourceGeneratedLocation) + ); + } + + return dispatch( + addBreakpoint( + cx, + newGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled + ) + ); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap new file mode 100644 index 0000000000..c18c3593d9 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap @@ -0,0 +1,173 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`breakpoints should add a breakpoint 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 2, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:2:1", + "location": Object { + "column": 1, + "line": 2, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "options": Object {}, + "originalText": "return a", + "text": "return a", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; + +exports[`breakpoints should not show a breakpoint that does not have text 1`] = `Array []`; + +exports[`breakpoints should show a disabled breakpoint that does not have text 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "disabled": true, + "generatedLocation": Object { + "column": 1, + "line": 5, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "id": "a:5:1", + "location": Object { + "column": 1, + "line": 5, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "a", + "sourceUrl": "http://localhost:8000/examples/a", + }, + "options": Object {}, + "originalText": "", + "text": "", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js new file mode 100644 index 0000000000..558d2400a8 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js @@ -0,0 +1,521 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + createStore, + selectors, + actions, + makeSource, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { createLocation } from "../../../utils/location"; + +jest.mock("../../../utils/prefs", () => ({ + prefs: { + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + features: { + inlinePreview: true, + }, +})); + +function mockClient(positionsResponse = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => positionsResponse, + getSourceActorBreakableLines: async () => [], + }; +} + +describe("breakpoints", () => { + it("should add a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 2: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 2, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(getTelemetryEvents("add_breakpoint")).toHaveLength(1); + + const bpSources = selectors.getBreakpointSources(getState()); + expect(bpSources).toMatchSnapshot(); + }); + + it("should not show a breakpoint that does not have text", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should show a disabled breakpoint that does not have text", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, breakpoint)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should not re-add a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc1 = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + + await dispatch(actions.addBreakpoint(cx, loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should remove a breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1], 6: [2] }) + ); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + await dispatch( + actions.selectLocation( + cx, + createLocation({ + source: aSource, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + const bp = selectors.getBreakpoint(getState(), loc1); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.removeBreakpoint(cx, bp)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should disable a breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1], 6: [2] }) + ); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, breakpoint)); + + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.disabled).toBe(true); + }); + + it("should enable breakpoint", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1], 6: [2] }) + ); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + await dispatch(actions.addBreakpoint(cx, loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + expect(bp && bp.disabled).toBe(true); + + await dispatch(actions.enableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && !bp.disabled).toBe(true); + }); + + it("should toggle all the breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1], 6: [2] }) + ); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: aSourceActor, + }) + ); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: bSourceActor, + }) + ); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + sourceUrl: "http://localhost:8000/examples/b", + }); + + await dispatch(actions.addBreakpoint(cx, loc1)); + await dispatch(actions.addBreakpoint(cx, loc2)); + + await dispatch(actions.toggleAllBreakpoints(cx, true)); + + let bp1 = selectors.getBreakpoint(getState(), loc1); + let bp2 = selectors.getBreakpoint(getState(), loc2); + + expect(bp1 && bp1.disabled).toBe(true); + expect(bp2 && bp2.disabled).toBe(true); + + await dispatch(actions.toggleAllBreakpoints(cx, false)); + + bp1 = selectors.getBreakpoint(getState(), loc1); + bp2 = selectors.getBreakpoint(getState(), loc2); + expect(bp1 && bp1.disabled).toBe(false); + expect(bp2 && bp2.disabled).toBe(false); + }); + + it("should toggle a breakpoint at a location", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const loc = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), loc); + await dispatch(actions.selectLocation(cx, loc)); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + const bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + expect(getBp()).toBe(undefined); + }); + + it("should disable/enable a breakpoint at a location", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const location = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), location); + await dispatch( + actions.selectLocation(cx, createLocation({ source, line: 1 })) + ); + + await dispatch(actions.toggleBreakpointAtLine(cx, 5)); + let bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + bp = getBp(); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.toggleDisabledBreakpoint(cx, bp)); + bp = getBp(); + expect(bp && bp.disabled).toBe(true); + }); + + it("should set the breakpoint condition", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, loc)); + + let bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(cx, loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe("const foo = 0"); + }); + + it("should set the condition and enable a breakpoint", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + const loc = createLocation({ + source, + line: 5, + column: 1, + sourceUrl: "http://localhost:8000/examples/a", + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(cx, bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(cx, loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + const newBreakpoint = selectors.getBreakpoint(getState(), loc); + expect(newBreakpoint && !newBreakpoint.disabled).toBe(true); + expect(newBreakpoint && newBreakpoint.options.condition).toBe( + "const foo = 0" + ); + }); + + it("should remove the pretty-printed breakpoint that was added", async () => { + const { dispatch, getState, cx } = createStore(mockClient({ 1: [0] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("a.js")) + ); + const loc = createLocation({ + source, + line: 1, + column: 0, + sourceUrl: "http://localhost:8000/examples/a.js", + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, loc)); + await dispatch(actions.togglePrettyPrint(cx, "a.js")); + + const breakpoint = selectors.getBreakpointsList(getState())[0]; + + await dispatch(actions.removeBreakpoint(cx, breakpoint)); + + const breakpointList = selectors.getPendingBreakpointList(getState()); + expect(breakpointList.length).toBe(0); + }); +}); diff --git a/devtools/client/debugger/src/actions/event-listeners.js b/devtools/client/debugger/src/actions/event-listeners.js new file mode 100644 index 0000000000..9c59e930a7 --- /dev/null +++ b/devtools/client/debugger/src/actions/event-listeners.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getActiveEventListeners, + getEventListenerExpanded, + shouldLogEventBreakpoints, +} from "../selectors"; + +async function updateBreakpoints(dispatch, client, newEvents) { + await client.setEventListenerBreakpoints(newEvents); + dispatch({ type: "UPDATE_EVENT_LISTENERS", active: newEvents }); +} + +async function updateExpanded(dispatch, newExpanded) { + dispatch({ + type: "UPDATE_EVENT_LISTENER_EXPANDED", + expanded: newExpanded, + }); +} + +export function addEventListenerBreakpoints(eventsToAdd) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = [ + ...new Set([...eventsToAdd, ...activeListenerBreakpoints]), + ]; + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function removeEventListenerBreakpoints(eventsToRemove) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = activeListenerBreakpoints.filter( + event => !eventsToRemove.includes(event) + ); + + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function toggleEventLogging() { + return async ({ dispatch, getState, client }) => { + const logEventBreakpoints = !shouldLogEventBreakpoints(getState()); + await client.toggleEventLogging(logEventBreakpoints); + dispatch({ type: "TOGGLE_EVENT_LISTENERS", logEventBreakpoints }); + }; +} + +export function addEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + const newExpanded = [...new Set([...expanded, category])]; + await updateExpanded(dispatch, newExpanded); + }; +} + +export function removeEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + + const newExpanded = expanded.filter(expand => expand != category); + + updateExpanded(dispatch, newExpanded); + }; +} + +export function getEventListenerBreakpointTypes() { + return async ({ dispatch, client }) => { + const categories = await client.getEventListenerBreakpointTypes(); + dispatch({ type: "RECEIVE_EVENT_LISTENER_TYPES", categories }); + }; +} diff --git a/devtools/client/debugger/src/actions/exceptions.js b/devtools/client/debugger/src/actions/exceptions.js new file mode 100644 index 0000000000..f1746ec2bb --- /dev/null +++ b/devtools/client/debugger/src/actions/exceptions.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function addExceptionFromResources(resources) { + return async function ({ dispatch }) { + for (const resource of resources) { + const { pageError } = resource; + if (!pageError.error) { + continue; + } + const { columnNumber, lineNumber, sourceId, errorMessage } = pageError; + const stacktrace = pageError.stacktrace || []; + + const exception = { + columnNumber, + lineNumber, + sourceActorId: sourceId, + errorMessage, + stacktrace, + threadActorId: resource.targetFront.targetForm.threadActor, + }; + + dispatch({ + type: "ADD_EXCEPTION", + exception, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/expressions.js b/devtools/client/debugger/src/actions/expressions.js new file mode 100644 index 0000000000..e324038bfb --- /dev/null +++ b/devtools/client/debugger/src/actions/expressions.js @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getExpression, + getExpressions, + getSelectedFrame, + getSelectedFrameId, + getSelectedSource, + getSelectedScopeMappings, + getSelectedFrameBindings, + getCurrentThread, + getIsPaused, + isMapScopesEnabled, +} from "../selectors"; +import { PROMISE } from "./utils/middleware/promise"; +import { wrapExpression } from "../utils/expressions"; +import { features } from "../utils/prefs"; + +/** + * Add expression for debugger to watch + * + * @param {object} expression + * @param {number} expression.id + * @memberof actions/pause + * @static + */ +export function addExpression(cx, input) { + return async ({ dispatch, getState, parserWorker }) => { + if (!input) { + return null; + } + + const expressionError = await parserWorker.hasSyntaxError(input); + + const expression = getExpression(getState(), input); + if (expression) { + return dispatch(evaluateExpression(cx, expression)); + } + + dispatch({ type: "ADD_EXPRESSION", cx, input, expressionError }); + + const newExpression = getExpression(getState(), input); + if (newExpression) { + return dispatch(evaluateExpression(cx, newExpression)); + } + + return null; + }; +} + +export function autocomplete(cx, input, cursor) { + return async ({ dispatch, getState, client }) => { + if (!input) { + return; + } + const frameId = getSelectedFrameId(getState(), cx.thread); + const result = await client.autocomplete(input, cursor, frameId); + dispatch({ type: "AUTOCOMPLETE", cx, input, result }); + }; +} + +export function clearAutocomplete() { + return { type: "CLEAR_AUTOCOMPLETE" }; +} + +export function clearExpressionError() { + return { type: "CLEAR_EXPRESSION_ERROR" }; +} + +export function updateExpression(cx, input, expression) { + return async ({ dispatch, getState, parserWorker }) => { + if (!input) { + return; + } + + const expressionError = await parserWorker.hasSyntaxError(input); + dispatch({ + type: "UPDATE_EXPRESSION", + cx, + expression, + input: expressionError ? expression.input : input, + expressionError, + }); + + dispatch(evaluateExpressions(cx)); + }; +} + +/** + * + * @param {object} expression + * @param {number} expression.id + * @memberof actions/pause + * @static + */ +export function deleteExpression(expression) { + return ({ dispatch }) => { + dispatch({ + type: "DELETE_EXPRESSION", + input: expression.input, + }); + }; +} + +/** + * + * @memberof actions/pause + * @param {number} selectedFrameId + * @static + */ +export function evaluateExpressions(cx) { + return async function ({ dispatch, getState, client }) { + const expressions = getExpressions(getState()); + const inputs = expressions.map(({ input }) => input); + const frameId = getSelectedFrameId(getState(), cx.thread); + const results = await client.evaluateExpressions(inputs, { + frameId, + threadId: cx.thread, + }); + dispatch({ type: "EVALUATE_EXPRESSIONS", cx, inputs, results }); + }; +} + +function evaluateExpression(cx, expression) { + return async function ({ dispatch, getState, client }) { + if (!expression.input) { + console.warn("Expressions should not be empty"); + return null; + } + + let { input } = expression; + const frame = getSelectedFrame(getState(), cx.thread); + + if (frame) { + const selectedSource = getSelectedSource(getState()); + + if ( + selectedSource && + frame.location.source.isOriginal && + selectedSource.isOriginal + ) { + const mapResult = await dispatch(getMappedExpression(input)); + if (mapResult) { + input = mapResult.expression; + } + } + } + + const frameId = getSelectedFrameId(getState(), cx.thread); + + return dispatch({ + type: "EVALUATE_EXPRESSION", + cx, + thread: cx.thread, + input: expression.input, + [PROMISE]: client.evaluate(wrapExpression(input), { + frameId, + }), + }); + }; +} + +/** + * Gets information about original variable names from the source map + * and replaces all posible generated names. + */ +export function getMappedExpression(expression) { + return async function ({ dispatch, getState, parserWorker }) { + const thread = getCurrentThread(getState()); + const mappings = getSelectedScopeMappings(getState(), thread); + const bindings = getSelectedFrameBindings(getState(), thread); + + // We bail early if we do not need to map the expression. This is important + // because mapping an expression can be slow if the parserWorker + // worker is busy doing other work. + // + // 1. there are no mappings - we do not need to map original expressions + // 2. does not contain `await` - we do not need to map top level awaits + // 3. does not contain `=` - we do not need to map assignments + const shouldMapScopes = isMapScopesEnabled(getState()) && mappings; + if (!shouldMapScopes && !expression.match(/(await|=)/)) { + return null; + } + + return parserWorker.mapExpression( + expression, + mappings, + bindings || [], + features.mapExpressionBindings && getIsPaused(getState(), thread), + features.mapAwaitExpression + ); + }; +} diff --git a/devtools/client/debugger/src/actions/file-search.js b/devtools/client/debugger/src/actions/file-search.js new file mode 100644 index 0000000000..4ea2ea01bb --- /dev/null +++ b/devtools/client/debugger/src/actions/file-search.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 { searchSourceForHighlight } from "../utils/editor"; + +import { getSelectedSourceTextContent, getSearchOptions } from "../selectors"; + +import { closeActiveSearch, clearHighlightLineRange } from "./ui"; + +export function doSearchForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const sourceTextContent = getSelectedSourceTextContent(getState()); + if (!sourceTextContent) { + return; + } + + dispatch(searchContentsForHighlight(query, editor, line, ch)); + }; +} + +// Expose an action to the React component, so that it can call the searchWorker. +export function querySearchWorker(query, text, modifiers) { + return ({ searchWorker }) => { + return searchWorker.getMatches(query, text, modifiers); + }; +} + +export function searchContentsForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const modifiers = getSearchOptions(getState(), "file-search"); + const sourceTextContent = getSelectedSourceTextContent(getState()); + + if (!query || !editor || !sourceTextContent || !modifiers) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + searchSourceForHighlight(ctx, false, query, true, modifiers, line, ch); + }; +} + +export function closeFileSearch(cx, editor) { + return ({ getState, dispatch }) => { + dispatch(closeActiveSearch()); + dispatch(clearHighlightLineRange()); + }; +} diff --git a/devtools/client/debugger/src/actions/index.js b/devtools/client/debugger/src/actions/index.js new file mode 100644 index 0000000000..ab6eec75f1 --- /dev/null +++ b/devtools/client/debugger/src/actions/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 * as ast from "./ast"; +import * as breakpoints from "./breakpoints"; +import * as exceptions from "./exceptions"; +import * as expressions from "./expressions"; +import * as eventListeners from "./event-listeners"; +import * as pause from "./pause"; +import * as navigation from "./navigation"; +import * as ui from "./ui"; +import * as fileSearch from "./file-search"; +import * as projectTextSearch from "./project-text-search"; +import * as quickOpen from "./quick-open"; +import * as sourcesTree from "./sources-tree"; +import * as sources from "./sources"; +import * as sourcesActors from "./source-actors"; +import * as tabs from "./tabs"; +import * as threads from "./threads"; +import * as toolbox from "./toolbox"; +import * as preview from "./preview"; +import * as tracing from "./tracing"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +export default { + ...ast, + ...navigation, + ...breakpoints, + ...exceptions, + ...expressions, + ...eventListeners, + ...sources, + ...sourcesActors, + ...tabs, + ...pause, + ...ui, + ...fileSearch, + ...objectInspector.actions, + ...projectTextSearch, + ...quickOpen, + ...sourcesTree, + ...threads, + ...toolbox, + ...preview, + ...tracing, +}; diff --git a/devtools/client/debugger/src/actions/moz.build b/devtools/client/debugger/src/actions/moz.build new file mode 100644 index 0000000000..770fc61139 --- /dev/null +++ b/devtools/client/debugger/src/actions/moz.build @@ -0,0 +1,31 @@ +# 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 += [ + "ast", + "breakpoints", + "pause", + "sources", + "utils", +] + +CompiledModules( + "event-listeners.js", + "exceptions.js", + "expressions.js", + "file-search.js", + "index.js", + "navigation.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "sources-tree.js", + "tabs.js", + "toolbox.js", + "tracing.js", + "threads.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/actions/navigation.js b/devtools/client/debugger/src/actions/navigation.js new file mode 100644 index 0000000000..03d06a2baa --- /dev/null +++ b/devtools/client/debugger/src/actions/navigation.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { clearDocuments } from "../utils/editor"; +import sourceQueue from "../utils/source-queue"; + +import { clearWasmStates } from "../utils/wasm"; +import { getMainThread, getThreadContext } from "../selectors"; +import { evaluateExpressions } from "../actions/expressions"; + +/** + * Redux actions for the navigation state + * @module actions/navigation + */ + +/** + * @memberof actions/navigation + * @static + */ +export function willNavigate(event) { + return async function ({ + dispatch, + getState, + client, + sourceMapLoader, + parserWorker, + }) { + sourceQueue.clear(); + sourceMapLoader.clearSourceMaps(); + clearWasmStates(); + clearDocuments(); + parserWorker.clear(); + const thread = getMainThread(getState()); + + dispatch({ + type: "NAVIGATE", + mainThread: { ...thread, url: event.url }, + }); + }; +} + +/** + * @memberof actions/navigation + * @static + */ +export function navigated() { + return async function ({ getState, dispatch, panel }) { + try { + // Update the watched expressions once the page is fully loaded + const threadcx = getThreadContext(getState()); + await dispatch(evaluateExpressions(threadcx)); + } catch (e) { + // This may throw if we resume during the page load. + // browser_dbg-debugger-buttons.js highlights this, especially on MacOS or when ran many times + console.error("Failed to update expression on navigation", e); + } + + panel.emit("reloaded"); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/breakOnNext.js b/devtools/client/debugger/src/actions/pause/breakOnNext.js new file mode 100644 index 0000000000..02df827cb1 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/breakOnNext.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Debugger breakOnNext command. + * It's different from the comand action because we also want to + * highlight the pause icon. + * + * @memberof actions/pause + * @static + */ +export function breakOnNext(cx) { + return async ({ dispatch, getState, client }) => { + await client.breakOnNext(cx.thread); + return dispatch({ type: "BREAK_ON_NEXT", thread: cx.thread }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/commands.js b/devtools/client/debugger/src/actions/pause/commands.js new file mode 100644 index 0000000000..27478d6ad2 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/commands.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getSelectedFrame, + getThreadContext, + getCurrentThread, + getIsCurrentThreadPaused, +} from "../../selectors"; +import { PROMISE } from "../utils/middleware/promise"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources"; +import { fetchScopes } from "./fetchScopes"; +import { fetchFrames } from "./fetchFrames"; +import { recordEvent } from "../../utils/telemetry"; +import assert from "../../utils/assert"; + +export function selectThread(cx, thread) { + return async ({ dispatch, getState, client }) => { + if (getCurrentThread(getState()) === thread) { + return; + } + + dispatch({ cx, type: "SELECT_THREAD", thread }); + + // Get a new context now that the current thread has changed. + const threadcx = getThreadContext(getState()); + // Note that this is a rethorical assertion as threadcx.thread is updated by SELECT_THREAD action + assert(threadcx.thread == thread, "Thread mismatch"); + + const serverRequests = []; + // Update the watched expressions as we may never have evaluated them against this thread + serverRequests.push(dispatch(evaluateExpressions(threadcx))); + + // If we were paused on the newly selected thread, ensure: + // - select the source where we are paused, + // - fetching the paused stackframes, + // - fetching the paused scope, so that variable preview are working on the selected source. + // (frames and scopes is supposed to be fetched on pause, + // but if two threads pause concurrently, it might be cancelled) + const frame = getSelectedFrame(getState(), thread); + if (frame) { + serverRequests.push(dispatch(selectLocation(threadcx, frame.location))); + serverRequests.push(dispatch(fetchFrames(threadcx))); + serverRequests.push(dispatch(fetchScopes(threadcx))); + } + + await Promise.all(serverRequests); + }; +} + +/** + * Debugger commands like stepOver, stepIn, stepUp + * + * @param string $0.type + * @memberof actions/pause + * @static + */ +export function command(type) { + return async ({ dispatch, getState, client }) => { + if (!type) { + return null; + } + // For now, all commands are by default against the currently selected thread + const thread = getCurrentThread(getState()); + + const frame = getSelectedFrame(getState(), thread); + + return dispatch({ + type: "COMMAND", + command: type, + thread, + [PROMISE]: client[type](thread, frame?.id), + }); + }; +} + +/** + * StepIn + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepIn() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepIn")); + }; +} + +/** + * stepOver + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOver() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOver")); + }; +} + +/** + * stepOut + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function stepOut() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOut")); + }; +} + +/** + * resume + * @memberof actions/pause + * @static + * @returns {Function} {@link command} + */ +export function resume() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + recordEvent("continue"); + return dispatch(command("resume")); + }; +} + +/** + * restart frame + * @memberof actions/pause + * @static + */ +export function restart(cx, frame) { + return async ({ dispatch, getState, client }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch({ + type: "COMMAND", + command: "restart", + thread: cx.thread, + [PROMISE]: client.restart(cx.thread, frame.id), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/continueToHere.js b/devtools/client/debugger/src/actions/pause/continueToHere.js new file mode 100644 index 0000000000..56aa117eab --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/continueToHere.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getSelectedSource, + getSelectedFrame, + getClosestBreakpointPosition, + getBreakpoint, +} from "../../selectors"; +import { createLocation } from "../../utils/location"; +import { addHiddenBreakpoint } from "../breakpoints"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +import { resume } from "./commands"; + +export function continueToHere(cx, location) { + return async function ({ dispatch, getState }) { + const { line, column } = location; + const selectedSource = getSelectedSource(getState()); + const selectedFrame = getSelectedFrame(getState(), cx.thread); + + if (!selectedFrame || !selectedSource) { + return; + } + + const debugLine = selectedFrame.location.line; + // If the user selects a line to continue to, + // it must be different than the currently paused line. + if (!column && debugLine == line) { + return; + } + + await dispatch(setBreakpointPositions({ cx, location })); + const position = getClosestBreakpointPosition(getState(), location); + + // If the user selects a location in the editor, + // there must be a place we can pause on that line. + if (column && !position) { + return; + } + + const pauseLocation = column && position ? position.location : location; + + // Set a hidden breakpoint if we do not already have a breakpoint + // at the closest position + if (!getBreakpoint(getState(), pauseLocation)) { + await dispatch( + addHiddenBreakpoint( + cx, + createLocation({ + source: selectedSource, + line: pauseLocation.line, + column: pauseLocation.column, + }) + ) + ); + } + + dispatch(resume(cx)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/expandScopes.js b/devtools/client/debugger/src/actions/pause/expandScopes.js new file mode 100644 index 0000000000..fa431ee0b9 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/expandScopes.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getScopeItemPath } from "../../utils/pause/scopes/utils"; + +export function setExpandedScope(cx, item, expanded) { + return function ({ dispatch, getState }) { + return dispatch({ + type: "SET_EXPANDED_SCOPE", + cx, + thread: cx.thread, + path: getScopeItemPath(item), + expanded, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchFrames.js b/devtools/client/debugger/src/actions/pause/fetchFrames.js new file mode 100644 index 0000000000..42295ae026 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchFrames.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isValidThreadContext } from "../../utils/context"; + +export function fetchFrames(cx) { + return async function ({ dispatch, client, getState }) { + const { thread } = cx; + let frames; + try { + frames = await client.getFrames(thread); + } catch (e) { + // getFrames will fail if the thread has resumed. In this case the thread + // should no longer be valid and the frames we would have fetched would be + // discarded anyways. + if (isValidThreadContext(getState(), cx)) { + throw e; + } + } + dispatch({ type: "FETCHED_FRAMES", thread, frames, cx }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchScopes.js b/devtools/client/debugger/src/actions/pause/fetchScopes.js new file mode 100644 index 0000000000..691b3ce006 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchScopes.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSelectedFrame, getGeneratedFrameScope } from "../../selectors"; +import { mapScopes } from "./mapScopes"; +import { generateInlinePreview } from "./inlinePreview"; +import { PROMISE } from "../utils/middleware/promise"; + +export function fetchScopes(cx) { + return async function ({ dispatch, getState, client }) { + const frame = getSelectedFrame(getState(), cx.thread); + if (!frame || getGeneratedFrameScope(getState(), frame.id)) { + return; + } + + const scopes = dispatch({ + type: "ADD_SCOPES", + cx, + thread: cx.thread, + frame, + [PROMISE]: client.getFrameScopes(frame), + }); + + scopes.then(() => { + dispatch(generateInlinePreview(cx, frame)); + }); + await dispatch(mapScopes(cx, scopes, frame)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/highlightCalls.js b/devtools/client/debugger/src/actions/pause/highlightCalls.js new file mode 100644 index 0000000000..aec82fe35b --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/highlightCalls.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + getSymbols, + getSelectedFrame, + getCurrentThread, +} from "../../selectors"; + +// a is an ast location with start and end positions (line and column). +// b is a single position (line and column). +// This function tests to see if the b position +// falls within the range given in a. +function inHouseContainsPosition(a, b) { + const bColumn = b.column || 0; + const startsBefore = + a.start.line < b.line || + (a.start.line === b.line && a.start.column <= bColumn); + const endsAfter = + a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn); + + return startsBefore && endsAfter; +} + +export function highlightCalls(cx) { + return async function ({ dispatch, getState, parserWorker }) { + if (!cx) { + return null; + } + + const frame = await getSelectedFrame( + getState(), + getCurrentThread(getState()) + ); + + if (!frame || !parserWorker.isLocationSupported(frame.location)) { + return null; + } + + const { thread } = cx; + + const originalAstScopes = await parserWorker.getScopes(frame.location); + if (!originalAstScopes) { + return null; + } + + const symbols = getSymbols(getState(), frame.location); + + if (!symbols) { + return null; + } + + if (!symbols.callExpressions) { + return null; + } + + const localAstScope = originalAstScopes[0]; + const allFunctionCalls = symbols.callExpressions; + + const highlightedCalls = allFunctionCalls.filter(function (call) { + const containsStart = inHouseContainsPosition( + localAstScope, + call.location.start + ); + const containsEnd = inHouseContainsPosition( + localAstScope, + call.location.end + ); + return containsStart && containsEnd; + }); + + return dispatch({ + type: "HIGHLIGHT_CALLS", + thread, + highlightedCalls, + }); + }; +} + +export function unhighlightCalls(cx) { + return async function ({ dispatch, getState }) { + const { thread } = cx; + return dispatch({ + type: "UNHIGHLIGHT_CALLS", + thread, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/index.js b/devtools/client/debugger/src/actions/pause/index.js new file mode 100644 index 0000000000..be31894019 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/index.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the pause state + * @module actions/pause + */ + +export { + selectThread, + stepIn, + stepOver, + stepOut, + resume, + restart, +} from "./commands"; +export { fetchFrames } from "./fetchFrames"; +export { fetchScopes } from "./fetchScopes"; +export { paused } from "./paused"; +export { resumed } from "./resumed"; +export { continueToHere } from "./continueToHere"; +export { breakOnNext } from "./breakOnNext"; +export { resetBreakpointsPaneState } from "./resetBreakpointsPaneState"; +export { mapFrames } from "./mapFrames"; +export { mapDisplayNames } from "./mapDisplayNames"; +export { pauseOnExceptions } from "./pauseOnExceptions"; +export { selectFrame } from "./selectFrame"; +export { toggleSkipPausing, setSkipPausing } from "./skipPausing"; +export { toggleMapScopes } from "./mapScopes"; +export { setExpandedScope } from "./expandScopes"; +export { generateInlinePreview } from "./inlinePreview"; +export { highlightCalls, unhighlightCalls } from "./highlightCalls"; diff --git a/devtools/client/debugger/src/actions/pause/inlinePreview.js b/devtools/client/debugger/src/actions/pause/inlinePreview.js new file mode 100644 index 0000000000..e3a4e614c0 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/inlinePreview.js @@ -0,0 +1,244 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getOriginalFrameScope, + getGeneratedFrameScope, + getInlinePreviews, + getSelectedLocation, +} from "../../selectors"; +import { features } from "../../utils/prefs"; +import { validateThreadContext } from "../../utils/context"; + +// We need to display all variables in the current functional scope so +// include all data for block scopes until the first functional scope +function getLocalScopeLevels(originalAstScopes) { + let levels = 0; + while ( + originalAstScopes[levels] && + originalAstScopes[levels].type === "block" + ) { + levels++; + } + return levels; +} + +export function generateInlinePreview(cx, frame) { + return async function ({ dispatch, getState, parserWorker, client }) { + if (!frame || !features.inlinePreview) { + return null; + } + + const { thread } = cx; + + // Avoid regenerating inline previews when we already have preview data + if (getInlinePreviews(getState(), thread, frame.id)) { + return null; + } + + const originalFrameScopes = getOriginalFrameScope( + getState(), + thread, + frame.location.sourceId, + frame.id + ); + + const generatedFrameScopes = getGeneratedFrameScope( + getState(), + thread, + frame.id + ); + + let scopes = originalFrameScopes?.scope || generatedFrameScopes?.scope; + + if (!scopes || !scopes.bindings) { + return null; + } + + // It's important to use selectedLocation, because we don't know + // if we'll be viewing the original or generated frame location + const selectedLocation = getSelectedLocation(getState()); + if (!selectedLocation) { + return null; + } + + if (!parserWorker.isLocationSupported(selectedLocation)) { + return null; + } + + const originalAstScopes = await parserWorker.getScopes(selectedLocation); + validateThreadContext(getState(), cx); + if (!originalAstScopes) { + return null; + } + + const allPreviews = []; + const pausedOnLine = selectedLocation.line; + const levels = getLocalScopeLevels(originalAstScopes); + + for ( + let curLevel = 0; + curLevel <= levels && scopes && scopes.bindings; + curLevel++ + ) { + const bindings = { ...scopes.bindings.variables }; + scopes.bindings.arguments.forEach(argument => { + Object.keys(argument).forEach(key => { + bindings[key] = argument[key]; + }); + }); + + const previewBindings = Object.keys(bindings).map(async name => { + // We want to show values of properties of objects only and not + // function calls on other data types like someArr.forEach etc.. + let properties = null; + const objectGrip = bindings[name].value; + if (objectGrip.actor && objectGrip.class === "Object") { + properties = await client.loadObjectProperties( + { + name, + path: name, + contents: { value: objectGrip }, + }, + cx.thread + ); + } + + const previewsFromBindings = getBindingValues( + originalAstScopes, + pausedOnLine, + name, + bindings[name].value, + curLevel, + properties + ); + + allPreviews.push(...previewsFromBindings); + }); + await Promise.all(previewBindings); + + scopes = scopes.parent; + } + + // Sort previews by line and column so they're displayed in the right order in the editor + allPreviews.sort((previewA, previewB) => { + if (previewA.line < previewB.line) { + return -1; + } + if (previewA.line > previewB.line) { + return 1; + } + // If we have the same line number + return previewA.column < previewB.column ? -1 : 1; + }); + + const previews = {}; + for (const preview of allPreviews) { + const { line } = preview; + if (!previews[line]) { + previews[line] = []; + } + previews[line].push(preview); + } + + return dispatch({ + type: "ADD_INLINE_PREVIEW", + thread, + frame, + previews, + }); + }; +} + +function getBindingValues( + originalAstScopes, + pausedOnLine, + name, + value, + curLevel, + properties +) { + const previews = []; + + const binding = originalAstScopes[curLevel]?.bindings[name]; + if (!binding) { + return previews; + } + + // Show a variable only once ( an object and it's child property are + // counted as different ) + const identifiers = new Set(); + + // We start from end as we want to show values besides variable + // located nearest to the breakpoint + for (let i = binding.refs.length - 1; i >= 0; i--) { + const ref = binding.refs[i]; + // Subtracting 1 from line as codemirror lines are 0 indexed + const line = ref.start.line - 1; + const column = ref.start.column; + // We don't want to render inline preview below the paused line + if (line >= pausedOnLine - 1) { + continue; + } + + const { displayName, displayValue } = getExpressionNameAndValue( + name, + value, + ref, + properties + ); + + // Variable with same name exists, display value of current or + // closest to the current scope's variable + if (identifiers.has(displayName)) { + continue; + } + identifiers.add(displayName); + + previews.push({ + line, + column, + name: displayName, + value: displayValue, + }); + } + return previews; +} + +function getExpressionNameAndValue( + name, + value, + // TODO: Add data type to ref + ref, + properties +) { + let displayName = name; + let displayValue = value; + + // Only variables of type Object will have properties + if (properties) { + let { meta } = ref; + // Presence of meta property means expression contains child property + // reference eg: objName.propName + while (meta) { + // Initially properties will be an array, after that it will be an object + if (displayValue === value) { + const property = properties.find(prop => prop.name === meta.property); + displayValue = property?.contents.value; + displayName += `.${meta.property}`; + } else if (displayValue?.preview?.ownProperties) { + const { ownProperties } = displayValue.preview; + Object.keys(ownProperties).forEach(prop => { + if (prop === meta.property) { + displayValue = ownProperties[prop].value; + displayName += `.${meta.property}`; + } + }); + } + meta = meta.parent; + } + } + + return { displayName, displayValue }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapDisplayNames.js b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js new file mode 100644 index 0000000000..a7abbc36bd --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFrames, getSymbols } from "../../selectors"; + +import { findClosestFunction } from "../../utils/ast"; + +function mapDisplayName(frame, { getState }) { + if (frame.isOriginal) { + return frame; + } + + const symbols = getSymbols(getState(), frame.location); + + if (!symbols || !symbols.functions) { + return frame; + } + + const originalFunction = findClosestFunction(symbols, frame.location); + + if (!originalFunction) { + return frame; + } + + const originalDisplayName = originalFunction.name; + return { ...frame, originalDisplayName }; +} + +export function mapDisplayNames(cx) { + return function ({ dispatch, getState }) { + const frames = getFrames(getState(), cx.thread); + + if (!frames) { + return; + } + + const mappedFrames = frames.map(frame => + mapDisplayName(frame, { getState }) + ); + + dispatch({ + type: "MAP_FRAME_DISPLAY_NAMES", + cx, + thread: cx.thread, + frames: mappedFrames, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapFrames.js b/devtools/client/debugger/src/actions/pause/mapFrames.js new file mode 100644 index 0000000000..d677677505 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapFrames.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getFrames, + getBlackBoxRanges, + getSelectedFrame, +} from "../../selectors"; + +import { isFrameBlackBoxed } from "../../utils/source"; + +import assert from "../../utils/assert"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "../../utils/location"; +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; + +function getSelectedFrameId(state, thread, frames) { + let selectedFrame = getSelectedFrame(state, thread); + const blackboxedRanges = getBlackBoxRanges(state); + + if (selectedFrame && !isFrameBlackBoxed(selectedFrame, blackboxedRanges)) { + return selectedFrame.id; + } + + selectedFrame = frames.find(frame => { + return !isFrameBlackBoxed(frame, blackboxedRanges); + }); + return selectedFrame?.id; +} + +async function updateFrameLocation(frame, thunkArgs) { + if (frame.isOriginal) { + return Promise.resolve(frame); + } + const location = await getOriginalLocation(frame.location, thunkArgs, true); + return { + ...frame, + location, + generatedLocation: frame.generatedLocation || frame.location, + }; +} + +function updateFrameLocations(frames, thunkArgs) { + if (!frames || !frames.length) { + return Promise.resolve(frames); + } + + return Promise.all( + frames.map(frame => updateFrameLocation(frame, thunkArgs)) + ); +} + +function isWasmOriginalSourceFrame(frame, getState) { + if (isGeneratedId(frame.location.sourceId)) { + return false; + } + + return Boolean(frame.generatedLocation?.source.isWasm); +} + +async function expandFrames(frames, { getState, sourceMapLoader }) { + const result = []; + for (let i = 0; i < frames.length; ++i) { + const frame = frames[i]; + if (frame.isOriginal || !isWasmOriginalSourceFrame(frame, getState)) { + result.push(frame); + continue; + } + const originalFrames = await sourceMapLoader.getOriginalStackFrames( + debuggerToSourceMapLocation(frame.generatedLocation) + ); + if (!originalFrames) { + result.push(frame); + continue; + } + + assert(!!originalFrames.length, "Expected at least one original frame"); + // First entry has not specific location -- use one from original frame. + originalFrames[0] = { + ...originalFrames[0], + location: frame.location, + }; + + originalFrames.forEach((originalFrame, j) => { + if (!originalFrame.location) { + return; + } + + // Keep outer most frame with true actor ID, and generate uniquie + // one for the nested frames. + const id = j == 0 ? frame.id : `${frame.id}-originalFrame${j}`; + result.push({ + id, + displayName: originalFrame.displayName, + location: sourceMapToDebuggerLocation( + getState(), + originalFrame.location + ), + index: frame.index, + source: null, + thread: frame.thread, + scope: frame.scope, + this: frame.this, + isOriginal: true, + // More fields that will be added by the mapDisplayNames and + // updateFrameLocation. + generatedLocation: frame.generatedLocation, + originalDisplayName: originalFrame.displayName, + originalVariables: originalFrame.variables, + asyncCause: frame.asyncCause, + state: frame.state, + }); + }); + } + return result; +} + +/** + * Map call stack frame locations and display names to originals. + * e.g. + * 1. When the debuggee pauses + * 2. When a source is pretty printed + * 3. When symbols are loaded + * @memberof actions/pause + * @static + */ +export function mapFrames(cx) { + return async function (thunkArgs) { + const { dispatch, getState } = thunkArgs; + const frames = getFrames(getState(), cx.thread); + if (!frames) { + return; + } + + let mappedFrames = await updateFrameLocations(frames, thunkArgs); + + mappedFrames = await expandFrames(mappedFrames, thunkArgs); + + const selectedFrameId = getSelectedFrameId( + getState(), + cx.thread, + mappedFrames + ); + + dispatch({ + type: "MAP_FRAMES", + cx, + thread: cx.thread, + frames: mappedFrames, + selectedFrameId, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapScopes.js b/devtools/client/debugger/src/actions/pause/mapScopes.js new file mode 100644 index 0000000000..2a352dc578 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapScopes.js @@ -0,0 +1,194 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getSelectedFrameId, + getSettledSourceTextContent, + isMapScopesEnabled, + getSelectedFrame, + getSelectedGeneratedScope, + getSelectedOriginalScope, + getThreadContext, + getFirstSourceActorForGeneratedSource, +} from "../../selectors"; +import { + loadOriginalSourceText, + loadGeneratedSourceText, +} from "../sources/loadSourceText"; +import { PROMISE } from "../utils/middleware/promise"; +import assert from "../../utils/assert"; + +import { log } from "../../utils/log"; +import { isGenerated } from "../../utils/source"; + +import { buildMappedScopes } from "../../utils/pause/mapScopes"; +import { isFulfilled } from "../../utils/async-value"; + +import { getMappedLocation } from "../../utils/source-maps"; + +const expressionRegex = /\bfp\(\)/g; + +export async function buildOriginalScopes( + frame, + client, + cx, + frameId, + generatedScopes +) { + if (!frame.originalVariables) { + throw new TypeError("(frame.originalVariables: XScopeVariables)"); + } + const originalVariables = frame.originalVariables; + const frameBase = originalVariables.frameBase || ""; + + const inputs = []; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { expr } = originalVariables.vars[i]; + const expression = expr + ? expr.replace(expressionRegex, frameBase) + : "void 0"; + + inputs[i] = expression; + } + + const results = await client.evaluateExpressions(inputs, { + frameId, + }); + + const variables = {}; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { name } = originalVariables.vars[i]; + variables[name] = { value: results[i].result }; + } + + const bindings = { + arguments: [], + variables, + }; + + const { actor } = await generatedScopes; + const scope = { + type: "function", + scopeKind: "", + actor, + bindings, + parent: null, + function: null, + block: null, + }; + return { + mappings: {}, + scope, + }; +} + +export function toggleMapScopes() { + return async function ({ dispatch, getState }) { + if (isMapScopesEnabled(getState())) { + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: false }); + return; + } + + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: true }); + + const cx = getThreadContext(getState()); + + if (getSelectedOriginalScope(getState(), cx.thread)) { + return; + } + + const scopes = getSelectedGeneratedScope(getState(), cx.thread); + const frame = getSelectedFrame(getState(), cx.thread); + if (!scopes || !frame) { + return; + } + + dispatch(mapScopes(cx, Promise.resolve(scopes.scope), frame)); + }; +} + +export function mapScopes(cx, scopes, frame) { + return async function (thunkArgs) { + const { dispatch, client, getState } = thunkArgs; + assert(cx.thread == frame.thread, "Thread mismatch"); + + await dispatch({ + type: "MAP_SCOPES", + cx, + thread: cx.thread, + frame, + [PROMISE]: (async function () { + if (frame.isOriginal && frame.originalVariables) { + const frameId = getSelectedFrameId(getState(), cx.thread); + return buildOriginalScopes(frame, client, cx, frameId, scopes); + } + + return dispatch(getMappedScopes(cx, scopes, frame)); + })(), + }); + }; +} + +export function getMappedScopes(cx, scopes, frame) { + return async function (thunkArgs) { + const { getState, dispatch } = thunkArgs; + const generatedSource = frame.generatedLocation.source; + + const source = frame.location.source; + + if ( + !isMapScopesEnabled(getState()) || + !source || + !generatedSource || + generatedSource.isWasm || + source.isPrettyPrinted || + isGenerated(source) + ) { + return null; + } + + // Load source text for the original source + await dispatch(loadOriginalSourceText({ cx, source })); + + const generatedSourceActor = getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ); + + // Also load source text for its corresponding generated source + await dispatch( + loadGeneratedSourceText({ + cx, + sourceActor: generatedSourceActor, + }) + ); + + try { + // load original source text content + const content = getSettledSourceTextContent(getState(), frame.location); + + return await buildMappedScopes( + source, + content && isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined }, + frame, + await scopes, + thunkArgs + ); + } catch (e) { + log(e); + return null; + } + }; +} + +export function getMappedScopesForLocation(location) { + return async function (thunkArgs) { + const { dispatch, getState } = thunkArgs; + const cx = getThreadContext(getState()); + const mappedLocation = await getMappedLocation(location, thunkArgs); + return dispatch(getMappedScopes(cx, null, mappedLocation)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/moz.build b/devtools/client/debugger/src/actions/pause/moz.build new file mode 100644 index 0000000000..54cf792166 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/moz.build @@ -0,0 +1,27 @@ +# 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( + "breakOnNext.js", + "commands.js", + "continueToHere.js", + "expandScopes.js", + "fetchFrames.js", + "fetchScopes.js", + "highlightCalls.js", + "index.js", + "inlinePreview.js", + "mapDisplayNames.js", + "mapFrames.js", + "mapScopes.js", + "paused.js", + "pauseOnExceptions.js", + "resetBreakpointsPaneState.js", + "resumed.js", + "selectFrame.js", + "skipPausing.js", +) diff --git a/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js new file mode 100644 index 0000000000..e7c04ded61 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; + +/** + * + * @memberof actions/pause + * @static + */ +export function pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions +) { + return ({ dispatch, getState, client }) => { + recordEvent("pause_on_exceptions", { + exceptions: shouldPauseOnExceptions, + // There's no "n" in the key below (#1463117) + ["caught_exceptio"]: shouldPauseOnCaughtExceptions, + }); + + return dispatch({ + type: "PAUSE_ON_EXCEPTIONS", + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + [PROMISE]: client.pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions + ), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/paused.js b/devtools/client/debugger/src/actions/pause/paused.js new file mode 100644 index 0000000000..0e797035a5 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/paused.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 { + getHiddenBreakpoint, + isEvaluatingExpression, + getSelectedFrame, + getThreadContext, +} from "../../selectors"; + +import { mapFrames, fetchFrames } from "."; +import { removeBreakpoint } from "../breakpoints"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources"; +import assert from "../../utils/assert"; + +import { fetchScopes } from "./fetchScopes"; + +/** + * Debugger has just paused + * + * @param {object} pauseInfo + * @memberof actions/pause + * @static + */ +export function paused(pauseInfo) { + return async function ({ dispatch, getState }) { + const { thread, frame, why } = pauseInfo; + + dispatch({ type: "PAUSED", thread, why, frame }); + + // Get a context capturing the newly paused and selected thread. + const cx = getThreadContext(getState()); + // Note that this is a rethorical assertion as threadcx.thread is updated by PAUSED action + assert(cx.thread == thread, "Thread mismatch"); + + // When we use "continue to here" feature we register an "hidden" breakpoint + // that should be removed on the next paused, even if we didn't hit it and + // paused for any other reason. + const hiddenBreakpoint = getHiddenBreakpoint(getState()); + if (hiddenBreakpoint) { + dispatch(removeBreakpoint(cx, hiddenBreakpoint)); + } + + // The THREAD_STATE's "paused" resource only passes the top level stack frame, + // we dispatch the PAUSED action with it so that we can right away + // display it and update the UI to be paused. + // But we then fetch all the other frames: + await dispatch(fetchFrames(cx)); + // And map them to original source locations. + // Note that this will wait for all related original sources to be loaded in the reducers. + // So this step may pause for a little while. + await dispatch(mapFrames(cx)); + + // If we paused on a particular frame, automatically select the related source + // and highlight the paused line + const selectedFrame = getSelectedFrame(getState(), thread); + if (selectedFrame) { + await dispatch(selectLocation(cx, selectedFrame.location)); + } + + // Fetch the previews for variables visible in the currently selected paused stackframe + await dispatch(fetchScopes(cx)); + + // Run after fetching scoping data so that it may make use of the sourcemap + // expression mappings for local variables. + const atException = why.type == "exception"; + if (!atException || !isEvaluatingExpression(getState(), thread)) { + await dispatch(evaluateExpressions(cx)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js new file mode 100644 index 0000000000..a602c58896 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Action for the breakpoints panel while paused. + * + * @memberof actions/pause + * @static + */ +export function resetBreakpointsPaneState(thread) { + return async ({ dispatch }) => { + dispatch({ + type: "RESET_BREAKPOINTS_PANE_STATE", + thread, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resumed.js b/devtools/client/debugger/src/actions/pause/resumed.js new file mode 100644 index 0000000000..323e9f0ff8 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resumed.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isStepping, getPauseReason, getThreadContext } from "../../selectors"; +import { evaluateExpressions } from "../expressions"; +import { inDebuggerEval } from "../../utils/pause"; + +/** + * Debugger has just resumed + * + * @memberof actions/pause + * @static + */ +export function resumed(thread) { + return async ({ dispatch, client, getState }) => { + const why = getPauseReason(getState(), thread); + const wasPausedInEval = inDebuggerEval(why); + const wasStepping = isStepping(getState(), thread); + + dispatch({ type: "RESUME", thread, wasStepping }); + + const cx = getThreadContext(getState()); + if (!wasStepping && !wasPausedInEval && cx.thread == thread) { + await dispatch(evaluateExpressions(cx)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/selectFrame.js b/devtools/client/debugger/src/actions/pause/selectFrame.js new file mode 100644 index 0000000000..f97be42787 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/selectFrame.js @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { selectLocation } from "../sources"; +import { evaluateExpressions } from "../expressions"; +import { fetchScopes } from "./fetchScopes"; +import assert from "../../utils/assert"; + +/** + * @memberof actions/pause + * @static + */ +export function selectFrame(cx, frame) { + return async ({ dispatch, getState }) => { + assert(cx.thread == frame.thread, "Thread mismatch"); + + // Frames that aren't on-stack do not support evalling and may not + // have live inspectable scopes, so we do not allow selecting them. + if (frame.state !== "on-stack") { + dispatch(selectLocation(cx, frame.location)); + return; + } + + dispatch({ + type: "SELECT_FRAME", + cx, + thread: cx.thread, + frame, + }); + + // It's important that we wait for selectLocation to finish because + // we rely on the source being loaded and symbols fetched below. + await dispatch(selectLocation(cx, frame.location)); + + dispatch(evaluateExpressions(cx)); + dispatch(fetchScopes(cx)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/skipPausing.js b/devtools/client/debugger/src/actions/pause/skipPausing.js new file mode 100644 index 0000000000..1ecdf33b76 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/skipPausing.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSkipPausing } from "../../selectors"; + +/** + * @memberof actions/pause + * @static + */ +export function toggleSkipPausing() { + return async ({ dispatch, client, getState }) => { + const skipPausing = !getSkipPausing(getState()); + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} + +/** + * @memberof actions/pause + * @static + */ +export function setSkipPausing(skipPausing) { + return async ({ dispatch, client, getState }) => { + const currentlySkipping = getSkipPausing(getState()); + if (currentlySkipping === skipPausing) { + return; + } + + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap new file mode 100644 index 0000000000..55b8d3e724 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pauseOnExceptions should track telemetry for pauseOnException changes 1`] = ` +Array [ + Object { + "caught_exceptio": false, + "exceptions": true, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/pause/tests/pause.spec.js b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js new file mode 100644 index 0000000000..3a562ccfdd --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js @@ -0,0 +1,413 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + actions, + selectors, + createStore, + createSourceObject, + waitForState, + makeSource, + makeOriginalSource, + makeFrame, +} from "../../../utils/test-head"; + +import { makeWhyNormal } from "../../../utils/test-mockup"; +import { createLocation } from "../../../utils/location"; + +const { isStepping } = selectors; + +let stepInResolve = null; +const mockCommandClient = { + stepIn: () => + new Promise(_resolve => { + stepInResolve = _resolve; + }), + stepOver: () => new Promise(_resolve => _resolve), + evaluate: async () => {}, + evaluateExpressions: async () => [], + resume: async () => {}, + getFrameScopes: async frame => frame.scope, + getFrames: async () => [], + setBreakpoint: () => new Promise(_resolve => {}), + sourceContents: ({ source }) => { + return new Promise((resolve, reject) => { + switch (source) { + case "foo1": + return resolve({ + source: "function foo1() {\n return 5;\n}", + contentType: "text/javascript", + }); + case "await": + return resolve({ + source: "async function aWait() {\n await foo(); return 5;\n}", + contentType: "text/javascript", + }); + + case "foo": + return resolve({ + source: "function foo() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-original": + return resolve({ + source: "\n\nfunction fooOriginal() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-wasm": + return resolve({ + source: { binary: new ArrayBuffer(0) }, + contentType: "application/wasm", + }); + case "foo-wasm/originalSource": + return resolve({ + source: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }); + } + + return resolve(); + }); + }, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + actorID: "threadActorID", +}; + +const mockFrameId = "1"; + +function createPauseInfo( + frameLocation = createLocation({ + source: createSourceObject("foo1"), + line: 2, + }), + frameOpts = {} +) { + const frames = [ + makeFrame( + { id: mockFrameId, sourceId: frameLocation.sourceId }, + { + location: frameLocation, + generatedLocation: frameLocation, + ...frameOpts, + } + ), + ]; + return { + thread: "FakeThread", + frame: frames[0], + frames, + loadedObjects: [], + why: makeWhyNormal(), + }; +} + +describe("pause", () => { + describe("stepping", () => { + it("should set and clear the command", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + const cx = selectors.getThreadContext(getState()); + const stepped = dispatch(actions.stepIn(cx)); + expect(isStepping(getState(), "FakeThread")).toBeTruthy(); + if (!stepInResolve) { + throw new Error("no stepInResolve"); + } + await stepInResolve(); + await stepped; + expect(isStepping(getState(), "FakeThread")).toBeFalsy(); + }); + + it("should only step when paused", async () => { + const client = { stepIn: jest.fn() }; + const { dispatch, cx } = createStore(client); + + dispatch(actions.stepIn(cx)); + expect(client.stepIn.mock.calls).toHaveLength(0); + }); + + it("should step when paused", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + const cx = selectors.getThreadContext(getState()); + dispatch(actions.stepIn(cx)); + expect(isStepping(getState(), "FakeThread")).toBeTruthy(); + }); + + it("getting frame scopes with bindings", async () => { + const client = { ...mockCommandClient }; + const store = createStore(client, {}); + const { dispatch, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const generatedLocation = createLocation({ + source, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation, { + scope: { + bindings: { + variables: { b: { value: {} } }, + arguments: [{ a: { value: {} } }], + }, + }, + }); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + await dispatch(actions.newOriginalSources([makeOriginalSource(source)])); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + id: mockFrameId, + generatedLocation, + location: generatedLocation, + originalDisplayName: "foo", + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + thread: "FakeThread", + }, + ]); + + expect(selectors.getFrameScopes(getState(), "FakeThread")).toEqual({ + generated: { + 1: { + pending: false, + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + }, + }, + mappings: { 1: undefined }, + original: { 1: { pending: false, scope: undefined } }, + }); + + expect( + selectors.getSelectedFrameBindings(getState(), "FakeThread") + ).toEqual(["b", "a"]); + }); + + it("maps frame locations and names to original source", async () => { + const sourceMapLoaderMock = { + getOriginalLocation: () => Promise.resolve(originalLocation), + getOriginalLocations: async items => items, + getOriginalSourceText: async () => ({ + text: "\n\nfunction fooOriginal() {\n return -5;\n}", + contentType: "text/javascript", + }), + getGeneratedLocation: async location => location, + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}, sourceMapLoaderMock); + const { dispatch, getState } = store; + + const originalSource = await dispatch( + actions.newGeneratedSource(makeSource("foo-original")) + ); + + const originalLocation = createLocation({ + source: originalSource, + line: 3, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + + const generatedSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const generatedLocation = createLocation({ + source: generatedSource, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + id: mockFrameId, + generatedLocation, + location: originalLocation, + originalDisplayName: "fooOriginal", + scope: { bindings: { arguments: [], variables: {} } }, + thread: "FakeThread", + }, + ]); + }); + + it("maps frame to original frames", async () => { + const sourceMapLoaderMock = { + getOriginalStackFrames: loc => Promise.resolve(originStackFrames), + getOriginalLocation: () => Promise.resolve(originalLocation), + getOriginalLocations: async items => items, + getOriginalSourceText: async () => ({ + text: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }), + getGeneratedRangesForOriginal: async () => [], + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}, sourceMapLoaderMock); + const { dispatch, getState } = store; + + const generatedSource = await dispatch( + actions.newGeneratedSource( + makeSource("foo-wasm", { introductionType: "wasm" }) + ) + ); + + const generatedLocation = createLocation({ + source: generatedSource, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation); + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + const [originalSource] = await dispatch( + actions.newOriginalSources([makeOriginalSource(generatedSource)]) + ); + + const originalLocation = createLocation({ + source: originalSource, + line: 1, + column: 1, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + const originalLocation2 = createLocation({ + source: originalSource, + line: 2, + column: 14, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + + const originStackFrames = [ + { + displayName: "fooBar", + thread: "FakeThread", + }, + { + displayName: "barZoo", + location: originalLocation2, + thread: "FakeThread", + }, + ]; + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + asyncCause: undefined, + displayName: "fooBar", + generatedLocation, + id: "1", + index: undefined, + isOriginal: true, + location: originalLocation, + originalDisplayName: "fooBar", + originalVariables: undefined, + scope: { bindings: { arguments: [], variables: {} } }, + source: null, + state: undefined, + this: undefined, + thread: "FakeThread", + }, + { + asyncCause: undefined, + displayName: "barZoo", + generatedLocation, + id: "1-originalFrame1", + index: undefined, + isOriginal: true, + location: originalLocation2, + originalDisplayName: "barZoo", + originalVariables: undefined, + scope: { bindings: { arguments: [], variables: {} } }, + source: null, + state: undefined, + this: undefined, + thread: "FakeThread", + }, + ]); + }); + }); + + describe("resumed", () => { + it("should not evaluate expression while stepping", async () => { + const client = { ...mockCommandClient, evaluateExpressions: jest.fn() }; + const { dispatch, getState } = createStore(client); + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.paused(mockPauseInfo)); + + const cx = selectors.getThreadContext(getState()); + dispatch(actions.stepIn(cx)); + await dispatch(actions.resumed(mockCommandClient.actorID)); + expect(client.evaluateExpressions.mock.calls).toHaveLength(1); + }); + + it("resuming - will re-evaluate watch expressions", async () => { + const client = { ...mockCommandClient, evaluateExpressions: jest.fn() }; + const store = createStore(client); + const { dispatch, getState, cx } = store; + const mockPauseInfo = createPauseInfo(); + + await dispatch(actions.newGeneratedSource(makeSource("foo1"))); + await dispatch(actions.newGeneratedSource(makeSource("foo"))); + await dispatch(actions.addExpression(cx, "foo")); + await waitForState(store, state => selectors.getExpression(state, "foo")); + + client.evaluateExpressions.mockReturnValue(Promise.resolve(["YAY"])); + await dispatch(actions.paused(mockPauseInfo)); + + await dispatch(actions.resumed(mockCommandClient.actorID)); + const expression = selectors.getExpression(getState(), "foo"); + expect(expression && expression.value).toEqual("YAY"); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js new file mode 100644 index 0000000000..bc8d000697 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + actions, + createStore, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, +} from "../../../selectors/pause"; + +describe("pauseOnExceptions", () => { + it("should track telemetry for pauseOnException changes", async () => { + const { dispatch, getState } = createStore({ pauseOnExceptions: () => {} }); + dispatch(actions.pauseOnExceptions(true, false)); + expect(getTelemetryEvents("pause_on_exceptions")).toMatchSnapshot(); + expect(getShouldPauseOnExceptions(getState())).toBe(true); + expect(getShouldPauseOnCaughtExceptions(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js new file mode 100644 index 0000000000..83006c3089 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { actions, selectors, createStore } from "../../../utils/test-head"; + +describe("sources - pretty print", () => { + it("returns a pretty source for a minified file", async () => { + const client = { setSkipPausing: jest.fn() }; + const { dispatch, getState } = createStore(client); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(true); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/preview.js b/devtools/client/debugger/src/actions/preview.js new file mode 100644 index 0000000000..992737e2d1 --- /dev/null +++ b/devtools/client/debugger/src/actions/preview.js @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { isConsole } from "../utils/preview"; +import { findBestMatchExpression } from "../utils/ast"; +import { getGrip, getFront } from "../utils/evaluation-result"; +import { getExpressionFromCoords } from "../utils/editor/get-expression"; +import { isNodeTest } from "../utils/environment"; + +import { + getPreview, + isLineInScope, + isSelectedFrameVisible, + getSelectedSource, + getSelectedLocation, + getSelectedFrame, + getSymbols, + getCurrentThread, + getPreviewCount, + getSelectedException, +} from "../selectors"; + +import { getMappedExpression } from "./expressions"; + +function findExpressionMatch(state, codeMirror, tokenPos) { + const location = getSelectedLocation(state); + if (!location) { + return null; + } + + const symbols = getSymbols(state, location); + + let match; + if (!symbols) { + match = getExpressionFromCoords(codeMirror, tokenPos); + } else { + match = findBestMatchExpression(symbols, tokenPos); + } + return match; +} + +export function updatePreview(cx, target, tokenPos, codeMirror) { + return ({ dispatch, getState }) => { + const cursorPos = target.getBoundingClientRect(); + + if ( + !isSelectedFrameVisible(getState()) || + !isLineInScope(getState(), tokenPos.line) + ) { + return; + } + + const match = findExpressionMatch(getState(), codeMirror, tokenPos); + if (!match) { + return; + } + + const { expression, location } = match; + + if (isConsole(expression)) { + return; + } + + dispatch(setPreview(cx, expression, location, tokenPos, cursorPos, target)); + }; +} + +export function setPreview( + cx, + expression, + location, + tokenPos, + cursorPos, + target +) { + return async ({ dispatch, getState, client }) => { + dispatch({ type: "START_PREVIEW" }); + const previewCount = getPreviewCount(getState()); + if (getPreview(getState())) { + dispatch(clearPreview(cx)); + } + + const source = getSelectedSource(getState()); + if (!source) { + return; + } + + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + + if (location && source.isOriginal) { + const mapResult = await dispatch(getMappedExpression(expression)); + if (mapResult) { + expression = mapResult.expression; + } + } + + if (!selectedFrame) { + return; + } + + const { result } = await client.evaluate(expression, { + frameId: selectedFrame.id, + }); + + const resultGrip = getGrip(result); + + // Error case occurs for a token that follows an errored evaluation + // https://github.com/firefox-devtools/debugger/pull/8056 + // Accommodating for null allows us to show preview for falsy values + // line "", false, null, Nan, and more + if (resultGrip === null) { + return; + } + + // Handle cases where the result is invisible to the debugger + // and not possible to preview. Bug 1548256 + if ( + resultGrip && + resultGrip.class && + typeof resultGrip.class === "string" && + resultGrip.class.includes("InvisibleToDebugger") + ) { + return; + } + + const root = { + path: expression, + contents: { + value: resultGrip, + front: getFront(result), + }, + }; + const properties = await client.loadObjectProperties(root, thread); + + // The first time a popup is rendered, the mouse should be hovered + // on the token. If it happens to be hovered on whitespace, it should + // not render anything + if (!target.matches(":hover") && !isNodeTest()) { + return; + } + + // Don't finish dispatching if another setPreview was started + if (previewCount != getPreviewCount(getState())) { + return; + } + + dispatch({ + type: "SET_PREVIEW", + cx, + value: { + expression, + resultGrip, + properties, + root, + location, + tokenPos, + cursorPos, + target, + }, + }); + }; +} + +export function clearPreview(cx) { + return ({ dispatch, getState, client }) => { + const currentSelection = getPreview(getState()); + if (!currentSelection) { + return null; + } + + return dispatch({ + type: "CLEAR_PREVIEW", + cx, + }); + }; +} + +export function setExceptionPreview(cx, target, tokenPos, codeMirror) { + return async ({ dispatch, getState }) => { + const cursorPos = target.getBoundingClientRect(); + + const match = findExpressionMatch(getState(), codeMirror, tokenPos); + if (!match) { + return; + } + + const tokenColumnStart = match.location.start.column + 1; + const exception = getSelectedException( + getState(), + tokenPos.line, + tokenColumnStart + ); + if (!exception) { + return; + } + + dispatch({ + type: "SET_PREVIEW", + cx, + value: { + exception, + location: match.location, + tokenPos, + cursorPos, + target, + }, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/project-text-search.js b/devtools/client/debugger/src/actions/project-text-search.js new file mode 100644 index 0000000000..26ea0df107 --- /dev/null +++ b/devtools/client/debugger/src/actions/project-text-search.js @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the search state + * @module actions/search + */ + +import { isFulfilled } from "../utils/async-value"; +import { + getFirstSourceActorForGeneratedSource, + getSourceList, + getSettledSourceTextContent, + isSourceBlackBoxed, + getSearchOptions, +} from "../selectors"; +import { createLocation } from "../utils/location"; +import { matchesGlobPatterns } from "../utils/source"; +import { loadSourceText } from "./sources/loadSourceText"; +import { + getProjectSearchOperation, + getProjectSearchStatus, +} from "../selectors/project-text-search"; +import { statusType } from "../reducers/project-text-search"; +import { searchKeys } from "../constants"; + +export function addSearchQuery(cx, query) { + return { type: "ADD_QUERY", cx, query }; +} + +export function addOngoingSearch(cx, ongoingSearch) { + return { type: "ADD_ONGOING_SEARCH", cx, ongoingSearch }; +} + +export function addSearchResult(cx, location, matches) { + return { + type: "ADD_SEARCH_RESULT", + cx, + location, + matches, + }; +} + +export function clearSearchResults(cx) { + return { type: "CLEAR_SEARCH_RESULTS", cx }; +} + +export function clearSearch(cx) { + return { type: "CLEAR_SEARCH", cx }; +} + +export function updateSearchStatus(cx, status) { + return { type: "UPDATE_STATUS", cx, status }; +} + +export function closeProjectSearch(cx) { + return ({ dispatch, getState }) => { + dispatch(stopOngoingSearch(cx)); + dispatch({ type: "CLOSE_PROJECT_SEARCH" }); + }; +} + +export function stopOngoingSearch(cx) { + return ({ dispatch, getState }) => { + const state = getState(); + const ongoingSearch = getProjectSearchOperation(state); + const status = getProjectSearchStatus(state); + if (ongoingSearch && status !== statusType.done) { + ongoingSearch.cancel(); + dispatch(updateSearchStatus(cx, statusType.cancelled)); + } + }; +} + +export function searchSources(cx, query) { + let cancelled = false; + + const search = async ({ dispatch, getState }) => { + dispatch(stopOngoingSearch(cx)); + await dispatch(addOngoingSearch(cx, search)); + await dispatch(clearSearchResults(cx)); + await dispatch(addSearchQuery(cx, query)); + dispatch(updateSearchStatus(cx, statusType.fetching)); + const searchOptions = getSearchOptions( + getState(), + searchKeys.PROJECT_SEARCH + ); + const validSources = getSourceList(getState()).filter( + source => + !isSourceBlackBoxed(getState(), source) && + !matchesGlobPatterns(source, searchOptions.excludePatterns) + ); + // Sort original entries first so that search results are more useful. + // Deprioritize third-party scripts, so their results show last. + validSources.sort((a, b) => { + function isThirdParty(source) { + return ( + source?.url && + (source.url.includes("node_modules") || + source.url.includes("bower_components")) + ); + } + + if (a.isOriginal && !isThirdParty(a)) { + return -1; + } + + if (b.isOriginal && !isThirdParty(b)) { + return 1; + } + + if (!isThirdParty(a) && isThirdParty(b)) { + return -1; + } + if (isThirdParty(a) && !isThirdParty(b)) { + return 1; + } + return 0; + }); + + for (const source of validSources) { + if (cancelled) { + return; + } + + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(loadSourceText(cx, source, sourceActor)); + await dispatch(searchSource(cx, source, sourceActor, query)); + } + dispatch(updateSearchStatus(cx, statusType.done)); + }; + + search.cancel = () => { + cancelled = true; + }; + + return search; +} + +export function searchSource(cx, source, sourceActor, query) { + return async ({ dispatch, getState, searchWorker }) => { + if (!source) { + return; + } + const state = getState(); + const location = createLocation({ + source, + sourceActor, + }); + + const options = getSearchOptions(state, searchKeys.PROJECT_SEARCH); + const content = getSettledSourceTextContent(state, location); + let matches = []; + + if (content && isFulfilled(content) && content.value.type === "text") { + matches = await searchWorker.findSourceMatches( + content.value, + query, + options + ); + } + if (!matches.length) { + return; + } + dispatch(addSearchResult(cx, location, matches)); + }; +} diff --git a/devtools/client/debugger/src/actions/quick-open.js b/devtools/client/debugger/src/actions/quick-open.js new file mode 100644 index 0000000000..e5f5352292 --- /dev/null +++ b/devtools/client/debugger/src/actions/quick-open.js @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function setQuickOpenQuery(query) { + return { + type: "SET_QUICK_OPEN_QUERY", + query, + }; +} + +export function openQuickOpen(query) { + if (query != null) { + return { type: "OPEN_QUICK_OPEN", query }; + } + return { type: "OPEN_QUICK_OPEN" }; +} + +export function closeQuickOpen() { + return { type: "CLOSE_QUICK_OPEN" }; +} diff --git a/devtools/client/debugger/src/actions/source-actors.js b/devtools/client/debugger/src/actions/source-actors.js new file mode 100644 index 0000000000..9782e493b3 --- /dev/null +++ b/devtools/client/debugger/src/actions/source-actors.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/>. */ + +export function insertSourceActors(sourceActors) { + return function ({ dispatch }) { + dispatch({ + type: "INSERT_SOURCE_ACTORS", + sourceActors, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources-tree.js b/devtools/client/debugger/src/actions/sources-tree.js new file mode 100644 index 0000000000..ae750a3df7 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources-tree.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function setExpandedState(expanded) { + return { type: "SET_EXPANDED_STATE", expanded }; +} + +export function focusItem(item) { + return { type: "SET_FOCUSED_SOURCE_ITEM", item }; +} diff --git a/devtools/client/debugger/src/actions/sources/blackbox.js b/devtools/client/debugger/src/actions/sources/blackbox.js new file mode 100644 index 0000000000..6821a0e140 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/blackbox.js @@ -0,0 +1,223 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { + isOriginalId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; +import { recordEvent } from "../../utils/telemetry"; +import { toggleBreakpoints } from "../breakpoints"; +import { + getSourceActorsForSource, + isSourceBlackBoxed, + getBlackBoxRanges, + getBreakpointsForSource, +} from "../../selectors"; + +export async function blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges = [] +) { + const { getState, client, sourceMapLoader } = thunkArgs; + let sourceId = source.id; + // If the source is the original, then get the source id of its generated file + // and the range for where the original is represented in the generated file + // (which might be a bundle including other files). + if (isOriginalId(source.id)) { + sourceId = originalToGeneratedId(source.id); + const range = await sourceMapLoader.getFileGeneratedRange(source.id); + ranges = []; + if (range) { + ranges.push(range); + // TODO bug 1752108: Investigate blackboxing lines in original files, + // there is likely to be issues as the whole genrated file + // representing the original file will always be blackboxed. + console.warn( + "The might be unxpected issues when ignoring lines in an original file. " + + "The whole original source is being blackboxed." + ); + } else { + throw new Error( + `Unable to retrieve generated ranges for original source ${source.url}` + ); + } + } + + for (const actor of getSourceActorsForSource(getState(), sourceId)) { + await client.blackBox(actor, shouldBlackBox, ranges); + } +} + +/** + * Toggle blackboxing for the whole source or for specific lines in a source + * + * @param {Object} cx + * @param {Object} source - The source to be blackboxed/unblackboxed. + * @param {Boolean} [shouldBlackBox] - Specifies if the source should be blackboxed (true + * or unblackboxed (false). When this is not provided + * option is decided based on the blackboxed state + * of the source. + * @param {Array} [ranges] - List of line/column offsets to blackbox, these + * are provided only when blackboxing lines. + * The range structure: + * const range = { + * start: { line: 1, column: 5 }, + * end: { line: 3, column: 4 }, + * } + */ +export function toggleBlackBox(cx, source, shouldBlackBox, ranges = []) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + shouldBlackBox = + typeof shouldBlackBox == "boolean" + ? shouldBlackBox + : !isSourceBlackBoxed(getState(), source); + + await blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges + ); + + if (shouldBlackBox) { + recordEvent("blackbox"); + // If ranges is an empty array, it would mean we are blackboxing the whole + // source. To do that lets reset the content to an empty array. + if (!ranges.length) { + dispatch({ type: "BLACKBOX_WHOLE_SOURCES", sources: [source] }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: true, + sources: [source], + }); + } else { + const currentRanges = getBlackBoxRanges(getState())[source.url] || []; + ranges = ranges.filter(newRange => { + // To avoid adding duplicate ranges make sure + // no range already exists with same start and end lines. + const duplicate = currentRanges.findIndex( + r => + r.start.line == newRange.start.line && + r.end.line == newRange.end.line + ); + return duplicate == -1; + }); + dispatch({ type: "BLACKBOX_SOURCE_RANGES", source, ranges }); + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable: true, + source, + ranges, + }); + } + } else { + // if there are no ranges to blackbox, then we are unblackboxing + // the whole source + // eslint-disable-next-line no-lonely-if + if (!ranges.length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: false, + sources: [source], + }); + } else { + dispatch({ type: "UNBLACKBOX_SOURCE_RANGES", source, ranges }); + const blackboxRanges = getBlackBoxRanges(getState()); + if (!blackboxRanges[source.url].length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + } + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable: false, + source, + ranges, + }); + } + } + }; +} + +async function toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + cx, + shouldDisable, + source, + ranges, +}) { + const { dispatch, getState } = thunkArgs; + for (const range of ranges) { + const breakpoints = getBreakpointsForSource(getState(), source.id, range); + await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints)); + } +} + +async function toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable, + sources, +}) { + const { dispatch, getState } = thunkArgs; + for (const source of sources) { + const breakpoints = getBreakpointsForSource(getState(), source.id); + await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints)); + } +} + +/* + * Blackboxes a group of sources together + * + * @param {Object} cx + * @param {Array} sourcesToBlackBox - The list of sources to blackbox + * @param {Boolean} shouldBlackbox - Specifies if the sources should blackboxed (true) + * or unblackboxed (false). + */ +export function blackBoxSources(cx, sourcesToBlackBox, shouldBlackBox) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + const sources = sourcesToBlackBox.filter( + source => isSourceBlackBoxed(getState(), source) !== shouldBlackBox + ); + + if (!sources.length) { + return; + } + + for (const source of sources) { + await blackboxSourceActorsForSource(thunkArgs, source, shouldBlackBox); + } + + if (shouldBlackBox) { + recordEvent("blackbox"); + } + + dispatch({ + type: shouldBlackBox + ? "BLACKBOX_WHOLE_SOURCES" + : "UNBLACKBOX_WHOLE_SOURCES", + sources, + }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + cx, + shouldDisable: shouldBlackBox, + sources, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/breakableLines.js b/devtools/client/debugger/src/actions/sources/breakableLines.js new file mode 100644 index 0000000000..d028d480c0 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/breakableLines.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 { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +import { + getBreakableLines, + getSourceActorBreakableLines, +} from "../../selectors"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +function calculateBreakableLines(positions) { + const lines = []; + for (const line in positions) { + if (positions[line].length) { + lines.push(Number(line)); + } + } + + return lines; +} + +/** + * Ensure that breakable lines for a given source are fetched. + * + * @param Object cx + * @param Object location + */ +export function setBreakableLines(cx, location) { + return async ({ getState, dispatch, client }) => { + let breakableLines; + if (isOriginalId(location.source.id)) { + const positions = await dispatch( + setBreakpointPositions({ cx, location }) + ); + breakableLines = calculateBreakableLines(positions); + + const existingBreakableLines = getBreakableLines( + getState(), + location.source.id + ); + if (existingBreakableLines) { + breakableLines = [ + ...new Set([...existingBreakableLines, ...breakableLines]), + ]; + } + + dispatch({ + type: "SET_ORIGINAL_BREAKABLE_LINES", + cx, + sourceId: location.source.id, + breakableLines, + }); + } else { + // Ignore re-fetching the breakable lines for source actor we already fetched + breakableLines = getSourceActorBreakableLines( + getState(), + location.sourceActor.id + ); + if (breakableLines) { + return; + } + breakableLines = await client.getSourceActorBreakableLines( + location.sourceActor + ); + dispatch({ + type: "SET_SOURCE_ACTOR_BREAKABLE_LINES", + sourceActorId: location.sourceActor.id, + breakableLines, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/sources/index.js b/devtools/client/debugger/src/actions/sources/index.js new file mode 100644 index 0000000000..813f50262b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/index.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./blackbox"; +export * from "./breakableLines"; +export * from "./loadSourceText"; +export * from "./newSources"; +export * from "./prettyPrint"; +export * from "./select"; +export { setSymbols } from "./symbols"; + +export function setOverrideSource(cx, source, path) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.setOverride(url, path); + dispatch({ + type: "SET_OVERRIDE", + cx, + url, + path, + }); + }; +} + +export function removeOverrideSource(cx, source) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.removeOverride(url); + dispatch({ + type: "REMOVE_OVERRIDE", + cx, + url, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/loadSourceText.js b/devtools/client/debugger/src/actions/sources/loadSourceText.js new file mode 100644 index 0000000000..8210b07a97 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { PROMISE } from "../utils/middleware/promise"; +import { + getSourceTextContent, + getSettledSourceTextContent, + getGeneratedSource, + getSourcesEpoch, + getBreakpointsForSource, + getSourceActorsForSource, + getFirstSourceActorForGeneratedSource, +} from "../../selectors"; +import { addBreakpoint } from "../breakpoints"; + +import { prettyPrintSource } from "./prettyPrint"; +import { isFulfilled, fulfilled } from "../../utils/async-value"; + +import { isPretty } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { memoizeableAction } from "../../utils/memoizableAction"; + +async function loadGeneratedSource(sourceActor, { client }) { + // If no source actor can be found then the text for the + // source cannot be loaded. + if (!sourceActor) { + throw new Error("Source actor is null or not defined"); + } + + let response; + try { + response = await client.sourceContents(sourceActor); + } catch (e) { + throw new Error(`sourceContents failed: ${e}`); + } + + return { + text: response.source, + contentType: response.contentType || "text/javascript", + }; +} + +async function loadOriginalSource( + source, + { getState, client, sourceMapLoader, prettyPrintWorker } +) { + if (isPretty(source)) { + const generatedSource = getGeneratedSource(getState(), source); + if (!generatedSource) { + throw new Error("Unable to find minified original."); + } + + const content = getSettledSourceTextContent( + getState(), + createLocation({ + source: generatedSource, + }) + ); + + return prettyPrintSource( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + getSourceActorsForSource(getState(), generatedSource.id) + ); + } + + const result = await sourceMapLoader.getOriginalSourceText(source.id); + if (!result) { + // The way we currently try to load and select a pending + // selected location, it is possible that we will try to fetch the + // original source text right after the source map has been cleared + // after a navigation event. + throw new Error("Original source text unavailable"); + } + return result; +} + +async function loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + + await dispatch({ + type: "LOAD_GENERATED_SOURCE_TEXT", + sourceActorId: sourceActor.actor, + epoch, + [PROMISE]: loadGeneratedSource(sourceActor, thunkArgs), + }); + + await onSourceTextContentAvailable( + cx, + sourceActor.sourceObject, + sourceActor, + thunkArgs + ); +} + +async function loadOriginalSourceTextPromise(cx, source, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + await dispatch({ + type: "LOAD_ORIGINAL_SOURCE_TEXT", + sourceId: source.id, + epoch, + [PROMISE]: loadOriginalSource(source, thunkArgs), + }); + + await onSourceTextContentAvailable(cx, source, null, thunkArgs); +} + +/** + * Function called everytime a new original or generated source gets its text content + * fetched from the server and registered in the reducer. + * + * @param {Object} cx + * @param {Object} source + * @param {Object} sourceActor (optional) + * If this is a generated source, we expect a precise source actor. + * @param {Object} thunkArgs + */ +async function onSourceTextContentAvailable( + cx, + source, + sourceActor, + { dispatch, getState, parserWorker } +) { + const location = createLocation({ + source, + sourceActor, + }); + const content = getSettledSourceTextContent(getState(), location); + if (!content) { + return; + } + + if (parserWorker.isLocationSupported(location)) { + parserWorker.setSource( + source.id, + isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined } + ); + } + + // Update the text in any breakpoints for this source by re-adding them. + const breakpoints = getBreakpointsForSource(getState(), source.id); + for (const breakpoint of breakpoints) { + await dispatch( + addBreakpoint( + cx, + breakpoint.location, + breakpoint.options, + breakpoint.disabled + ) + ); + } +} + +/** + * Loads the source text for the generated source based of the source actor + * @param {Object} sourceActor + * There can be more than one source actor per source + * so the source actor needs to be specified. This is + * required for generated sources but will be null for + * original/pretty printed sources. + */ +export const loadGeneratedSourceText = memoizeableAction( + "loadGeneratedSourceText", + { + getValue: ({ sourceActor }, { getState }) => { + if (!sourceActor) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source: sourceActor.sourceObject, + sourceActor, + }) + ); + + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: ({ sourceActor }, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${sourceActor.actor}`; + }, + action: ({ cx, sourceActor }, thunkArgs) => + loadGeneratedSourceTextPromise(cx, sourceActor, thunkArgs), + } +); + +/** + * Loads the source text for an original source and source actor + * @param {Object} source + * The original source to load the source text + */ +export const loadOriginalSourceText = memoizeableAction( + "loadOriginalSourceText", + { + getValue: ({ source }, { getState }) => { + if (!source) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source, + }) + ); + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: ({ source }, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${source.id}`; + }, + action: ({ cx, source }, thunkArgs) => + loadOriginalSourceTextPromise(cx, source, thunkArgs), + } +); + +export function loadSourceText(cx, source, sourceActor) { + return async ({ dispatch, getState }) => { + if (!source) { + return null; + } + if (source.isOriginal) { + return dispatch(loadOriginalSourceText({ cx, source })); + } + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + } + return dispatch(loadGeneratedSourceText({ cx, sourceActor })); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/moz.build b/devtools/client/debugger/src/actions/sources/moz.build new file mode 100644 index 0000000000..9972e9f09b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "blackbox.js", + "breakableLines.js", + "index.js", + "loadSourceText.js", + "newSources.js", + "prettyPrint.js", + "select.js", + "symbols.js", +) diff --git a/devtools/client/debugger/src/actions/sources/newSources.js b/devtools/client/debugger/src/actions/sources/newSources.js new file mode 100644 index 0000000000..1e95c6d79d --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/newSources.js @@ -0,0 +1,367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ +import { PROMISE } from "../utils/middleware/promise"; +import { insertSourceActors } from "../../actions/source-actors"; +import { + makeSourceId, + createGeneratedSource, + createSourceMapOriginalSource, + createSourceActor, +} from "../../client/firefox/create"; +import { toggleBlackBox } from "./blackbox"; +import { syncPendingBreakpoint } from "../breakpoints"; +import { loadSourceText } from "./loadSourceText"; +import { togglePrettyPrint } from "./prettyPrint"; +import { toggleSourceMapIgnoreList } from "../ui"; +import { selectLocation, setBreakableLines } from "../sources"; + +import { getRawSourceURL, isPrettyURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { + getBlackBoxRanges, + getSource, + getSourceFromId, + hasSourceActor, + getSourceByActorId, + getPendingSelectedLocation, + getPendingBreakpointsForSource, + getContext, +} from "../../selectors"; + +import { prefs } from "../../utils/prefs"; +import sourceQueue from "../../utils/source-queue"; +import { validateNavigateContext, ContextError } from "../../utils/context"; + +function loadSourceMaps(cx, sources) { + return async function ({ dispatch }) { + try { + const sourceList = await Promise.all( + sources.map(async sourceActor => { + const originalSourcesInfo = await dispatch( + loadSourceMap(cx, sourceActor) + ); + originalSourcesInfo.forEach( + sourcesInfo => (sourcesInfo.sourceActor = sourceActor) + ); + sourceQueue.queueOriginalSources(originalSourcesInfo); + return originalSourcesInfo; + }) + ); + + await sourceQueue.flush(); + return sourceList.flat(); + } catch (error) { + if (!(error instanceof ContextError)) { + throw error; + } + } + return []; + }; +} + +/** + * @memberof actions/sources + * @static + */ +function loadSourceMap(cx, sourceActor) { + return async function ({ dispatch, getState, sourceMapLoader }) { + if (!prefs.clientSourceMapsEnabled || !sourceActor.sourceMapURL) { + return []; + } + + let data = null; + try { + // Ignore sourceMapURL on scripts that are part of HTML files, since + // we currently treat sourcemaps as Source-wide, not SourceActor-specific. + const source = getSourceByActorId(getState(), sourceActor.id); + if (source) { + data = await sourceMapLoader.getOriginalURLs({ + // Using source ID here is historical and eventually we'll want to + // switch to all of this being per-source-actor. + id: source.id, + url: sourceActor.url || "", + sourceMapBaseURL: sourceActor.sourceMapBaseURL || "", + sourceMapURL: sourceActor.sourceMapURL || "", + isWasm: sourceActor.introductionType === "wasm", + }); + dispatch({ + type: "ADD_SOURCEMAP_IGNORE_LIST_SOURCES", + [PROMISE]: sourceMapLoader.getSourceMapIgnoreList(source.id), + }); + } + } catch (e) { + console.error(e); + } + + if (!data || !data.length) { + // If this source doesn't have a sourcemap or there are no original files + // existing, enable it for pretty printing + dispatch({ + type: "CLEAR_SOURCE_ACTOR_MAP_URL", + cx, + sourceActorId: sourceActor.id, + }); + return []; + } + + validateNavigateContext(getState(), cx); + return data; + }; +} + +// If a request has been made to show this source, go ahead and +// select it. +function checkSelectedSource(cx, sourceId) { + return async ({ dispatch, getState }) => { + const state = getState(); + const pendingLocation = getPendingSelectedLocation(state); + + if (!pendingLocation || !pendingLocation.url) { + return; + } + + const source = getSource(state, sourceId); + + if (!source || !source.url) { + return; + } + + const pendingUrl = pendingLocation.url; + const rawPendingUrl = getRawSourceURL(pendingUrl); + + if (rawPendingUrl === source.url) { + if (isPrettyURL(pendingUrl)) { + const prettySource = await dispatch(togglePrettyPrint(cx, source.id)); + dispatch(checkPendingBreakpoints(cx, prettySource, null)); + return; + } + + await dispatch( + selectLocation( + cx, + createLocation({ + source, + line: + typeof pendingLocation.line === "number" + ? pendingLocation.line + : 0, + column: pendingLocation.column, + }) + ) + ); + } + }; +} + +function checkPendingBreakpoints(cx, source, sourceActor) { + return async ({ dispatch, getState }) => { + const pendingBreakpoints = getPendingBreakpointsForSource( + getState(), + source + ); + + if (pendingBreakpoints.length === 0) { + return; + } + + // load the source text if there is a pending breakpoint for it + await dispatch(loadSourceText(cx, source, sourceActor)); + await dispatch( + setBreakableLines(cx, createLocation({ source, sourceActor })) + ); + + await Promise.all( + pendingBreakpoints.map(pendingBp => { + return dispatch(syncPendingBreakpoint(cx, source.id, pendingBp)); + }) + ); + }; +} + +function restoreBlackBoxedSources(cx, sources) { + return async ({ dispatch, getState }) => { + const currentRanges = getBlackBoxRanges(getState()); + + if (!Object.keys(currentRanges).length) { + return; + } + + for (const source of sources) { + const ranges = currentRanges[source.url]; + if (ranges) { + // If the ranges is an empty then the whole source was blackboxed. + await dispatch(toggleBlackBox(cx, source, true, ranges)); + } + } + + if (prefs.sourceMapIgnoreListEnabled) { + await dispatch(toggleSourceMapIgnoreList(cx, true)); + } + }; +} + +export function newOriginalSources(originalSourcesInfo) { + return async ({ dispatch, getState }) => { + const state = getState(); + const seen = new Set(); + + const actors = []; + const actorsSources = {}; + + for (const { id, url, sourceActor } of originalSourcesInfo) { + if (seen.has(id) || getSource(state, id)) { + continue; + } + seen.add(id); + + if (!actorsSources[sourceActor.actor]) { + actors.push(sourceActor); + actorsSources[sourceActor.actor] = []; + } + + actorsSources[sourceActor.actor].push( + createSourceMapOriginalSource(id, url) + ); + } + + const cx = getContext(state); + + // Add the original sources per the generated source actors that + // they are primarily from. + actors.forEach(sourceActor => { + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + cx, + originalSources: actorsSources[sourceActor.actor], + generatedSourceActor: sourceActor, + }); + }); + + // Accumulate the sources back into one list + const actorsSourcesValues = Object.values(actorsSources); + let sources = []; + if (actorsSourcesValues.length) { + sources = actorsSourcesValues.reduce((acc, sourceList) => + acc.concat(sourceList) + ); + } + + await dispatch(checkNewSources(cx, sources)); + + for (const source of sources) { + dispatch(checkPendingBreakpoints(cx, source, null)); + } + + return sources; + }; +} + +// Wrapper around newGeneratedSources, only used by tests +export function newGeneratedSource(sourceInfo) { + return async ({ dispatch }) => { + const sources = await dispatch(newGeneratedSources([sourceInfo])); + return sources[0]; + }; +} + +export function newGeneratedSources(sourceResources) { + return async ({ dispatch, getState, client }) => { + if (!sourceResources.length) { + return []; + } + + const resultIds = []; + const newSourcesObj = {}; + const newSourceActors = []; + + for (const sourceResource of sourceResources) { + // By the time we process the sources, the related target + // might already have been destroyed. It means that the sources + // are also about to be destroyed, so ignore them. + // (This is covered by browser_toolbox_backward_forward_navigation.js) + if (sourceResource.targetFront.isDestroyed()) { + continue; + } + const id = makeSourceId(sourceResource); + + if (!getSource(getState(), id) && !newSourcesObj[id]) { + newSourcesObj[id] = createGeneratedSource(sourceResource); + } + + const actorId = sourceResource.actor; + + // We are sometimes notified about a new source multiple times if we + // request a new source list and also get a source event from the server. + if (!hasSourceActor(getState(), actorId)) { + newSourceActors.push( + createSourceActor( + sourceResource, + getSource(getState(), id) || newSourcesObj[id] + ) + ); + } + + resultIds.push(id); + } + + const newSources = Object.values(newSourcesObj); + + const cx = getContext(getState()); + dispatch(addSources(cx, newSources)); + dispatch(insertSourceActors(newSourceActors)); + + await dispatch(checkNewSources(cx, newSources)); + + (async () => { + await dispatch(loadSourceMaps(cx, newSourceActors)); + + // We would like to sync breakpoints after we are done + // loading source maps as sometimes generated and original + // files share the same paths. + for (const sourceActor of newSourceActors) { + // For HTML pages, we fetch all new incoming inline script, + // which will be related to one dedicated source actor. + // Whereas, for regular sources, if we have many source actors, + // this is for the same URL. And code expecting to have breakable lines + // will request breakable lines for that particular source actor. + if (sourceActor.sourceObject.isHTML) { + await dispatch( + setBreakableLines( + cx, + createLocation({ source: sourceActor.sourceObject, sourceActor }) + ) + ); + } + dispatch( + checkPendingBreakpoints(cx, sourceActor.sourceObject, sourceActor) + ); + } + })(); + + return resultIds.map(id => getSourceFromId(getState(), id)); + }; +} + +function addSources(cx, sources) { + return ({ dispatch, getState }) => { + dispatch({ type: "ADD_SOURCES", cx, sources }); + }; +} + +function checkNewSources(cx, sources) { + return async ({ dispatch, getState }) => { + for (const source of sources) { + dispatch(checkSelectedSource(cx, source.id)); + } + + await dispatch(restoreBlackBoxedSources(cx, sources)); + + return sources; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/prettyPrint.js b/devtools/client/debugger/src/actions/sources/prettyPrint.js new file mode 100644 index 0000000000..66e3f4129b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js @@ -0,0 +1,339 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + generatedToOriginalId, + originalToGeneratedId, +} from "devtools/client/shared/source-map-loader/index"; + +import assert from "../../utils/assert"; +import { recordEvent } from "../../utils/telemetry"; +import { updateBreakpointsForNewPrettyPrintedSource } from "../breakpoints"; +import { createLocation } from "../../utils/location"; + +import { + getPrettySourceURL, + isGenerated, + isJavaScript, +} from "../../utils/source"; +import { isFulfilled } from "../../utils/async-value"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { prefs } from "../../utils/prefs"; +import { + loadGeneratedSourceText, + loadOriginalSourceText, +} from "./loadSourceText"; +import { mapFrames } from "../pause"; +import { selectSpecificLocation } from "../sources"; +import { createPrettyPrintOriginalSource } from "../../client/firefox/create"; + +import { + getSource, + getFirstSourceActorForGeneratedSource, + getSourceByURL, + getSelectedLocation, + getThreadContext, +} from "../../selectors"; + +import { selectSource } from "./select"; + +import DevToolsUtils from "devtools/shared/DevToolsUtils"; + +const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g; +function matchAllLineBreaks(str) { + return Array.from(str.matchAll(LINE_BREAK_REGEX)); +} + +function getPrettyOriginalSourceURL(generatedSource) { + return getPrettySourceURL(generatedSource.url || generatedSource.id); +} + +export async function prettyPrintSource( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + actors +) { + if (!content || !isFulfilled(content)) { + throw new Error("Cannot pretty-print a file that has not loaded"); + } + + const contentValue = content.value; + if ( + (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) || + contentValue.type !== "text" + ) { + throw new Error( + `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.` + ); + } + + const url = getPrettyOriginalSourceURL(generatedSource); + + let prettyPrintWorkerResult; + if (generatedSource.isHTML) { + prettyPrintWorkerResult = await prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, + }); + } else { + prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({ + sourceText: contentValue.value, + indent: " ".repeat(prefs.indentSize), + url, + }); + } + + // The source map URL service used by other devtools listens to changes to + // sources based on their actor IDs, so apply the sourceMap there too. + const generatedSourceIds = [ + generatedSource.id, + ...actors.map(item => item.actor), + ]; + await sourceMapLoader.setSourceMapForGeneratedSources( + generatedSourceIds, + prettyPrintWorkerResult.sourceMap + ); + + return { + text: prettyPrintWorkerResult.code, + contentType: contentValue.contentType, + }; +} + +/** + * Pretty print inline script inside an HTML file + * + * @param {Object} options + * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker + * @param {Object} options.generatedSource: The HTML source we want to pretty print + * @param {Object} options.content + * @param {Array} options.actors: An array of the HTML file inline script sources data + * + * @returns Promise<Object> A promise that resolves with an object of the following shape: + * - {String} code: The prettified HTML text + * - {Object} sourceMap: The sourceMap object + */ +async function prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, +}) { + const url = getPrettyOriginalSourceURL(generatedSource); + const contentValue = content.value; + const htmlFileText = contentValue.value; + const prettyPrintWorkerResult = { code: htmlFileText }; + + const allLineBreaks = matchAllLineBreaks(htmlFileText); + let lineCountDelta = 0; + + // Sort inline script actors so they are in the same order as in the html document. + actors.sort((a, b) => { + if (a.sourceStartLine === b.sourceStartLine) { + return a.sourceStartColumn > b.sourceStartColumn; + } + return a.sourceStartLine > b.sourceStartLine; + }); + + const prettyPrintTaskId = generatedSource.id; + + // We don't want to replace part of the HTML document in the loop since it would require + // to account for modified lines for each iteration. + // Instead, we'll put each sections to replace in this array, where elements will be + // objects of the following shape: + // {Integer} startIndex: The start index in htmlFileText of the section we want to replace + // {Integer} endIndex: The end index in htmlFileText of the section we want to replace + // {String} prettyText: The pretty text we'll replace the original section with + // Once we iterated over all the inline scripts, we'll do the replacements (on the html + // file text) in reverse order, so we don't need have to care about the modified lines + // for each iteration. + const replacements = []; + + const seenLocations = new Set(); + + for (const sourceInfo of actors) { + // We can get duplicate source actors representing the same inline script which will + // cause trouble in the pretty printing here. This should be fixed on the server (see + // Bug 1824979), but in the meantime let's not handle the same location twice so the + // pretty printing is not impacted. + const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`; + if (!sourceInfo.sourceLength || seenLocations.has(location)) { + continue; + } + seenLocations.add(location); + // Here we want to get the index of the last line break before the script tag. + // In allLineBreaks, this would be the item at (script tag line - 1) + // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2) + const indexAfterPreviousLineBreakInHtml = + sourceInfo.sourceStartLine > 1 + ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1 + : 0; + const startIndex = + indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn; + const endIndex = startIndex + sourceInfo.sourceLength; + const scriptText = htmlFileText.substring(startIndex, endIndex); + DevToolsUtils.assert( + scriptText.length == sourceInfo.sourceLength, + "script text has expected length" + ); + + // Here we're going to pretty print each inline script content. + // Since we want to have a sourceMap that we'll apply to the whole HTML file, + // we'll only collect the sourceMap once we handled all inline scripts. + // `taskId` allows us to signal to the worker that all those calls are part of the + // same bigger file, and we'll use it later to get the sourceMap. + const prettyText = await prettyPrintWorker.prettyPrintInlineScript({ + taskId: prettyPrintTaskId, + sourceText: scriptText, + indent: " ".repeat(prefs.indentSize), + url, + originalStartLine: sourceInfo.sourceStartLine, + originalStartColumn: sourceInfo.sourceStartColumn, + // The generated line will be impacted by the previous inline scripts that were + // pretty printed, which is why we offset with lineCountDelta + generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta, + generatedStartColumn: sourceInfo.sourceStartColumn, + lineCountDelta, + }); + + // We need to keep track of the line added/removed in order to properly offset + // the mapping of the pretty-print text + lineCountDelta += + matchAllLineBreaks(prettyText).length - + matchAllLineBreaks(scriptText).length; + + replacements.push({ + startIndex, + endIndex, + prettyText, + }); + } + + // `getSourceMap` allow us to collect the computed source map resulting of the calls + // to `prettyPrint` with the same taskId. + prettyPrintWorkerResult.sourceMap = await prettyPrintWorker.getSourceMap( + prettyPrintTaskId + ); + + // Sort replacement in reverse order so we can replace code in the HTML file more easily + replacements.sort((a, b) => a.startIndex < b.startIndex); + for (const { startIndex, endIndex, prettyText } of replacements) { + prettyPrintWorkerResult.code = + prettyPrintWorkerResult.code.substring(0, startIndex) + + prettyText + + prettyPrintWorkerResult.code.substring(endIndex); + } + + return prettyPrintWorkerResult; +} + +function createPrettySource(cx, source) { + return async ({ dispatch, sourceMapLoader, getState }) => { + const url = getPrettyOriginalSourceURL(source); + const id = generatedToOriginalId(source.id, url); + const prettySource = createPrettyPrintOriginalSource(id, url); + + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + cx, + originalSources: [prettySource], + }); + return prettySource; + }; +} + +function selectPrettyLocation(cx, prettySource) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + let location = getSelectedLocation(getState()); + + // If we were selecting a particular line in the minified/generated source, + // try to select the matching line in the prettified/original source. + if ( + location && + location.line >= 1 && + location.sourceId == originalToGeneratedId(prettySource.id) + ) { + location = await getOriginalLocation(location, thunkArgs); + + return dispatch( + selectSpecificLocation( + cx, + createLocation({ ...location, source: prettySource }) + ) + ); + } + + return dispatch(selectSource(cx, prettySource)); + }; +} + +/** + * Toggle the pretty printing of a source's text. + * Nothing will happen for non-javascript files. + * + * @param Object cx + * @param String sourceId + * The source ID for the minified/generated source object. + * @returns Promise + * A promise that resolves to the Pretty print/original source object. + */ +export function togglePrettyPrint(cx, sourceId) { + return async ({ dispatch, getState }) => { + const source = getSource(getState(), sourceId); + if (!source) { + return {}; + } + + if (!source.isPrettyPrinted) { + recordEvent("pretty_print"); + } + + assert( + isGenerated(source), + "Pretty-printing only allowed on generated sources" + ); + + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(loadGeneratedSourceText({ cx, sourceActor })); + + const url = getPrettySourceURL(source.url); + const prettySource = getSourceByURL(getState(), url); + + if (prettySource) { + return dispatch(selectPrettyLocation(cx, prettySource)); + } + + const newPrettySource = await dispatch(createPrettySource(cx, source)); + + // Force loading the pretty source/original text. + // This will end up calling prettyPrintSource() of this module, and + // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation. + await dispatch(loadOriginalSourceText({ cx, source: newPrettySource })); + // Select the pretty/original source based on the location we may + // have had against the minified/generated source. + // This uses source map to map locations. + // Also note that selecting a location force many things: + // * opening tabs + // * fetching symbols/inline scope + // * fetching breakable lines + await dispatch(selectPrettyLocation(cx, newPrettySource)); + + const threadcx = getThreadContext(getState()); + // Update frames to the new pretty/original source (in case we were paused) + await dispatch(mapFrames(threadcx)); + // Update breakpoints locations to the new pretty/original source + await dispatch(updateBreakpointsForNewPrettyPrintedSource(cx, sourceId)); + + return newPrettySource; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/select.js b/devtools/client/debugger/src/actions/sources/select.js new file mode 100644 index 0000000000..c4443432a0 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/select.js @@ -0,0 +1,264 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { setSymbols } from "./symbols"; +import { setInScopeLines } from "../ast"; +import { togglePrettyPrint } from "./prettyPrint"; +import { addTab, closeTab } from "../tabs"; +import { loadSourceText } from "./loadSourceText"; +import { mapDisplayNames } from "../pause"; +import { setBreakableLines } from "."; + +import { prefs } from "../../utils/prefs"; +import { isMinified } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { getRelatedMapLocation } from "../../utils/source-maps"; + +import { + getSource, + getFirstSourceActorForGeneratedSource, + getSourceByURL, + getPrettySource, + getSelectedLocation, + getShouldSelectOriginalLocation, + canPrettyPrintSource, + getIsCurrentThreadPaused, + getSourceTextContent, + tabExists, +} from "../../selectors"; + +// This is only used by jest tests (and within this module) +export const setSelectedLocation = ( + cx, + location, + shouldSelectOriginalLocation +) => ({ + type: "SET_SELECTED_LOCATION", + cx, + location, + shouldSelectOriginalLocation, +}); + +// This is only used by jest tests (and within this module) +export const setPendingSelectedLocation = (cx, url, options) => ({ + type: "SET_PENDING_SELECTED_LOCATION", + cx, + url, + line: options?.line, + column: options?.column, +}); + +// This is only used by jest tests (and within this module) +export const clearSelectedLocation = cx => ({ + type: "CLEAR_SELECTED_LOCATION", + cx, +}); + +/** + * Deterministically select a source that has a given URL. This will + * work regardless of the connection status or if the source exists + * yet. + * + * This exists mostly for external things to interact with the + * debugger. + */ +export function selectSourceURL(cx, url, options) { + return async ({ dispatch, getState }) => { + const source = getSourceByURL(getState(), url); + if (!source) { + return dispatch(setPendingSelectedLocation(cx, url, options)); + } + + const location = createLocation({ ...options, source }); + return dispatch(selectLocation(cx, location)); + }; +} + +/** + * Wrapper around selectLocation, which creates the location object for us. + * Note that it ignores the currently selected source and will select + * the precise generated/original source passed as argument. + * + * @param {Object} cx + * @param {String} source + * The precise source to select. + * @param {String} sourceActor + * The specific source actor of the source to + * select the source text. This is optional. + */ +export function selectSource(cx, source, sourceActor) { + return async ({ dispatch }) => { + // `createLocation` requires a source object, but we may use selectSource to close the last tab, + // where source will be null and the location will be an empty object. + const location = source ? createLocation({ source, sourceActor }) : {}; + + return dispatch(selectSpecificLocation(cx, location)); + }; +} + +/** + * Select a new location. + * This will automatically select the source in the source tree (if visible) + * and open the source (a new tab and the source editor) + * as well as highlight a precise line in the editor. + * + * Note that by default, this may map your passed location to the original + * or generated location based on the selected source state. (see keepContext) + * + * @param {Object} cx + * @param {Object} location + * @param {Object} options + * @param {boolean} options.keepContext + * If false, this will ignore the currently selected source + * and select the generated or original location, even if we + * were currently selecting the other source type. + */ +export function selectLocation(cx, location, { keepContext = true } = {}) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + + if (!client) { + // No connection, do nothing. This happens when the debugger is + // shut down too fast and it tries to display a default source. + return; + } + + let source = location.source; + + if (!source) { + // If there is no source we deselect the current selected source + dispatch(clearSelectedLocation(cx)); + return; + } + + // Preserve the current source map context (original / generated) + // when navigating to a new location. + // i.e. if keepContext isn't manually overriden to false, + // we will convert the source we want to select to either + // original/generated in order to match the currently selected one. + // If the currently selected source is original, we will + // automatically map `location` to refer to the original source, + // even if that used to refer only to the generated source. + let shouldSelectOriginalLocation = getShouldSelectOriginalLocation( + getState() + ); + if (keepContext) { + if (shouldSelectOriginalLocation != isOriginalId(location.sourceId)) { + // getRelatedMapLocation will convert to the related generated/original location. + // i.e if the original location is passed, the related generated location will be returned and vice versa. + location = await getRelatedMapLocation(location, thunkArgs); + // Note that getRelatedMapLocation may return the exact same location. + // For example, if the source-map is half broken, it may return a generated location + // while we were selecting original locations. So we may be seeing bundles intermittently + // when stepping through broken source maps. And we will see original sources when stepping + // through functional original sources. + + source = location.source; + } + } else { + shouldSelectOriginalLocation = isOriginalId(location.sourceId); + } + + let sourceActor = location.sourceActor; + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + location = createLocation({ ...location, sourceActor }); + } + + if (!tabExists(getState(), source.id)) { + dispatch(addTab(source, sourceActor)); + } + + dispatch(setSelectedLocation(cx, location, shouldSelectOriginalLocation)); + + await dispatch(loadSourceText(cx, source, sourceActor)); + + await dispatch(setBreakableLines(cx, location)); + + const loadedSource = getSource(getState(), source.id); + + if (!loadedSource) { + // If there was a navigation while we were loading the loadedSource + return; + } + + const sourceTextContent = getSourceTextContent(getState(), location); + + if ( + keepContext && + prefs.autoPrettyPrint && + !getPrettySource(getState(), loadedSource.id) && + canPrettyPrintSource(getState(), location) && + isMinified(source, sourceTextContent) + ) { + await dispatch(togglePrettyPrint(cx, loadedSource.id)); + dispatch(closeTab(cx, loadedSource)); + } + + await dispatch(setSymbols({ cx, location })); + dispatch(setInScopeLines(cx)); + + if (getIsCurrentThreadPaused(getState())) { + await dispatch(mapDisplayNames(cx)); + } + }; +} + +/** + * Select a location while ignoring the currently selected source. + * This will select the generated location even if the currently + * select source is an original source. And the other way around. + * + * @param {Object} cx + * @param {Object} location + * The location to select, object which includes enough + * information to specify a precise source, line and column. + */ +export function selectSpecificLocation(cx, location) { + return selectLocation(cx, location, { keepContext: false }); +} + +/** + * Select the "mapped location". + * + * If the passed location is on a generated source, select the + * related location in the original source. + * If the passed location is on an original source, select the + * related location in the generated source. + */ +export function jumpToMappedLocation(cx, location) { + return async function (thunkArgs) { + const { client, dispatch } = thunkArgs; + if (!client) { + return null; + } + + // Map to either an original or a generated source location + const pairedLocation = await getRelatedMapLocation(location, thunkArgs); + + return dispatch(selectSpecificLocation(cx, pairedLocation)); + }; +} + +// This is only used by tests +export function jumpToMappedSelectedLocation(cx) { + return async function ({ dispatch, getState }) { + const location = getSelectedLocation(getState()); + if (!location) { + return; + } + + await dispatch(jumpToMappedLocation(cx, location)); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/symbols.js b/devtools/client/debugger/src/actions/sources/symbols.js new file mode 100644 index 0000000000..5a1fb1f967 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/symbols.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSymbols } from "../../selectors"; + +import { PROMISE } from "../utils/middleware/promise"; +import { loadSourceText } from "./loadSourceText"; + +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; + +async function doSetSymbols( + cx, + location, + { dispatch, getState, parserWorker } +) { + await dispatch(loadSourceText(cx, location.source, location.sourceActor)); + + await dispatch({ + type: "SET_SYMBOLS", + cx, + location, + [PROMISE]: parserWorker.getSymbols(location.sourceId), + }); +} + +export const setSymbols = memoizeableAction("setSymbols", { + getValue: ({ location }, { getState, parserWorker }) => { + if (!parserWorker.isLocationSupported(location)) { + return fulfilled(null); + } + + const symbols = getSymbols(getState(), location); + if (!symbols) { + return null; + } + + return fulfilled(symbols); + }, + createKey: ({ location }) => location.sourceId, + action: ({ cx, location }, thunkArgs) => + doSetSymbols(cx, location, thunkArgs), +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js new file mode 100644 index 0000000000..2ff8420b23 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; + +import { initialSourceBlackBoxState } from "../../../reducers/source-blackbox"; + +describe("blackbox", () => { + it("should blackbox and unblackbox a source based on the current state of the source ", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox a source when explicilty specified", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + // check the state before trying to blackbox + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + + // should blackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + // should unblackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox lines in a source", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 10, column: 3 }, + end: { line: 15, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range1]); + + // add new blackbox lines in the second range + await dispatch(actions.toggleBlackBox(cx, fooSource, true, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + // ranges are stored asc order + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox lines in the first range + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range2]); + + // un-blackbox lines in the second range + await dispatch(actions.toggleBlackBox(cx, fooSource, false, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should undo blackboxed lines when whole source unblackboxed", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch( + actions.toggleBlackBox(cx, fooSource, true, [range1, range2]) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + // The ranges are ordered in based on the lines & cols in ascending + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox the whole source + await dispatch(actions.toggleBlackBox(cx, fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should restore the blackboxed state correctly debugger load", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + const blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + }); + + it("should unblackbox lines after blackboxed state has been restored", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState, cx } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + + //unblackbox the blackboxed line + await dispatch( + actions.toggleBlackBox(cx, fooSource, false, mockFooSourceRange) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js new file mode 100644 index 0000000000..f81fc856dd --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js @@ -0,0 +1,363 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + actions, + selectors, + watchForState, + createStore, + makeOriginalSource, + makeSource, +} from "../../../utils/test-head"; +import { + createSource, + mockCommandClient, +} from "../../tests/helpers/mockCommandClient"; +import { getBreakpointsList } from "../../../selectors"; +import { isFulfilled, isRejected } from "../../../utils/async-value"; +import { createLocation } from "../../../utils/location"; + +describe("loadGeneratedSourceText", () => { + it("should load source text", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + + const foo1Source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const foo1SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo1Source.id + ); + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: foo1SourceActor, + }) + ); + + const foo1Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo1Source, + sourceActor: foo1SourceActor, + }) + ); + + expect( + foo1Content && + isFulfilled(foo1Content) && + foo1Content.value.type === "text" + ? foo1Content.value.value.indexOf("return foo1") + : -1 + ).not.toBe(-1); + + const foo2Source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + const foo2SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo2Source.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: foo2SourceActor, + }) + ); + + const foo2Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo2Source, + sourceActor: foo2SourceActor, + }) + ); + + expect( + foo2Content && + isFulfilled(foo2Content) && + foo2Content.value.type === "text" + ? foo2Content.value.value.indexOf("return foo2") + : -1 + ).not.toBe(-1); + }); + + it("should update breakpoint text when a source loads", async () => { + const fooOrigContent = createSource("fooOrig", "var fooOrig = 42;"); + const fooGenContent = createSource("fooGen", "var fooGen = 42;"); + + const store = createStore( + { + ...mockCommandClient, + sourceContents: async () => fooGenContent, + getSourceActorBreakpointPositions: async () => ({ 1: [0] }), + getSourceActorBreakableLines: async () => [], + }, + {}, + { + getGeneratedRangesForOriginal: async () => [ + { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + ], + getOriginalLocations: async items => + items.map(item => ({ + ...item, + sourceId: + item.sourceId === fooGenSource1.id + ? fooOrigSources1[0].id + : fooOrigSources2[0].id, + })), + getOriginalSourceText: async s => ({ + text: fooOrigContent.source, + contentType: fooOrigContent.contentType, + }), + } + ); + const { cx, dispatch, getState } = store; + + const fooGenSource1 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen1")) + ); + + const fooOrigSources1 = await dispatch( + actions.newOriginalSources([makeOriginalSource(fooGenSource1)]) + ); + const fooGenSource2 = await dispatch( + actions.newGeneratedSource(makeSource("fooGen2")) + ); + + const fooOrigSources2 = await dispatch( + actions.newOriginalSources([makeOriginalSource(fooGenSource2)]) + ); + + await dispatch( + actions.loadOriginalSourceText({ + cx, + source: fooOrigSources1[0], + }) + ); + + await dispatch( + actions.addBreakpoint( + cx, + createLocation({ + source: fooOrigSources1[0], + line: 1, + column: 0, + }), + {} + ) + ); + + const breakpoint1 = getBreakpointsList(getState())[0]; + expect(breakpoint1.text).toBe(""); + expect(breakpoint1.originalText).toBe("var fooOrig = 42;"); + + const fooGenSource1SourceActor = + selectors.getFirstSourceActorForGeneratedSource( + getState(), + fooGenSource1.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: fooGenSource1SourceActor, + }) + ); + + const breakpoint2 = getBreakpointsList(getState())[0]; + expect(breakpoint2.text).toBe("var fooGen = 42;"); + expect(breakpoint2.originalText).toBe("var fooOrig = 42;"); + + const fooGenSource2SourceActor = + selectors.getFirstSourceActorForGeneratedSource( + getState(), + fooGenSource2.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ + cx, + sourceActor: fooGenSource2SourceActor, + }) + ); + + await dispatch( + actions.addBreakpoint( + cx, + createLocation({ + source: fooGenSource2, + line: 1, + column: 0, + }), + {} + ) + ); + + const breakpoint3 = getBreakpointsList(getState())[1]; + expect(breakpoint3.text).toBe("var fooGen = 42;"); + expect(breakpoint3.originalText).toBe(""); + + await dispatch( + actions.loadOriginalSourceText({ + cx, + source: fooOrigSources2[0], + }) + ); + + const breakpoint4 = getBreakpointsList(getState())[1]; + expect(breakpoint4.text).toBe("var fooGen = 42;"); + expect(breakpoint4.originalText).toBe("var fooOrig = 42;"); + }); + + it("loads two sources w/ one request", async () => { + let resolve; + let count = 0; + const { dispatch, getState, cx } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const loading = dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor }) + ); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("doesn't re-load loaded sources", async () => { + let resolve; + let count = 0; + const { dispatch, getState, cx } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + const loading = dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor }) + ); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("should indicate a loading source", async () => { + const store = createStore(mockCommandClient); + const { dispatch, cx, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + const wasLoading = watchForState(store, state => { + return !selectors.getSettledSourceTextContent( + state, + createLocation({ + source, + sourceActor, + }) + ); + }); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + expect(wasLoading()).toBe(true); + }); + + it("should indicate an errored source text", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bad-id")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && isRejected(content) && typeof content.value === "string" + ? content.value.indexOf("sourceContents failed") + : -1 + ).not.toBe(-1); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js new file mode 100644 index 0000000000..730c5b32eb --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js @@ -0,0 +1,172 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + actions, + selectors, + createStore, + makeSource, + makeSourceURL, + makeOriginalSource, + waitForState, +} from "../../../utils/test-head"; +const { getSource, getSourceCount, getSelectedSource, getSourceByURL } = + selectors; +import sourceQueue from "../../../utils/source-queue"; +import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +describe("sources - new sources", () => { + it("should add sources to state", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("jquery.js"))); + + expect(getSourceCount(getState())).toEqual(2); + const base = getSource(getState(), "base.js"); + const jquery = getSource(getState(), "jquery.js"); + expect(base && base.id).toEqual("base.js"); + expect(jquery && jquery.id).toEqual("jquery.js"); + }); + + it("should not add multiple identical generated sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + const generated = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + + expect(getSourceCount(getState())).toEqual(2); + }); + + it("should not add multiple identical original sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + + expect(getSourceCount(getState())).toEqual(1); + }); + + it("should automatically select a pending source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(cx, baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + + it("should add original sources", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async source => [ + { + id: generatedToOriginalId(source.id, "magic.js"), + url: "magic.js", + }, + ], + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + + await dispatch( + actions.newGeneratedSource( + makeSource("base.js", { sourceMapURL: "base.js.map" }) + ) + ); + const magic = getSourceByURL(getState(), "magic.js"); + expect(magic && magic.url).toEqual("magic.js"); + }); + + // eslint-disable-next-line + it("should not attempt to fetch original sources if it's missing a source map url", async () => { + const getOriginalURLs = jest.fn(); + const { dispatch } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs, + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + expect(getOriginalURLs).not.toHaveBeenCalled(); + }); + + // eslint-disable-next-line + it("should process new sources immediately, without waiting for source maps to be fetched first", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async () => new Promise(_ => {}), + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + await dispatch( + actions.newGeneratedSource( + makeSource("base.js", { sourceMapURL: "base.js.map" }) + ) + ); + expect(getSourceCount(getState())).toEqual(1); + const base = getSource(getState(), "base.js"); + expect(base && base.id).toEqual("base.js"); + }); + + // eslint-disable-next-line + it("shouldn't let one slow loading source map delay all the other source maps", async () => { + const dbg = createStore( + mockCommandClient, + {}, + { + getOriginalURLs: async source => { + if (source.id == "foo.js") { + // simulate a hang loading foo.js.map + return new Promise(_ => {}); + } + const url = source.id.replace(".js", ".cljs"); + return [ + { + id: generatedToOriginalId(source.id, url), + url, + }, + ]; + }, + getOriginalLocations: async items => items, + getGeneratedLocation: location => location, + } + ); + const { dispatch, getState } = dbg; + await dispatch( + actions.newGeneratedSources([ + makeSource("foo.js", { sourceMapURL: "foo.js.map" }), + makeSource("bar.js", { sourceMapURL: "bar.js.map" }), + makeSource("bazz.js", { sourceMapURL: "bazz.js.map" }), + ]) + ); + await sourceQueue.flush(); + await waitForState(dbg, state => getSourceCount(state) == 5); + expect(getSourceCount(getState())).toEqual(5); + const barCljs = getSourceByURL(getState(), "bar.cljs"); + expect(barCljs && barCljs.url).toEqual("bar.cljs"); + const bazzCljs = getSourceByURL(getState(), "bazz.cljs"); + expect(bazzCljs && bazzCljs.url).toEqual("bazz.cljs"); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/select.spec.js b/devtools/client/debugger/src/actions/sources/tests/select.spec.js new file mode 100644 index 0000000000..3fcf24f2b7 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js @@ -0,0 +1,288 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + actions, + selectors, + createStore, + createSourceObject, + makeFrame, + makeSource, + makeSourceURL, + waitForState, + makeOriginalSource, +} from "../../../utils/test-head"; +import { + getSource, + getSourceCount, + getSelectedSource, + getSourceTabs, + getSelectedLocation, + getSymbols, +} from "../../../selectors/"; +import { createLocation } from "../../../utils/location"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +process.on("unhandledRejection", (reason, p) => {}); + +function initialLocation(sourceId) { + return createLocation({ source: createSourceObject(sourceId), line: 1 }); +} + +describe("sources", () => { + it("should select a source", async () => { + // Note that we pass an empty client in because the action checks + // if it exists. + const store = createStore(mockCommandClient); + const { dispatch, getState } = store; + + const frame = makeFrame({ id: "1", sourceId: "foo1" }); + + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + await dispatch( + actions.paused({ + thread: "FakeThread", + why: { type: "debuggerStatement" }, + frame, + frames: [frame], + }) + ); + + const cx = selectors.getThreadContext(getState()); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: baseSource, line: 1, column: 5 }) + ) + ); + + const selectedSource = getSelectedSource(getState()); + if (!selectedSource) { + throw new Error("bad selectedSource"); + } + expect(selectedSource.id).toEqual("foo1"); + + const source = getSource(getState(), selectedSource.id); + if (!source) { + throw new Error("bad source"); + } + expect(source.id).toEqual("foo1"); + }); + + it("should select next tab on tab closed if no previous tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + await dispatch(actions.newGeneratedSource(makeSource("baz.js"))); + + // 3rd tab + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // 2nd tab + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + + // 1st tab + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + + // 3rd tab is reselected + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // closes the 1st tab, which should have no previous tab + await dispatch(actions.closeTab(cx, fooSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should open a tab for the source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + const tabs = getSourceTabs(getState()); + expect(tabs).toHaveLength(1); + expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js"); + }); + + it("should select previous tab on tab closed", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + + const bazSource = await dispatch( + actions.newGeneratedSource(makeSource("baz.js")) + ); + + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + await dispatch(actions.closeTab(cx, bazSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should keep the selected source when other tab closed", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + const bazSource = await dispatch( + actions.newGeneratedSource(makeSource("baz.js")) + ); + + // 3rd tab + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + + // 2nd tab + await dispatch(actions.selectLocation(cx, initialLocation("bar.js"))); + + // 1st tab + await dispatch(actions.selectLocation(cx, initialLocation("baz.js"))); + + // 3rd tab is reselected + await dispatch(actions.selectLocation(cx, initialLocation("foo.js"))); + await dispatch(actions.closeTab(cx, bazSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should not select new sources that lack a URL", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch( + actions.newGeneratedSource({ + ...makeSource("foo"), + url: "", + }) + ); + + expect(getSourceCount(getState())).toEqual(1); + const selectedLocation = getSelectedLocation(getState()); + expect(selectedLocation).toEqual(undefined); + }); + + it("sets and clears selected location correctly", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const source = await dispatch( + actions.newGeneratedSource(makeSource("testSource")) + ); + const location = createLocation({ source }); + + // set value + dispatch(actions.setSelectedLocation(cx, location)); + expect(getSelectedLocation(getState())).toEqual({ + sourceId: source.id, + ...location, + }); + + // clear value + dispatch(actions.clearSelectedLocation(cx)); + expect(getSelectedLocation(getState())).toEqual(null); + }); + + it("sets and clears pending selected location correctly", () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const url = "testURL"; + const options = { line: "testLine", column: "testColumn" }; + + // set value + dispatch(actions.setPendingSelectedLocation(cx, url, options)); + const setResult = getState().sources.pendingSelectedLocation; + expect(setResult).toEqual({ + url, + line: options.line, + column: options.column, + }); + + // clear value + dispatch(actions.clearSelectedLocation(cx)); + const clearResult = getState().sources.pendingSelectedLocation; + expect(clearResult).toEqual({ url: "" }); + }); + + it("should keep the generated the viewing context", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState, cx } = store; + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + baseSource.id + ); + + const location = createLocation({ + source: baseSource, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(cx, location)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe(baseSource.id); + await waitForState(store, state => getSymbols(state, location)); + }); + + it("should change the original the viewing context", async () => { + const { dispatch, getState, cx } = createStore( + mockCommandClient, + {}, + { + getOriginalLocation: async location => ({ ...location, line: 12 }), + getOriginalLocations: async items => items, + getGeneratedRangesForOriginal: async () => [], + getOriginalSourceText: async () => ({ text: "" }), + } + ); + + const baseGenSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const baseSources = await dispatch( + actions.newOriginalSources([makeOriginalSource(baseGenSource)]) + ); + await dispatch(actions.selectSource(cx, baseSources[0])); + + await dispatch( + actions.selectSpecificLocation( + cx, + createLocation({ + source: baseSources[0], + line: 1, + }) + ) + ); + + const selected = getSelectedLocation(getState()); + expect(selected && selected.line).toBe(1); + }); + + describe("selectSourceURL", () => { + it("should automatically select a pending source", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(cx, baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/tabs.js b/devtools/client/debugger/src/actions/tabs.js new file mode 100644 index 0000000000..1b3c0d3f43 --- /dev/null +++ b/devtools/client/debugger/src/actions/tabs.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux actions for the editor tabs + * @module actions/tabs + */ + +import { removeDocument } from "../utils/editor"; +import { selectSource } from "./sources"; + +import { + getSourceByURL, + getSourceTabs, + getNewSelectedSource, +} from "../selectors"; + +export function addTab(source, sourceActor) { + return { + type: "ADD_TAB", + source, + sourceActor, + }; +} + +export function moveTab(url, tabIndex) { + return { + type: "MOVE_TAB", + url, + tabIndex, + }; +} + +export function moveTabBySourceId(sourceId, tabIndex) { + return { + type: "MOVE_TAB_BY_SOURCE_ID", + sourceId, + tabIndex, + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTab(cx, source, reason = "click") { + return ({ dispatch, getState, client }) => { + removeDocument(source.id); + + const tabs = getSourceTabs(getState()); + dispatch({ type: "CLOSE_TAB", source }); + + const newSource = getNewSelectedSource(getState(), tabs); + dispatch(selectSource(cx, newSource)); + }; +} + +/** + * @memberof actions/tabs + * @static + */ +export function closeTabs(cx, urls) { + return ({ dispatch, getState, client }) => { + const sources = urls + .map(url => getSourceByURL(getState(), url)) + .filter(Boolean); + + const tabs = getSourceTabs(getState()); + sources.map(source => removeDocument(source.id)); + dispatch({ type: "CLOSE_TABS", sources }); + + const source = getNewSelectedSource(getState(), tabs); + dispatch(selectSource(cx, source)); + }; +} diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap new file mode 100644 index 0000000000..f27eb26f50 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expressions should get the autocomplete matches for the input 1`] = ` +Array [ + "toLocaleString", + "toSource", + "toString", + "toolbar", + "top", +] +`; diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap new file mode 100644 index 0000000000..741e45d6d8 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`initializing when pending breakpoints exist in prefs syncs pending breakpoints 1`] = ` +Object { + "http://localhost:8000/examples/bar.js:5:2": Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "line": 5, + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 5, + "source": Object { + "id": "", + "url": "http://localhost:8000/examples/bar.js", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "", + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "location": Object { + "column": 2, + "line": 5, + "source": Object { + "id": "", + "url": "http://localhost:8000/examples/bar.js", + }, + "sourceActor": null, + "sourceActorId": undefined, + "sourceId": "", + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "options": Object { + "condition": null, + "hidden": false, + }, + }, +} +`; diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap new file mode 100644 index 0000000000..026bfe4a89 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`preview should generate previews 1`] = `null`; diff --git a/devtools/client/debugger/src/actions/tests/expressions.spec.js b/devtools/client/debugger/src/actions/tests/expressions.spec.js new file mode 100644 index 0000000000..48b06ebd1a --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../utils/test-head"; + +import { makeMockFrame } from "../../utils/test-mockup"; + +const mockThreadFront = { + evaluate: (script, { frameId }) => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }), + evaluateExpressions: (inputs, { frameId }) => + Promise.all( + inputs.map( + input => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }) + ) + ), + getFrameScopes: async () => {}, + getFrames: async () => [], + sourceContents: () => ({ source: "", contentType: "text/javascript" }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + autocomplete: () => { + return new Promise(resolve => { + resolve({ + from: "foo", + matches: ["toLocaleString", "toSource", "toString", "toolbar", "top"], + matchProp: "to", + }); + }); + }, +}; + +describe("expressions", () => { + it("should add an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + expect(selectors.getExpressions(getState())).toHaveLength(1); + }); + + it("should not add empty expressions", () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + dispatch(actions.addExpression(cx, undefined)); + dispatch(actions.addExpression(cx, "")); + expect(selectors.getExpressions(getState())).toHaveLength(0); + }); + + it("should not add invalid expressions", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + await dispatch(actions.addExpression(cx, "foo#")); + const state = getState(); + expect(selectors.getExpressions(state)).toHaveLength(0); + expect(selectors.getExpressionError(state)).toBe(true); + }); + + it("should update an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + + await dispatch(actions.updateExpression(cx, "bar", expression)); + const bar = selectors.getExpression(getState(), "bar"); + + expect(bar && bar.input).toBe("bar"); + }); + + it("should not update an expression w/ invalid code", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + await dispatch(actions.updateExpression(cx, "#bar", expression)); + expect(selectors.getExpression(getState(), "bar")).toBeUndefined(); + }); + + it("should delete an expression", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + expect(selectors.getExpressions(getState())).toHaveLength(2); + + const expression = selectors.getExpression(getState(), "foo"); + + if (!expression) { + throw new Error("expression must exist"); + } + + const bar = selectors.getExpression(getState(), "bar"); + dispatch(actions.deleteExpression(expression)); + expect(selectors.getExpressions(getState())).toHaveLength(1); + expect(bar && bar.input).toBe("bar"); + }); + + it("should evaluate expressions global scope", async () => { + const { dispatch, getState, cx } = createStore(mockThreadFront); + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + + let foo = selectors.getExpression(getState(), "foo"); + let bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + + await dispatch(actions.evaluateExpressions(cx)); + foo = selectors.getExpression(getState(), "foo"); + bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + }); + + it("should evaluate expressions in specific scope", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + await createFrames(getState, dispatch); + + const cx = selectors.getThreadContext(getState()); + await dispatch(actions.newGeneratedSource(makeSource("source"))); + await dispatch(actions.addExpression(cx, "foo")); + await dispatch(actions.addExpression(cx, "bar")); + + let foo = selectors.getExpression(getState(), "foo"); + let bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("boo"); + expect(bar && bar.value).toBe("boo"); + + await dispatch(actions.evaluateExpressions(cx)); + foo = selectors.getExpression(getState(), "foo"); + bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("boo"); + expect(bar && bar.value).toBe("boo"); + }); + + it("should get the autocomplete matches for the input", async () => { + const { cx, dispatch, getState } = createStore(mockThreadFront); + await dispatch(actions.autocomplete(cx, "to", 2)); + expect(selectors.getAutocompleteMatchset(getState())).toMatchSnapshot(); + }); +}); + +async function createFrames(getState, dispatch) { + const frame = makeMockFrame(); + await dispatch(actions.newGeneratedSource(makeSource("example.js"))); + await dispatch(actions.newGeneratedSource(makeSource("source"))); + + await dispatch( + actions.paused({ + thread: "FakeThread", + frame, + frames: [frame], + why: { type: "just because" }, + }) + ); + + await dispatch( + actions.selectFrame(selectors.getThreadContext(getState()), frame) + ); +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/immutable.js b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js new file mode 100644 index 0000000000..e8ac7fb233 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js @@ -0,0 +1,2 @@ + +const m = Immutable.Map({a: 2}) diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js new file mode 100644 index 0000000000..526c852d99 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js @@ -0,0 +1,7 @@ +import React, { Component } from "react"; + +class FixtureComponent extends Component { + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js new file mode 100644 index 0000000000..3103161fe0 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js @@ -0,0 +1,5 @@ +import React, { Component } from "react"; + +export default FixtureComponent = (props) => { + return <div>props.a</div>; +} diff --git a/devtools/client/debugger/src/actions/tests/fixtures/scopes.js b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js new file mode 100644 index 0000000000..3a38097f5e --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js @@ -0,0 +1,11 @@ +// Program Scope + +function outer() { + function inner() { + const x = 1; + } + + const declaration = function() { + const x = 1; + }; +} diff --git a/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js new file mode 100644 index 0000000000..e0279e5fc1 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createLocation } from "../../../utils/location"; + +export function mockPendingBreakpoint(overrides = {}) { + const { sourceUrl, line, column, condition, disabled, hidden } = overrides; + return { + location: createLocation({ + source: { + id: "", + url: sourceUrl || "http://localhost:8000/examples/bar.js", + }, + sourceId: "", + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }), + generatedLocation: createLocation({ + source: { + id: "", + url: sourceUrl || "http://localhost:8000/examples/bar.js", + }, + sourceId: "", + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }), + astLocation: { + name: undefined, + offset: { + line: line || 5, + }, + index: 0, + }, + options: { + condition: condition || null, + hidden: hidden || false, + }, + disabled: disabled || false, + }; +} + +export function generateBreakpoint(filename, line = 5, column = 0) { + return { + id: "breakpoint", + originalText: "", + text: "", + location: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceUrl: `http://localhost:8000/examples/${filename}`, + sourceId: filename, + line, + column, + }), + generatedLocation: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceUrl: `http://localhost:8000/examples/${filename}`, + sourceId: filename, + line, + column, + }), + astLocation: undefined, + options: { + condition: "", + hidden: false, + }, + disabled: false, + }; +} diff --git a/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js new file mode 100644 index 0000000000..38dd55c274 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function createSource(name, code) { + name = name.replace(/\..*$/, ""); + return { + source: code || `function ${name}() {\n return ${name} \n}`, + contentType: "text/javascript", + }; +} + +const sources = [ + "a", + "b", + "foo", + "bar", + "foo1", + "foo2", + "a.js", + "baz.js", + "foobar.js", + "barfoo.js", + "foo.js", + "bar.js", + "base.js", + "bazz.js", + "jquery.js", +]; + +export const mockCommandClient = { + sourceContents: function ({ source }) { + return new Promise((resolve, reject) => { + if (sources.includes(source)) { + resolve(createSource(source)); + } + + reject(`unknown source: ${source}`); + }); + }, + setBreakpoint: async () => {}, + removeBreakpoint: _id => Promise.resolve(), + threadFront: async () => {}, + getFrameScopes: async () => {}, + getFrames: async () => [], + evaluateExpressions: async () => {}, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; diff --git a/devtools/client/debugger/src/actions/tests/helpers/readFixture.js b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js new file mode 100644 index 0000000000..6c23641226 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import fs from "fs"; +import path from "path"; + +export default function readFixture(name) { + const text = fs.readFileSync( + path.join(__dirname, `../fixtures/${name}`), + "utf8" + ); + return text; +} diff --git a/devtools/client/debugger/src/actions/tests/navigation.spec.js b/devtools/client/debugger/src/actions/tests/navigation.spec.js new file mode 100644 index 0000000000..e2572fde80 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/navigation.spec.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createStore, selectors, actions } from "../../utils/test-head"; + +jest.mock("../../utils/editor"); + +const { getActiveSearch } = selectors; + +const threadFront = { + sourceContents: async () => ({ + source: "function foo1() {\n const foo = 5; return foo;\n}", + contentType: "text/javascript", + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; + +describe("navigation", () => { + it("navigation removes activeSearch 'file' value", async () => { + const { dispatch, getState } = createStore(threadFront); + dispatch(actions.setActiveSearch("file")); + expect(getActiveSearch(getState())).toBe("file"); + + await dispatch(actions.willNavigate("will-navigate")); + expect(getActiveSearch(getState())).toBe(null); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js new file mode 100644 index 0000000000..2baa17a79f --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js @@ -0,0 +1,294 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// TODO: we would like to mock this in the local tests +import { + generateBreakpoint, + mockPendingBreakpoint, +} from "./helpers/breakpoints.js"; + +import { mockCommandClient } from "./helpers/mockCommandClient"; +import { asyncStore } from "../../utils/prefs"; + +function loadInitialState(opts = {}) { + const mockedPendingBreakpoint = mockPendingBreakpoint({ ...opts, column: 2 }); + const l = mockedPendingBreakpoint.location; + const id = `${l.sourceUrl}:${l.line}:${l.column}`; + asyncStore.pendingBreakpoints = { [id]: mockedPendingBreakpoint }; + + return { pendingBreakpoints: asyncStore.pendingBreakpoints }; +} + +jest.mock("../../utils/prefs", () => ({ + prefs: { + clientSourceMapsEnabled: true, + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + clear: jest.fn(), + features: { + inlinePreview: true, + }, +})); + +import { + createStore, + selectors, + actions, + makeSource, + makeSourceURL, + waitForState, +} from "../../utils/test-head"; + +import sourceMapLoader from "devtools/client/shared/source-map-loader/index"; + +function mockClient(bpPos = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => bpPos, + getSourceActorBreakableLines: async () => [], + }; +} + +function mockSourceMaps() { + return { + ...sourceMapLoader, + getOriginalSourceText: async id => ({ + id, + text: "", + contentType: "text/javascript", + }), + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => items, + }; +} + +describe("when adding breakpoints", () => { + it("a corresponding pending breakpoint should be added", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1] }), + loadInitialState(), + mockSourceMaps() + ); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + const bp = generateBreakpoint("foo.js", 5, 1); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(2); + }); +}); + +describe("initializing when pending breakpoints exist in prefs", () => { + it("syncs pending breakpoints", async () => { + const { getState } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bps = selectors.getPendingBreakpoints(getState()); + expect(bps).toMatchSnapshot(); + }); + + it("re-adding breakpoints update existing pending breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [1, 2] }), + loadInitialState(), + mockSourceMaps() + ); + const bar = generateBreakpoint("bar.js", 5, 1); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + await dispatch(actions.addBreakpoint(cx, bar.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); + + it("adding bps doesn't remove existing pending breakpoints", async () => { + const { dispatch, getState, cx } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bp = generateBreakpoint("foo.js"); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await dispatch(actions.addBreakpoint(cx, bp.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); +}); + +describe("initializing with disabled pending breakpoints in prefs", () => { + it("syncs breakpoints with pending breakpoints", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + + const { getState, dispatch, cx } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await waitForState(store, state => { + const bps = selectors.getBreakpointsForSource(state, source.id); + return bps && !!Object.values(bps).length; + }); + + const bp = selectors.getBreakpointsList(getState()).find(({ location }) => { + return ( + location.line == 5 && + location.column == 2 && + location.sourceId == source.id + ); + }); + + if (!bp) { + throw new Error("no bp"); + } + expect(bp.location.sourceId).toEqual(source.id); + expect(bp.disabled).toEqual(true); + }); +}); + +describe("adding sources", () => { + it("corresponding breakpoints are added for a single source", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch, cx } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor })); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("corresponding breakpoints are added to the original source", async () => { + const sourceURL = makeSourceURL("bar.js"); + const store = createStore(mockClient({ 5: [2] }), loadInitialState(), { + getOriginalURLs: async source => [ + { + id: sourceMapLoader.generatedToOriginalId(source.id, sourceURL), + url: sourceURL, + }, + ], + getOriginalSourceText: async () => ({ text: "" }), + getGeneratedLocation: async location => location, + getOriginalLocation: async location => location, + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => + items.map(item => ({ + ...item, + sourceId: sourceMapLoader.generatedToOriginalId( + item.sourceId, + sourceURL + ), + })), + }); + + const { getState, dispatch } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + await dispatch( + actions.newGeneratedSource(makeSource("bar.js", { sourceMapURL: "foo" })) + ); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("add corresponding breakpoints for multiple sources", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch, cx } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const [source1, source2] = await dispatch( + actions.newGeneratedSources([makeSource("bar.js"), makeSource("foo.js")]) + ); + const sourceActor1 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source1.id + ); + const sourceActor2 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source2.id + ); + + await dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor: sourceActor1 }) + ); + await dispatch( + actions.loadGeneratedSourceText({ cx, sourceActor: sourceActor2 }) + ); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/preview.spec.js b/devtools/client/debugger/src/actions/tests/preview.spec.js new file mode 100644 index 0000000000..3b6c9c23ac --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/preview.spec.js @@ -0,0 +1,217 @@ +/* eslint max-nested-callbacks: ["error", 6] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + createStore, + selectors, + actions, + makeSource, + makeFrame, + waitForState, + waitATick, +} from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; + +function waitForPreview(store, expression) { + return waitForState(store, state => { + const preview = selectors.getPreview(state); + return preview && preview.expression == expression; + }); +} + +function mockThreadFront(overrides) { + return { + evaluate: async () => ({ result: {} }), + getFrameScopes: async () => {}, + getFrames: async () => [], + sourceContents: async () => ({ + source: "", + contentType: "text/javascript", + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + evaluateExpressions: async () => [], + loadObjectProperties: async () => ({}), + ...overrides, + }; +} + +function dispatchSetPreview(dispatch, context, expression, target) { + return dispatch( + actions.setPreview( + context, + expression, + { + start: { url: "foo.js", line: 1, column: 2 }, + end: { url: "foo.js", line: 1, column: 5 }, + }, + { line: 2, column: 3 }, + target.getBoundingClientRect(), + target + ) + ); +} + +async function pause(store, client) { + const { dispatch, cx, getState } = store; + const source = makeSource("base.js"); + const base = await dispatch(actions.newGeneratedSource(source)); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + + await dispatch(actions.selectSource(cx, base, sourceActor)); + const location = createLocation({ source: base, sourceActor }); + await waitForState(store, state => selectors.getSymbols(state, location)); + + const { thread } = cx; + const frames = [makeFrame({ id: "frame1", sourceId: base.id, thread })]; + client.getFrames = async () => frames; + + await dispatch( + actions.paused({ + thread, + frame: frames[0], + loadedObjects: [], + why: { type: "debuggerStatement" }, + }) + ); +} + +describe("preview", () => { + it("should generate previews", async () => { + const store = createStore(mockThreadFront()); + const { dispatch, getState, cx } = store; + const source = makeSource("base.js"); + const base = await dispatch(actions.newGeneratedSource(source)); + + await dispatch(actions.selectSource(cx, base)); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const location = createLocation({ source: base, sourceActor }); + + await waitForState(store, state => selectors.getSymbols(state, location)); + const frames = [makeFrame({ id: "f1", sourceId: base.id })]; + + await dispatch( + actions.paused({ + thread: store.cx.thread, + frame: frames[0], + frames, + loadedObjects: [], + why: { type: "debuggerStatement" }, + }) + ); + + const newCx = selectors.getContext(getState()); + const firstTarget = document.createElement("div"); + + dispatchSetPreview(dispatch, newCx, "foo", firstTarget); + + expect(selectors.getPreview(getState())).toMatchSnapshot(); + }); + + // When a 2nd setPreview is called before a 1st setPreview dispatches + // and the 2nd setPreview has not dispatched yet, + // the first setPreview should not finish dispatching + it("queued previews (w/ the 1st finishing first)", async () => { + let resolveFirst, resolveSecond; + const promises = [ + new Promise(resolve => { + resolveFirst = resolve; + }), + new Promise(resolve => { + resolveSecond = resolve; + }), + ]; + + const client = mockThreadFront({ + loadObjectProperties: () => promises.shift(), + }); + const store = createStore(client); + + const { dispatch, getState } = store; + await pause(store, client); + + const newCx = selectors.getContext(getState()); + const firstTarget = document.createElement("div"); + const secondTarget = document.createElement("div"); + + // Start the dispatch of the first setPreview. At this point, it will not + // finish execution until we resolve the firstSetPreview + dispatchSetPreview(dispatch, newCx, "firstSetPreview", firstTarget); + + // Start the dispatch of the second setPreview. At this point, it will not + // finish execution until we resolve the secondSetPreview + dispatchSetPreview(dispatch, newCx, "secondSetPreview", secondTarget); + + let fail = false; + + resolveFirst(); + waitForPreview(store, "firstSetPreview").then(() => { + fail = true; + }); + + resolveSecond(); + await waitForPreview(store, "secondSetPreview"); + expect(fail).toEqual(false); + + const preview = selectors.getPreview(getState()); + expect(preview && preview.expression).toEqual("secondSetPreview"); + }); + + // When a 2nd setPreview is called before a 1st setPreview dispatches + // and the 2nd setPreview has dispatched, + // the first setPreview should not finish dispatching + it("queued previews (w/ the 2nd finishing first)", async () => { + let resolveFirst, resolveSecond; + const promises = [ + new Promise(resolve => { + resolveFirst = resolve; + }), + new Promise(resolve => { + resolveSecond = resolve; + }), + ]; + + const client = mockThreadFront({ + loadObjectProperties: () => promises.shift(), + }); + const store = createStore(client); + + const { dispatch, getState } = store; + await pause(store, client); + + const cx = selectors.getThreadContext(getState()); + const firstTarget = document.createElement("div"); + const secondTarget = document.createElement("div"); + + // Start the dispatch of the first setPreview. At this point, it will not + // finish execution until we resolve the firstSetPreview + dispatchSetPreview(dispatch, cx, "firstSetPreview", firstTarget); + + // Start the dispatch of the second setPreview. At this point, it will not + // finish execution until we resolve the secondSetPreview + dispatchSetPreview(dispatch, cx, "secondSetPreview", secondTarget); + + let fail = false; + + resolveSecond(); + await waitForPreview(store, "secondSetPreview"); + + resolveFirst(); + waitForPreview(store, "firstSetPreview").then(() => { + fail = true; + }); + + await waitATick(() => expect(fail).toEqual(false)); + + const preview = selectors.getPreview(getState()); + expect(preview && preview.expression).toEqual("secondSetPreview"); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/sources-tree.spec.js b/devtools/client/debugger/src/actions/tests/sources-tree.spec.js new file mode 100644 index 0000000000..916b2d015b --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/sources-tree.spec.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { actions, selectors, createStore } from "../../utils/test-head"; +const { getExpandedState } = selectors; + +describe("source tree", () => { + it("should set the expanded state", () => { + const { dispatch, getState } = createStore(); + const expandedState = new Set(["foo", "bar"]); + + expect(getExpandedState(getState())).toEqual(new Set([])); + dispatch(actions.setExpandedState(expandedState)); + expect(getExpandedState(getState())).toEqual(expandedState); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/tabs.spec.js b/devtools/client/debugger/src/actions/tests/tabs.spec.js new file mode 100644 index 0000000000..419e5b7e60 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/tabs.spec.js @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../utils/test-head"; +const { getSelectedSource, getSourceTabs } = selectors; +import { createLocation } from "../../utils/location"; + +import { mockCommandClient } from "./helpers/mockCommandClient"; + +describe("closing tabs", () => { + it("closing a tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); + + it("closing the inactive tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bar.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing the only tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + dispatch(actions.closeTab(cx, fooSource)); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); + + it("closing the active tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch(actions.closeTab(cx, barSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing many inactive tabs", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const bazzSource = await dispatch( + actions.newGeneratedSource(makeSource("bazz.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: bazzSource, line: 1 }) + ) + ); + + const tabs = [ + "http://localhost:8000/examples/foo.js", + "http://localhost:8000/examples/bar.js", + ]; + dispatch(actions.closeTabs(cx, tabs)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("bazz.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing many tabs including the active tab", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const bazzSource = await dispatch( + actions.newGeneratedSource(makeSource("bazz.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.selectLocation( + cx, + createLocation({ source: bazzSource, line: 1 }) + ) + ); + const tabs = [ + "http://localhost:8000/examples/bar.js", + "http://localhost:8000/examples/bazz.js", + ]; + await dispatch(actions.closeTabs(cx, tabs)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(1); + }); + + it("closing all the tabs", async () => { + const { dispatch, getState, cx } = createStore(mockCommandClient); + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const barSource = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 })) + ); + await dispatch( + actions.selectLocation(cx, createLocation({ source: barSource, line: 1 })) + ); + await dispatch( + actions.closeTabs(cx, [ + "http://localhost:8000/examples/foo.js", + "http://localhost:8000/examples/bar.js", + ]) + ); + + expect(getSelectedSource(getState())).toBe(undefined); + expect(getSourceTabs(getState())).toHaveLength(0); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/ui.spec.js b/devtools/client/debugger/src/actions/tests/ui.spec.js new file mode 100644 index 0000000000..0e13681a12 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/ui.spec.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + createStore, + selectors, + actions, + makeSource, +} from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; +import { mockCommandClient } from "./helpers/mockCommandClient"; + +const { + getActiveSearch, + getFrameworkGroupingState, + getPaneCollapse, + getHighlightedLineRangeForSelectedSource, +} = selectors; + +describe("ui", () => { + it("should toggle the visible state of file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + expect(getActiveSearch(getState())).toBe("file"); + }); + + it("should close file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + dispatch(actions.closeActiveSearch()); + expect(getActiveSearch(getState())).toBe(null); + }); + + it("should toggle the collapse state of a pane", () => { + const { dispatch, getState } = createStore(); + expect(getPaneCollapse(getState(), "start")).toBe(false); + dispatch(actions.togglePaneCollapse("start", true)); + expect(getPaneCollapse(getState(), "start")).toBe(true); + }); + + it("should toggle the collapsed state of frameworks in the callstack", () => { + const { dispatch, getState } = createStore(); + const currentState = getFrameworkGroupingState(getState()); + dispatch(actions.toggleFrameworkGrouping(!currentState)); + expect(getFrameworkGroupingState(getState())).toBe(!currentState); + }); + + it("should highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const cx = selectors.getThreadContext(getState()); + //await dispatch(actions.selectSource(cx, base, sourceActor)); + const location = createLocation({ + source: base, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(cx, location)); + + const range = { start: 3, end: 5, sourceId: base.id }; + dispatch(actions.highlightLineRange(range)); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(range); + }); + + it("should clear highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + const cx = selectors.getThreadContext(getState()); + await dispatch(actions.selectSource(cx, base, sourceActor)); + const range = { start: 3, end: 5, sourceId: "2" }; + dispatch(actions.highlightLineRange(range)); + dispatch(actions.clearHighlightLineRange()); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(null); + }); +}); diff --git a/devtools/client/debugger/src/actions/threads.js b/devtools/client/debugger/src/actions/threads.js new file mode 100644 index 0000000000..13f53e7c67 --- /dev/null +++ b/devtools/client/debugger/src/actions/threads.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createThread } from "../client/firefox/create"; +import { getSourcesToRemoveForThread } from "../selectors"; + +export function addTarget(targetFront) { + return { type: "INSERT_THREAD", newThread: createThread(targetFront) }; +} + +export function removeTarget(targetFront) { + return ({ getState, dispatch }) => { + const threadActorID = targetFront.targetForm.threadActor; + + // Just before emitting the REMOVE_THREAD action, + // synchronously compute the list of source and source actor objects + // which should be removed as that one target get removed. + // + // The list of source objects isn't trivial to compute as these objects + // are shared across targets/threads. + const { actors, sources } = getSourcesToRemoveForThread( + getState(), + threadActorID + ); + + dispatch({ + type: "REMOVE_THREAD", + threadActorID, + actors, + sources, + }); + }; +} + +export function toggleJavaScriptEnabled(enabled) { + return async ({ dispatch, client }) => { + await client.toggleJavaScriptEnabled(enabled); + dispatch({ + type: "TOGGLE_JAVASCRIPT_ENABLED", + value: enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/toolbox.js b/devtools/client/debugger/src/actions/toolbox.js new file mode 100644 index 0000000000..a343c92863 --- /dev/null +++ b/devtools/client/debugger/src/actions/toolbox.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * @memberof actions/toolbox + * @static + */ +export function openLink(url) { + return async function ({ panel }) { + return panel.openLink(url); + }; +} + +export function evaluateInConsole(inputString) { + return async ({ panel }) => { + return panel.openConsoleAndEvaluate(inputString); + }; +} + +export function openElementInInspectorCommand(grip) { + return async ({ panel }) => { + return panel.openElementInInspector(grip); + }; +} + +export function openInspector(grip) { + return async ({ panel }) => { + return panel.openInspector(); + }; +} + +export function highlightDomElement(grip) { + return async ({ panel }) => { + return panel.highlightDomElement(grip); + }; +} + +export function unHighlightDomElement(grip) { + return async ({ panel }) => { + return panel.unHighlightDomElement(grip); + }; +} diff --git a/devtools/client/debugger/src/actions/tracing.js b/devtools/client/debugger/src/actions/tracing.js new file mode 100644 index 0000000000..9cbe7bc20e --- /dev/null +++ b/devtools/client/debugger/src/actions/tracing.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getIsThreadCurrentlyTracing, getAllThreads } from "../selectors"; +import { PROMISE } from "./utils/middleware/promise"; + +/** + * Toggle ON/OFF Javascript tracing for all targets, + * using the specified log method. + * + * @param {string} logMethod + * Can be "stdout" or "console". See TracerActor. + */ +export function toggleTracing(logMethod) { + return async ({ dispatch, getState, client, panel }) => { + // Check if any of the thread is currently tracing. + // For now, the UI can only toggle all the targets all at once. + const threads = getAllThreads(getState()); + const isTracingEnabled = threads.some(thread => + getIsThreadCurrentlyTracing(getState(), thread.actor) + ); + + // Automatically open the split console when enabling tracing to the console + if (!isTracingEnabled && logMethod == "console") { + await panel.toolbox.openSplitConsole({ focusConsoleInput: false }); + } + + return dispatch({ + type: "TOGGLE_TRACING", + [PROMISE]: isTracingEnabled + ? client.stopTracing() + : client.startTracing(logMethod), + }); + }; +} + +/** + * Called when tracing is toggled ON/OFF on a particular thread. + */ +export function tracingToggled(thread, enabled) { + return ({ dispatch }) => { + dispatch({ + type: "TRACING_TOGGLED", + thread, + enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/ui.js b/devtools/client/debugger/src/actions/ui.js new file mode 100644 index 0000000000..67b2629135 --- /dev/null +++ b/devtools/client/debugger/src/actions/ui.js @@ -0,0 +1,290 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { + getActiveSearch, + getPaneCollapse, + getQuickOpenEnabled, + getSource, + getSourceContent, + getMainThread, + getIgnoreListSourceUrls, + getSourceByURL, + getBreakpointsForSource, +} from "../selectors"; +import { selectSource } from "../actions/sources/select"; +import { + getEditor, + getLocationsInViewport, + updateDocuments, +} from "../utils/editor"; +import { blackboxSourceActorsForSource } from "./sources/blackbox"; +import { toggleBreakpoints } from "./breakpoints"; +import { copyToTheClipboard } from "../utils/clipboard"; +import { isFulfilled } from "../utils/async-value"; +import { primaryPaneTabs } from "../constants"; + +export function setPrimaryPaneTab(tabName) { + return { type: "SET_PRIMARY_PANE_TAB", tabName }; +} + +export function closeActiveSearch() { + return { + type: "TOGGLE_ACTIVE_SEARCH", + value: null, + }; +} + +export function setActiveSearch(activeSearch) { + return ({ dispatch, getState }) => { + const activeSearchState = getActiveSearch(getState()); + if (activeSearchState === activeSearch) { + return; + } + + if (getQuickOpenEnabled(getState())) { + dispatch({ type: "CLOSE_QUICK_OPEN" }); + } + + // Open start panel if it was collapsed so the project search UI is visible + if ( + activeSearch === primaryPaneTabs.PROJECT_SEARCH && + getPaneCollapse(getState(), "start") + ) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch({ + type: "TOGGLE_ACTIVE_SEARCH", + value: activeSearch, + }); + }; +} + +export function toggleFrameworkGrouping(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_FRAMEWORK_GROUPING", + value: toggleValue, + }); + }; +} + +export function toggleInlinePreview(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_INLINE_PREVIEW", + value: toggleValue, + }); + }; +} + +export function toggleEditorWrapping(toggleValue) { + return ({ dispatch, getState }) => { + updateDocuments(doc => doc.cm.setOption("lineWrapping", toggleValue)); + + dispatch({ + type: "TOGGLE_EDITOR_WRAPPING", + value: toggleValue, + }); + }; +} + +export function toggleSourceMapsEnabled(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_SOURCE_MAPS_ENABLED", + value: toggleValue, + }); + }; +} + +export function showSource(cx, sourceId) { + return ({ dispatch, getState }) => { + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + if (getPaneCollapse(getState(), "start")) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch(setPrimaryPaneTab("sources")); + + dispatch(selectSource(cx, source)); + }; +} + +export function togglePaneCollapse(position, paneCollapsed) { + return ({ dispatch, getState }) => { + const prevPaneCollapse = getPaneCollapse(getState(), position); + if (prevPaneCollapse === paneCollapsed) { + return; + } + + // Set active search to null when closing start panel if project search was active + if ( + position === "start" && + paneCollapsed && + getActiveSearch(getState()) === primaryPaneTabs.PROJECT_SEARCH + ) { + dispatch(closeActiveSearch()); + } + + dispatch({ + type: "TOGGLE_PANE", + position, + paneCollapsed, + }); + }; +} + +/** + * Highlight one or many lines in CodeMirror for a given source. + * + * @param {Object} location + * @param {String} location.sourceId + * The precise source to highlight. + * @param {Number} location.start + * The 1-based index of first line to highlight. + * @param {Number} location.end + * The 1-based index of last line to highlight. + */ +export function highlightLineRange(location) { + return { + type: "HIGHLIGHT_LINES", + location, + }; +} + +export function flashLineRange(location) { + return ({ dispatch }) => { + dispatch(highlightLineRange(location)); + setTimeout(() => dispatch(clearHighlightLineRange()), 200); + }; +} + +export function clearHighlightLineRange() { + return { + type: "CLEAR_HIGHLIGHT_LINES", + }; +} + +export function openConditionalPanel(location, log = false) { + if (!location) { + return null; + } + + return { + type: "OPEN_CONDITIONAL_PANEL", + location, + log, + }; +} + +export function closeConditionalPanel() { + return { + type: "CLOSE_CONDITIONAL_PANEL", + }; +} + +export function clearProjectDirectoryRoot(cx) { + return { + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: "", + name: "", + }; +} + +export function setProjectDirectoryRoot(cx, newRoot, newName) { + return ({ dispatch, getState }) => { + // If the new project root is against the top level thread, + // replace its thread ID with "top-level", so that later, + // getDirectoryForUniquePath could match the project root, + // even after a page reload where the new top level thread actor ID + // will be different. + const mainThread = getMainThread(getState()); + if (mainThread && newRoot.startsWith(mainThread.actor)) { + newRoot = newRoot.replace(mainThread.actor, "top-level"); + } + dispatch({ + type: "SET_PROJECT_DIRECTORY_ROOT", + cx, + url: newRoot, + name: newName, + }); + }; +} + +export function updateViewport() { + return { + type: "SET_VIEWPORT", + viewport: getLocationsInViewport(getEditor()), + }; +} + +export function updateCursorPosition(cursorPosition) { + return { type: "SET_CURSOR_POSITION", cursorPosition }; +} + +export function setOrientation(orientation) { + return { type: "SET_ORIENTATION", orientation }; +} + +export function setSearchOptions(searchKey, searchOptions) { + return { type: "SET_SEARCH_OPTIONS", searchKey, searchOptions }; +} + +export function copyToClipboard(location) { + return ({ dispatch, getState }) => { + const content = getSourceContent(getState(), location); + if (content && isFulfilled(content) && content.value.type === "text") { + copyToTheClipboard(content.value.value); + } + }; +} + +export function setJavascriptTracingLogMethod(value) { + return ({ dispatch, getState }) => { + dispatch({ + type: "SET_JAVASCRIPT_TRACING_LOG_METHOD", + value, + }); + }; +} + +export function setHideOrShowIgnoredSources(shouldHide) { + return ({ dispatch, getState }) => { + dispatch({ type: "HIDE_IGNORED_SOURCES", shouldHide }); + }; +} + +export function toggleSourceMapIgnoreList(cx, shouldEnable) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + const ignoreListSourceUrls = getIgnoreListSourceUrls(getState()); + // Blackbox the source actors on the server + for (const url of ignoreListSourceUrls) { + const source = getSourceByURL(getState(), url); + await blackboxSourceActorsForSource(thunkArgs, source, shouldEnable); + // Disable breakpoints in sources on the ignore list + const breakpoints = getBreakpointsForSource(getState(), source.id); + await dispatch(toggleBreakpoints(cx, shouldEnable, breakpoints)); + } + await dispatch({ + type: "ENABLE_SOURCEMAP_IGNORELIST", + shouldEnable, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/create-store.js b/devtools/client/debugger/src/actions/utils/create-store.js new file mode 100644 index 0000000000..9527c67afc --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/create-store.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* global window */ + +/** + * Redux store utils + * @module utils/create-store + */ + +import { createStore, applyMiddleware } from "redux"; +import { waitUntilService } from "./middleware/wait-service"; +import { log } from "./middleware/log"; +import { promise } from "./middleware/promise"; +import { thunk } from "./middleware/thunk"; +import { timing } from "./middleware/timing"; +import { context } from "./middleware/context"; + +/** + * @memberof utils/create-store + * @static + */ + +/** + * This creates a dispatcher with all the standard middleware in place + * that all code requires. It can also be optionally configured in + * various ways, such as logging and recording. + * + * @param {object} opts: + * - log: log all dispatched actions to console + * - history: an array to store every action in. Should only be + * used in tests. + * - middleware: array of middleware to be included in the redux store + * @memberof utils/create-store + * @static + */ +const configureStore = (opts = {}) => { + const middleware = [ + thunk(opts.makeThunkArgs), + context, + promise, + + // Order is important: services must go last as they always + // operate on "already transformed" actions. Actions going through + // them shouldn't have any special fields like promises, they + // should just be normal JSON objects. + waitUntilService, + ]; + + if (opts.middleware) { + opts.middleware.forEach(fn => middleware.push(fn)); + } + + if (opts.log) { + middleware.push(log); + } + + if (opts.timing) { + middleware.push(timing); + } + + // Hook in the redux devtools browser extension if it exists + const devtoolsExt = + typeof window === "object" && window.devToolsExtension + ? window.devToolsExtension() + : f => f; + + return applyMiddleware(...middleware)(devtoolsExt(createStore)); +}; + +export default configureStore; diff --git a/devtools/client/debugger/src/actions/utils/middleware/context.js b/devtools/client/debugger/src/actions/utils/middleware/context.js new file mode 100644 index 0000000000..ebadaa4eff --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/context.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + validateNavigateContext, + validateContext, +} from "../../../utils/context"; + +function validateActionContext(getState, action) { + if (action.type == "COMMAND" && action.status == "done") { + // The thread will have resumed execution since the action was initiated, + // so just make sure we haven't navigated. + validateNavigateContext(getState(), action.cx); + return; + } + + // Validate using all available information in the context. + validateContext(getState(), action.cx); +} + +// Middleware which looks for actions that have a cx property and ignores +// them if the context is no longer valid. +function context({ dispatch, getState }) { + return next => action => { + if ("cx" in action) { + validateActionContext(getState, action); + } + return next(action); + }; +} + +export { context }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/log.js b/devtools/client/debugger/src/actions/utils/middleware/log.js new file mode 100644 index 0000000000..b9592ce22c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/log.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 flags from "devtools/shared/flags"; +import { prefs } from "../../../utils/prefs"; + +const ignoreList = [ + "ADD_BREAKPOINT_POSITIONS", + "SET_SYMBOLS", + "OUT_OF_SCOPE_LOCATIONS", + "MAP_SCOPES", + "MAP_FRAMES", + "ADD_SCOPES", + "IN_SCOPE_LINES", + "REMOVE_BREAKPOINT", + "NODE_PROPERTIES_LOADED", + "SET_FOCUSED_SOURCE_ITEM", + "NODE_EXPAND", + "IN_SCOPE_LINES", + "SET_PREVIEW", +]; + +function cloneAction(action) { + action = action || {}; + action = { ...action }; + + // ADD_TAB, ... + if (action.source?.text) { + const source = { ...action.source, text: "" }; + action.source = source; + } + + if (action.sources) { + const sources = action.sources.slice(0, 20).map(source => { + const url = !source.url || source.url.includes("data:") ? "" : source.url; + return { ...source, url }; + }); + action.sources = sources; + } + + // LOAD_SOURCE_TEXT + if (action.text) { + action.text = ""; + } + + if (action.value?.text) { + const value = { ...action.value, text: "" }; + action.value = value; + } + + return action; +} + +function formatPause(pause) { + return { + ...pause, + pauseInfo: { why: pause.why }, + scopes: [], + loadedObjects: [], + }; +} + +function serializeAction(action) { + try { + action = cloneAction(action); + if (ignoreList.includes(action.type)) { + action = {}; + } + + if (action.type === "PAUSED") { + action = formatPause(action); + } + + const serializer = function (key, value) { + // Serialize Object/LongString fronts + if (value?.getGrip) { + return value.getGrip(); + } + return value; + }; + + // dump(`> ${action.type}...\n ${JSON.stringify(action, serializer)}\n`); + return JSON.stringify(action, serializer); + } catch (e) { + console.error(e); + return ""; + } +} + +/** + * A middleware that logs all actions coming through the system + * to the console. + */ +export function log({ dispatch, getState }) { + return next => action => { + const asyncMsg = !action.status ? "" : `[${action.status}]`; + + if (prefs.logActions) { + if (flags.testing) { + dump( + `[ACTION] ${action.type} ${asyncMsg} - ${serializeAction(action)}\n` + ); + } else { + console.log(action, asyncMsg); + } + } + + next(action); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/moz.build b/devtools/client/debugger/src/actions/utils/middleware/moz.build new file mode 100644 index 0000000000..f46a0bb725 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/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( + "context.js", + "log.js", + "promise.js", + "thunk.js", + "timing.js", + "wait-service.js", +) diff --git a/devtools/client/debugger/src/actions/utils/middleware/promise.js b/devtools/client/debugger/src/actions/utils/middleware/promise.js new file mode 100644 index 0000000000..52054a1fcc --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/promise.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { executeSoon } from "../../../utils/DevToolsUtils"; + +import { pending, rejected, fulfilled } from "../../../utils/async-value"; +export function asyncActionAsValue(action) { + if (action.status === "start") { + return pending(); + } + if (action.status === "error") { + return rejected(action.error); + } + return fulfilled(action.value); +} + +let seqIdVal = 1; + +function seqIdGen() { + return seqIdVal++; +} + +function promiseMiddleware({ dispatch, getState }) { + return next => action => { + if (!(PROMISE in action)) { + return next(action); + } + + const seqId = seqIdGen().toString(); + const { [PROMISE]: promiseInst, ...originalActionProperties } = action; + + // Create a new action that doesn't have the promise field and has + // the `seqId` field that represents the sequence id + action = { ...originalActionProperties, seqId }; + + dispatch({ ...action, status: "start" }); + + // Return the promise so action creators can still compose if they + // want to. + return Promise.resolve(promiseInst) + .finally(() => new Promise(resolve => executeSoon(resolve))) + .then( + value => { + dispatch({ ...action, status: "done", value: value }); + return value; + }, + error => { + dispatch({ + ...action, + status: "error", + error: error.message || error, + }); + return Promise.reject(error); + } + ); + }; +} + +export const PROMISE = "@@dispatch/promise"; +export { promiseMiddleware as promise }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/thunk.js b/devtools/client/debugger/src/actions/utils/middleware/thunk.js new file mode 100644 index 0000000000..fba17d516c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/thunk.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/>. */ + +/** + * A middleware that allows thunks (functions) to be dispatched. If + * it's a thunk, it is called with an argument that contains + * `dispatch`, `getState`, and any additional args passed in via the + * middleware constructure. This allows the action to create multiple + * actions (most likely asynchronously). + */ +export function thunk(makeArgs) { + return ({ dispatch, getState }) => { + const args = { dispatch, getState }; + + return next => action => { + return typeof action === "function" + ? action(makeArgs ? makeArgs(args, getState()) : args) + : next(action); + }; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/timing.js b/devtools/client/debugger/src/actions/utils/middleware/timing.js new file mode 100644 index 0000000000..d0bfa05977 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/timing.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Redux middleware that sets performance markers for all actions such that they + * will appear in performance tooling under the User Timing API + */ + +const mark = window.performance?.mark + ? window.performance.mark.bind(window.performance) + : a => {}; + +const measure = window.performance?.measure + ? window.performance.measure.bind(window.performance) + : (a, b, c) => {}; + +export function timing(store) { + return next => action => { + mark(`${action.type}_start`); + const result = next(action); + mark(`${action.type}_end`); + measure(`${action.type}`, `${action.type}_start`, `${action.type}_end`); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/wait-service.js b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js new file mode 100644 index 0000000000..337df7e336 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * A middleware which acts like a service, because it is stateful + * and "long-running" in the background. It provides the ability + * for actions to install a function to be run once when a specific + * condition is met by an action coming through the system. Think of + * it as a thunk that blocks until the condition is met. Example: + * + * ```js + * const services = { WAIT_UNTIL: require('wait-service').NAME }; + * + * { type: services.WAIT_UNTIL, + * predicate: action => action.type === "ADD_ITEM", + * run: (dispatch, getState, action) => { + * // Do anything here. You only need to accept the arguments + * // if you need them. `action` is the action that satisfied + * // the predicate. + * } + * } + * ``` + */ +export const NAME = "@@service/waitUntil"; + +export function waitUntilService({ dispatch, getState }) { + let pending = []; + + function checkPending(action) { + const readyRequests = []; + const stillPending = []; + + // Find the pending requests whose predicates are satisfied with + // this action. Wait to run the requests until after we update the + // pending queue because the request handler may synchronously + // dispatch again and run this service (that use case is + // completely valid). + for (const request of pending) { + if (request.predicate(action)) { + readyRequests.push(request); + } else { + stillPending.push(request); + } + } + + pending = stillPending; + for (const request of readyRequests) { + request.run(dispatch, getState, action); + } + } + + return next => action => { + if (action.type === NAME) { + pending.push(action); + return null; + } + const result = next(action); + checkPending(action); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/moz.build b/devtools/client/debugger/src/actions/utils/moz.build new file mode 100644 index 0000000000..08a43a218c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "middleware", +] + +CompiledModules( + "create-store.js", +) diff --git a/devtools/client/debugger/src/client/README.md b/devtools/client/debugger/src/client/README.md new file mode 100644 index 0000000000..4681a4e15e --- /dev/null +++ b/devtools/client/debugger/src/client/README.md @@ -0,0 +1,47 @@ +# DevTools Client + +The DevTools client is responsible for managing the communication between the +client application and JS server. + +- When the server sends a notification to the client, the client receives an + "event" and notifies the application via redux actions. +- When the application, wants to send a command to the server, it invokes + "commands" in the client. + +The Debugger supports a Firefox and a Chrome client, which lets it attach and +debug Firefox, Chrome, and Node contexts. The clients are defined in +`src/client` and have an `onConnect` function, and a `commands` and `events` +module. + +Both clients implement client adapters for translating commands and events into +JSON packets. The chrome client debugger adapter is defined in +[chrome-remote-interface][chrome-remote-interface]. The Firefox client is maintained in +[devtools-client.js][devtools-client.js]. + +## Firefox + +### Remote Debugger Protocol + +The [Remote Debugger Protocol][protocol] specifies the client / server API. + +### Interrupt + +When the client wants to add a breakpoint, it avoids race conditions by doing +temporary pauses called interrupts. + +We want to do these interrupts transparently, so we've decided that the client +should not notify the application that the thread has been paused or resumed. + +[protocol]: https://searchfox.org/mozilla-central/source/devtools/docs/backend/protocol.md +[devtools-client.js]: https://searchfox.org/mozilla-central/source/devtools/client/devtools-client.js + +## Chrome + +### Chrome Debugger Protocol + +The chrome debugger protocol is available [here][devtools-protocol-viewer]. And +is maintained in the devtools-protocol [repo][devtools-protocol-gh]. + +[chrome-remote-interface]: https://github.com/cyrus-and/chrome-remote-interface +[devtools-protocol-viewer]: https://chromedevtools.github.io/devtools-protocol/ +[devtools-protocol-gh]: https://github.com/ChromeDevTools/devtools-protocol diff --git a/devtools/client/debugger/src/client/firefox.js b/devtools/client/debugger/src/client/firefox.js new file mode 100644 index 0000000000..d66d168e37 --- /dev/null +++ b/devtools/client/debugger/src/client/firefox.js @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { setupCommands, clientCommands } from "./firefox/commands"; +import { setupCreate, createPause } from "./firefox/create"; +import { features } from "../utils/prefs"; + +import { recordEvent } from "../utils/telemetry"; +import sourceQueue from "../utils/source-queue"; +import { getContext } from "../selectors"; + +let actions; +let commands; +let targetCommand; +let resourceCommand; + +export async function onConnect(_commands, _resourceCommand, _actions, store) { + actions = _actions; + commands = _commands; + targetCommand = _commands.targetCommand; + resourceCommand = _resourceCommand; + + setupCommands(commands); + setupCreate({ store }); + + sourceQueue.initialize(actions); + + const { descriptorFront } = commands; + const { targetFront } = targetCommand; + + // For tab, browser and webextension toolboxes, we want to enable watching for + // worker targets as soon as the debugger is opened. + // And also for service workers, if the related experimental feature is enabled + if ( + descriptorFront.isTabDescriptor || + descriptorFront.isWebExtensionDescriptor || + descriptorFront.isBrowserProcessDescriptor + ) { + targetCommand.listenForWorkers = true; + if (descriptorFront.isLocalTab && features.windowlessServiceWorkers) { + targetCommand.listenForServiceWorkers = true; + targetCommand.destroyServiceWorkersOnNavigation = true; + } + await targetCommand.startListening(); + } + + const options = { + // `pauseWorkersUntilAttach` is one option set when the debugger panel is opened rather that from the toolbox. + // The reason is to support early breakpoints in workers, which will force the workers to pause + // and later on (when TargetMixin.attachThread is called) resume worker execution, after passing the breakpoints. + // We only observe workers when the debugger panel is opened (see the few lines before and listenForWorkers = true). + // So if we were passing `pauseWorkersUntilAttach=true` from the toolbox code, workers would freeze as we would not watch + // for their targets and not resume them. + pauseWorkersUntilAttach: true, + + // Bug 1719615 - Immediately turn on WASM debugging when the debugger opens. + // We avoid enabling that as soon as DevTools open as WASM generates different kind of machine code + // with debugging instruction which significantly increase the memory usage. + observeWasm: true, + }; + await commands.threadConfigurationCommand.updateConfiguration(options); + + // Select the top level target by default + await actions.selectThread( + getContext(store.getState()), + targetFront.threadFront.actor + ); + + await targetCommand.watchTargets({ + types: targetCommand.ALL_TYPES, + onAvailable: onTargetAvailable, + onDestroyed: onTargetDestroyed, + }); + + // Use independant listeners for SOURCE and THREAD_STATE in order to ease + // doing batching and notify about a set of SOURCE's in one redux action. + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: onSourceAvailable, + }); + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: onThreadStateAvailable, + }); + await resourceCommand.watchResources([resourceCommand.TYPES.TRACING_STATE], { + onAvailable: onTracingStateAvailable, + }); + + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable: actions.addExceptionFromResources, + }); + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: onDocumentEventAvailable, + // we only care about future events for DOCUMENT_EVENT + ignoreExistingResources: true, + }); +} + +export function onDisconnect() { + targetCommand.unwatchTargets({ + types: targetCommand.ALL_TYPES, + onAvailable: onTargetAvailable, + onDestroyed: onTargetDestroyed, + }); + resourceCommand.unwatchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: onSourceAvailable, + }); + resourceCommand.unwatchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: onThreadStateAvailable, + }); + resourceCommand.unwatchResources([resourceCommand.TYPES.TRACING_STATE], { + onAvailable: onTracingStateAvailable, + }); + resourceCommand.unwatchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable: actions.addExceptionFromResources, + }); + resourceCommand.unwatchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: onDocumentEventAvailable, + }); + sourceQueue.clear(); +} + +async function onTargetAvailable({ targetFront, isTargetSwitching }) { + const isBrowserToolbox = commands.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + // Do not attach the thread here, as it was already done by the + // corresponding content-process target. + return; + } + + if (!targetFront.isTopLevel) { + await actions.addTarget(targetFront); + return; + } + + // At this point, we expect the target and its thread to be attached. + const { threadFront } = targetFront; + if (!threadFront) { + console.error("The thread for", targetFront, "isn't attached."); + return; + } + + // Retrieve possible event listener breakpoints + actions.getEventListenerBreakpointTypes().catch(e => console.error(e)); + + // Initialize the event breakpoints on the thread up front so that + // they are active once attached. + actions.addEventListenerBreakpoints([]).catch(e => console.error(e)); + + await actions.addTarget(targetFront); +} + +function onTargetDestroyed({ targetFront }) { + actions.removeTarget(targetFront); +} + +async function onSourceAvailable(sources) { + await actions.newGeneratedSources(sources); +} + +async function onThreadStateAvailable(resources) { + for (const resource of resources) { + if (resource.targetFront.isDestroyed()) { + continue; + } + const threadFront = await resource.targetFront.getFront("thread"); + if (resource.state == "paused") { + const pause = await createPause(threadFront.actor, resource); + await actions.paused(pause); + recordEvent("pause", { reason: resource.why.type }); + } else if (resource.state == "resumed") { + await actions.resumed(threadFront.actorID); + } + } +} + +async function onTracingStateAvailable(resources) { + for (const resource of resources) { + if (resource.targetFront.isDestroyed()) { + continue; + } + const threadFront = await resource.targetFront.getFront("thread"); + await actions.tracingToggled(threadFront.actor, resource.enabled); + } +} + +function onDocumentEventAvailable(events) { + for (const event of events) { + // Only consider top level document, and ignore remote iframes top document + if (!event.targetFront.isTopLevel) continue; + // The browser toolbox debugger doesn't support the iframe dropdown. + // you will always see all the sources of all targets of your debugging context. + // + // But still allow it to clear the debugger when reloading the addon, or when + // switching between fallback document and other addon document. + if ( + event.isFrameSwitching && + !commands.descriptorFront.isWebExtensionDescriptor + ) { + continue; + } + if (event.name == "will-navigate") { + actions.willNavigate({ url: event.newURI }); + } else if (event.name == "dom-complete") { + actions.navigated(); + } + } +} + +export { clientCommands }; diff --git a/devtools/client/debugger/src/client/firefox/commands.js b/devtools/client/debugger/src/client/firefox/commands.js new file mode 100644 index 0000000000..06f9d73854 --- /dev/null +++ b/devtools/client/debugger/src/client/firefox/commands.js @@ -0,0 +1,537 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createFrame } from "./create"; +import { makeBreakpointServerLocationId } from "../../utils/breakpoint"; + +import Reps from "devtools/client/shared/components/reps/index"; + +let commands; +let breakpoints; + +// The maximal number of stackframes to retrieve when pausing +const CALL_STACK_PAGE_SIZE = 1000; + +function setupCommands(innerCommands) { + commands = innerCommands; + breakpoints = {}; +} + +function currentTarget() { + return commands.targetCommand.targetFront; +} + +function currentThreadFront() { + return currentTarget().threadFront; +} + +/** + * Create an object front for the passed grip + * + * @param {Object} grip + * @param {Object} frame: An optional frame that will manage the created object front. + * if not passed, the current thread front will manage the object. + * @returns {ObjectFront} + */ +function createObjectFront(grip, frame) { + if (!grip.actor) { + throw new Error("Actor is missing"); + } + const threadFront = frame?.thread + ? lookupThreadFront(frame.thread) + : currentThreadFront(); + const frameFront = frame ? threadFront.getActorByID(frame.id) : null; + return commands.client.createObjectFront(grip, threadFront, frameFront); +} + +async function loadObjectProperties(root, threadActorID) { + const { utils } = Reps.objectInspector; + const properties = await utils.loadProperties.loadItemProperties( + root, + commands.client, + undefined, + threadActorID + ); + return utils.node.getChildren({ + item: root, + loadedProperties: new Map([[root.path, properties]]), + }); +} + +function releaseActor(actor) { + if (!actor) { + return Promise.resolve(); + } + const objFront = commands.client.getFrontByID(actor); + + if (!objFront) { + return Promise.resolve(); + } + + return objFront.release().catch(() => {}); +} + +function lookupTarget(thread) { + if (thread == currentThreadFront().actor) { + return currentTarget(); + } + + const targets = commands.targetCommand.getAllTargets( + commands.targetCommand.ALL_TYPES + ); + return targets.find(target => target.targetForm.threadActor == thread); +} + +function lookupThreadFront(thread) { + const target = lookupTarget(thread); + return target.threadFront; +} + +function listThreadFronts() { + const targets = commands.targetCommand.getAllTargets( + commands.targetCommand.ALL_TYPES + ); + return targets.map(target => target.threadFront).filter(front => !!front); +} + +function forEachThread(iteratee) { + // We have to be careful here to atomically initiate the operation on every + // thread, with no intervening await. Otherwise, other code could run and + // trigger additional thread operations. Requests on server threads will + // resolve in FIFO order, and this could result in client and server state + // going out of sync. + + const promises = listThreadFronts().map( + // If a thread shuts down while sending the message then it will + // throw. Ignore these exceptions. + t => iteratee(t).catch(e => console.log(e)) + ); + + return Promise.all(promises); +} + +/** + * Start JavaScript tracing for all targets. + * + * @param {String} logMethod + * Where to log the traces. Can be stdout or console. + */ +async function startTracing(logMethod) { + const targets = commands.targetCommand.getAllTargets( + commands.targetCommand.ALL_TYPES + ); + await Promise.all( + targets.map(async targetFront => { + const tracerFront = await targetFront.getFront("tracer"); + return tracerFront.startTracing(logMethod); + }) + ); +} + +/** + * Stop JavaScript tracing for all targets. + */ +async function stopTracing() { + const targets = commands.targetCommand.getAllTargets( + commands.targetCommand.ALL_TYPES + ); + await Promise.all( + targets.map(async targetFront => { + const tracerFront = await targetFront.getFront("tracer"); + return tracerFront.stopTracing(); + }) + ); +} + +function resume(thread, frameId) { + return lookupThreadFront(thread).resume(); +} + +function stepIn(thread, frameId) { + return lookupThreadFront(thread).stepIn(frameId); +} + +function stepOver(thread, frameId) { + return lookupThreadFront(thread).stepOver(frameId); +} + +function stepOut(thread, frameId) { + return lookupThreadFront(thread).stepOut(frameId); +} + +function restart(thread, frameId) { + return lookupThreadFront(thread).restart(frameId); +} + +function breakOnNext(thread) { + return lookupThreadFront(thread).breakOnNext(); +} + +async function sourceContents({ actor, thread }) { + const sourceThreadFront = lookupThreadFront(thread); + const sourceFront = sourceThreadFront.source({ actor }); + const { source, contentType } = await sourceFront.source(); + return { source, contentType }; +} + +async function setXHRBreakpoint(path, method) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (!hasWatcherSupport) { + // Without watcher support, forward setXHRBreakpoint to all threads. + await forEachThread(thread => thread.setXHRBreakpoint(path, method)); + return; + } + const breakpointsFront = + await commands.targetCommand.watcherFront.getBreakpointListActor(); + await breakpointsFront.setXHRBreakpoint(path, method); +} + +async function removeXHRBreakpoint(path, method) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (!hasWatcherSupport) { + // Without watcher support, forward removeXHRBreakpoint to all threads. + await forEachThread(thread => thread.removeXHRBreakpoint(path, method)); + return; + } + const breakpointsFront = + await commands.targetCommand.watcherFront.getBreakpointListActor(); + await breakpointsFront.removeXHRBreakpoint(path, method); +} + +export function toggleJavaScriptEnabled(enabled) { + return commands.targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: enabled, + }); +} + +async function addWatchpoint(object, property, label, watchpointType) { + if (!currentTarget().getTrait("watchpoints")) { + return; + } + const objectFront = createObjectFront(object); + await objectFront.addWatchpoint(property, label, watchpointType); +} + +async function removeWatchpoint(object, property) { + if (!currentTarget().getTrait("watchpoints")) { + return; + } + const objectFront = createObjectFront(object); + await objectFront.removeWatchpoint(property); +} + +function hasBreakpoint(location) { + return !!breakpoints[makeBreakpointServerLocationId(location)]; +} + +function getServerBreakpointsList() { + return Object.values(breakpoints); +} + +async function setBreakpoint(location, options) { + const breakpoint = breakpoints[makeBreakpointServerLocationId(location)]; + if ( + breakpoint && + JSON.stringify(breakpoint.options) == JSON.stringify(options) + ) { + return null; + } + breakpoints[makeBreakpointServerLocationId(location)] = { location, options }; + + // Map frontend options to a more restricted subset of what + // the server supports. For example frontend uses `hidden` attribute + // which isn't meant to be passed to the server. + // (note that protocol.js specification isn't enough to filter attributes, + // all primitive attributes will be passed as-is) + const serverOptions = { + condition: options.condition, + logValue: options.logValue, + }; + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (!hasWatcherSupport) { + // Without watcher support, unconditionally forward setBreakpoint to all threads. + return forEachThread(async thread => + thread.setBreakpoint(location, serverOptions) + ); + } + const breakpointsFront = + await commands.targetCommand.watcherFront.getBreakpointListActor(); + await breakpointsFront.setBreakpoint(location, serverOptions); + + // Call setBreakpoint for threads linked to targets + // not managed by the watcher. + return forEachThread(async thread => { + if ( + !commands.targetCommand.hasTargetWatcherSupport( + thread.targetFront.targetType + ) + ) { + return thread.setBreakpoint(location, serverOptions); + } + + return Promise.resolve(); + }); +} + +async function removeBreakpoint(location) { + delete breakpoints[makeBreakpointServerLocationId(location)]; + + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (!hasWatcherSupport) { + // Without watcher support, unconditionally forward removeBreakpoint to all threads. + return forEachThread(async thread => thread.removeBreakpoint(location)); + } + const breakpointsFront = + await commands.targetCommand.watcherFront.getBreakpointListActor(); + await breakpointsFront.removeBreakpoint(location); + + // Call removeBreakpoint for threads linked to targets + // not managed by the watcher. + return forEachThread(async thread => { + if ( + !commands.targetCommand.hasTargetWatcherSupport( + thread.targetFront.targetType + ) + ) { + return thread.removeBreakpoint(location); + } + + return Promise.resolve(); + }); +} + +async function evaluateExpressions(scripts, options) { + return Promise.all(scripts.map(script => evaluate(script, options))); +} + +async function evaluate(script, { frameId, threadId } = {}) { + if (!currentTarget() || !script) { + return { result: null }; + } + + const selectedTargetFront = threadId ? lookupTarget(threadId) : null; + + return commands.scriptCommand.execute(script, { + frameActor: frameId, + selectedTargetFront, + }); +} + +async function autocomplete(input, cursor, frameId) { + if (!currentTarget() || !input) { + return {}; + } + const consoleFront = await currentTarget().getFront("console"); + if (!consoleFront) { + return {}; + } + + return new Promise(resolve => { + consoleFront.autocomplete( + input, + cursor, + result => resolve(result), + frameId + ); + }); +} + +function getProperties(thread, grip) { + const objClient = lookupThreadFront(thread).pauseGrip(grip); + + return objClient.getPrototypeAndProperties().then(resp => { + const { ownProperties, safeGetterValues } = resp; + for (const name in safeGetterValues) { + const { enumerable, writable, getterValue } = safeGetterValues[name]; + ownProperties[name] = { enumerable, writable, value: getterValue }; + } + return resp; + }); +} + +async function getFrames(thread) { + const threadFront = lookupThreadFront(thread); + const response = await threadFront.getFrames(0, CALL_STACK_PAGE_SIZE); + + return Promise.all( + response.frames.map((frame, i) => createFrame(thread, frame, i)) + ); +} + +async function getFrameScopes(frame) { + const frameFront = lookupThreadFront(frame.thread).getActorByID(frame.id); + return frameFront.getEnvironment(); +} + +async function pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions +) { + await commands.threadConfigurationCommand.updateConfiguration({ + pauseOnExceptions: shouldPauseOnExceptions, + ignoreCaughtExceptions: !shouldPauseOnCaughtExceptions, + }); +} + +async function blackBox(sourceActor, shouldBlackBox, ranges) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (hasWatcherSupport) { + const blackboxingFront = + await commands.targetCommand.watcherFront.getBlackboxingActor(); + if (shouldBlackBox) { + await blackboxingFront.blackbox(sourceActor.url, ranges); + } else { + await blackboxingFront.unblackbox(sourceActor.url, ranges); + } + } else { + const sourceFront = currentThreadFront().source({ + actor: sourceActor.actor, + }); + // If there are no ranges, the whole source is being blackboxed + if (!ranges.length) { + await toggleBlackBoxSourceFront(sourceFront, shouldBlackBox); + return; + } + // Blackbox the specific ranges + for (const range of ranges) { + await toggleBlackBoxSourceFront(sourceFront, shouldBlackBox, range); + } + } +} + +async function toggleBlackBoxSourceFront(sourceFront, shouldBlackBox, range) { + if (shouldBlackBox) { + await sourceFront.blackBox(range); + } else { + await sourceFront.unblackBox(range); + } +} + +async function setSkipPausing(shouldSkip) { + await commands.threadConfigurationCommand.updateConfiguration({ + skipBreakpoints: shouldSkip, + }); +} + +async function setEventListenerBreakpoints(ids) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (!hasWatcherSupport) { + await forEachThread(thread => thread.setActiveEventBreakpoints(ids)); + return; + } + const breakpointListFront = + await commands.targetCommand.watcherFront.getBreakpointListActor(); + await breakpointListFront.setActiveEventBreakpoints(ids); +} + +async function getEventListenerBreakpointTypes() { + return currentThreadFront().getAvailableEventBreakpoints(); +} + +function pauseGrip(thread, func) { + return lookupThreadFront(thread).pauseGrip(func); +} + +async function toggleEventLogging(logEventBreakpoints) { + await commands.threadConfigurationCommand.updateConfiguration({ + logEventBreakpoints, + }); +} + +function getMainThread() { + return currentThreadFront().actor; +} + +async function getSourceActorBreakpointPositions({ thread, actor }, range) { + const sourceThreadFront = lookupThreadFront(thread); + const sourceFront = sourceThreadFront.source({ actor }); + return sourceFront.getBreakpointPositionsCompressed(range); +} + +async function getSourceActorBreakableLines({ thread, actor }) { + let actorLines = []; + try { + const sourceThreadFront = lookupThreadFront(thread); + const sourceFront = sourceThreadFront.source({ actor }); + actorLines = await sourceFront.getBreakableLines(); + } catch (e) { + // Exceptions could be due to the target thread being shut down. + console.warn(`getSourceActorBreakableLines failed: ${e}`); + } + + return actorLines; +} + +function getFrontByID(actorID) { + return commands.client.getFrontByID(actorID); +} + +function fetchAncestorFramePositions(index) { + currentThreadFront().fetchAncestorFramePositions(index); +} + +async function setOverride(url, path) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (hasWatcherSupport) { + const networkFront = + await commands.targetCommand.watcherFront.getNetworkParentActor(); + return networkFront.override(url, path); + } + return null; +} + +async function removeOverride(url) { + const hasWatcherSupport = commands.targetCommand.hasTargetWatcherSupport(); + if (hasWatcherSupport) { + const networkFront = + await commands.targetCommand.watcherFront.getNetworkParentActor(); + networkFront.removeOverride(url); + } +} + +const clientCommands = { + autocomplete, + blackBox, + createObjectFront, + loadObjectProperties, + releaseActor, + pauseGrip, + startTracing, + stopTracing, + resume, + stepIn, + stepOut, + stepOver, + restart, + breakOnNext, + sourceContents, + getSourceActorBreakpointPositions, + getSourceActorBreakableLines, + hasBreakpoint, + getServerBreakpointsList, + setBreakpoint, + setXHRBreakpoint, + removeXHRBreakpoint, + addWatchpoint, + removeWatchpoint, + removeBreakpoint, + evaluate, + evaluateExpressions, + getProperties, + getFrameScopes, + getFrames, + pauseOnExceptions, + toggleEventLogging, + getMainThread, + setSkipPausing, + setEventListenerBreakpoints, + getEventListenerBreakpointTypes, + getFrontByID, + fetchAncestorFramePositions, + toggleJavaScriptEnabled, + setOverride, + removeOverride, +}; + +export { setupCommands, clientCommands }; diff --git a/devtools/client/debugger/src/client/firefox/create.js b/devtools/client/debugger/src/client/firefox/create.js new file mode 100644 index 0000000000..97976aa358 --- /dev/null +++ b/devtools/client/debugger/src/client/firefox/create.js @@ -0,0 +1,392 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// This module converts Firefox specific types to the generic types + +import { + hasSource, + hasSourceActor, + getSourceActor, + getSourceCount, +} from "../../selectors"; +import { features } from "../../utils/prefs"; +import { isUrlExtension } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { getDisplayURL } from "../../utils/sources-tree/getURL"; + +let store; + +/** + * This function is to be called first before any other + * and allow having access to any instances of classes that are + * useful for this module + * + * @param {Object} dependencies + * @param {Object} dependencies.store + * The redux store object of the debugger frontend. + */ +export function setupCreate(dependencies) { + store = dependencies.store; +} + +export async function createFrame(thread, frame, index = 0) { + if (!frame) { + return null; + } + + // Because of throttling, the source may be available a bit late. + const sourceActor = await waitForSourceActorToBeRegisteredInStore( + frame.where.actor + ); + + const location = createLocation({ + source: sourceActor.sourceObject, + sourceActor, + line: frame.where.line, + column: frame.where.column, + }); + + return { + id: frame.actorID, + thread, + displayName: frame.displayName, + location, + generatedLocation: location, + this: frame.this, + source: null, + index, + asyncCause: frame.asyncCause, + state: frame.state, + type: frame.type, + }; +} + +/** + * This method wait for the given source actor to be registered in Redux store. + * + * @param {String} sourceActorId + * Actor ID of the source to be waiting for. + */ +async function waitForSourceActorToBeRegisteredInStore(sourceActorId) { + if (!hasSourceActor(store.getState(), sourceActorId)) { + await new Promise(resolve => { + const unsubscribe = store.subscribe(check); + let currentSize = null; + function check() { + const previousSize = currentSize; + currentSize = store.getState().sourceActors.mutableSourceActors.size; + // For perf reason, avoid any extra computation if sources did not change + if (previousSize == currentSize) { + return; + } + if (hasSourceActor(store.getState(), sourceActorId)) { + unsubscribe(); + resolve(); + } + } + }); + } + return getSourceActor(store.getState(), sourceActorId); +} + +/** + * This method wait for the given source to be registered in Redux store. + * + * @param {String} sourceId + * The id of the source to be waiting for. + */ +export async function waitForSourceToBeRegisteredInStore(sourceId) { + return new Promise(resolve => { + if (hasSource(store.getState(), sourceId)) { + resolve(); + return; + } + const unsubscribe = store.subscribe(check); + let currentSize = null; + function check() { + const previousSize = currentSize; + currentSize = getSourceCount(store.getState()); + // For perf reason, avoid any extra computation if sources did not change + if (previousSize == currentSize) { + return; + } + if (hasSource(store.getState(), sourceId)) { + unsubscribe(); + resolve(); + } + } + }); +} + +// Compute the reducer's source ID for a given source front/resource. +// +// We have four kind of "sources": +// * "sources" in sources.js reducer, which map to 1 or many: +// * "source actors" in source-actors.js reducer, which map 1 for 1 with: +// * "SOURCE" resources coming from ResourceCommand API +// * SourceFront, which are retrieved via `ThreadFront.source(sourceResource)` +// +// Note that SOURCE resources are actually the "form" of the SourceActor, +// with the addition of `resourceType` and `targetFront` attributes. +// +// Unfortunately, the debugger frontend interacts with these 4 type of objects. +// The last three actually try to represent the exact same thing. +// +// Here this method received a SOURCE resource (the 3rd bullet point) +export function makeSourceId(sourceResource) { + // Allows Jest to use custom, simplier IDs + if ("mockedJestID" in sourceResource) { + return sourceResource.mockedJestID; + } + // By default, within a given target, all sources will be grouped by URL. + // You will be having a unique Source object in sources.js reducer, + // while you might have many Source Actor objects in source-actors.js reducer. + // + // There is two distinct usecases here: + // * HTML pages, which will have one source object which represents the whole HTML page + // and it will relate to many source actors. One for each inline <script> tag. + // Each script tag's source actor will actually return the whole content of the html page + // and not only this one script tag content. + // * Scripts with the same URL injected many times. + // For example, two <script src=""> with the same location + // Or by using eval("...// # SourceURL=") + // All the scripts will be grouped under a unique Source object, while having dedicated + // Source Actor objects. + // An important point this time is that each actor may have a different source text content. + // For now, the debugger arbitrarily picks the first source actor's text content and never + // updates it. (See bug 1751063) + if (sourceResource.url) { + return `source-url-${sourceResource.url}`; + } + + // Otherwise, we are processing a source without URL. + // This is typically evals, console evaluations, setTimeout/setInterval strings, + // DOM event handler strings (i.e. `<div onclick="foo">`), ... + // The main way to interact with them is to use a debugger statement from them, + // or have other panels ask the debugger to open them (like DOM event handlers from the inspector). + // We can register transient breakpoints against them (i.e. they will only apply to the current source actor instance) + return `source-actor-${sourceResource.actor}`; +} + +/** + * Create the source object for a generated source that is stored in sources.js reducer. + * These generated sources relate to JS code which run in the + * debugged runtime (as oppose to original sources + * which are only available in debugger's environment). + * + * @param {SOURCE} sourceResource + * SOURCE resource coming from the ResourceCommand API. + * This represents the `SourceActor` from the server codebase. + */ +export function createGeneratedSource(sourceResource) { + return createSourceObject({ + id: makeSourceId(sourceResource), + url: sourceResource.url, + extensionName: sourceResource.extensionName, + isWasm: !!features.wasm && sourceResource.introductionType === "wasm", + isExtension: + (sourceResource.url && isUrlExtension(sourceResource.url)) || false, + isHTML: !!sourceResource.isInlineSource, + }); +} + +/** + * Create the source object that is stored in sources.js reducer. + * + * This is an internal helper to this module to ensure all sources have the same shape. + * Do not use it outside of this module! + */ +function createSourceObject({ + id, + url, + extensionName = null, + isWasm = false, + isExtension = false, + isPrettyPrinted = false, + isOriginal = false, + isHTML = false, +}) { + return { + // The ID, computed by: + // * `makeSourceId` for generated, + // * `generatedToOriginalId` for both source map and pretty printed original, + id, + + // Absolute URL for the source. This may be a fake URL for pretty printed sources + url, + + // A (slightly tweaked) URL object to represent the source URL. + // The URL object is augmented of a "group" attribute and some other standard attributes + // are modified from their typical value. See getDisplayURL implementation. + displayURL: getDisplayURL(url, extensionName), + + // Only set for generated sources that are WebExtension sources. + // This is especially useful to display the extension name for content scripts + // that executes against the page we are debugging. + extensionName, + + // Will be true if the source URL starts with moz-extension://, + // which most likely means the source is a content script. + // (Note that when debugging an add-on all generated sources will most likely have this flag set to true) + isExtension, + + // True if WASM is enabled *and* the generated source is a WASM source + isWasm, + + // True if this source is an HTML and relates to many sources actors, + // one for each of its inline <script> + isHTML, + + // True, if this is an original pretty printed source + isPrettyPrinted, + + // True for source map original files, as well as pretty printed sources + isOriginal, + }; +} + +/** + * Create the source object for a source mapped original source that is stored in sources.js reducer. + * These original sources referred to by source maps. + * This isn't code that runs in the runtime, so it isn't associated with anything + * on the server side. It is associated with a generated source for the related bundle file + * which itself relates to an actual code that runs in the runtime. + * + * @param {String} id + * The ID of the source, computed by source map codebase. + * @param {String} url + * The URL of the original source file. + */ +export function createSourceMapOriginalSource(id, url) { + return createSourceObject({ + id, + url, + isOriginal: true, + }); +} + +/** + * Create the source object for a pretty printed original source that is stored in sources.js reducer. + * These original pretty printed sources aren't code that run in the runtime, + * so it isn't associated with anything on the server side. + * It is associated with a generated source for the non-pretty-printed file + * which itself relates to an actual code that runs in the runtime. + * + * @param {String} id + * The ID of the source, computed by pretty print. + * @param {String} url + * The URL of the pretty-printed source file. + * This URL doesn't work. It is the URL of the non-pretty-printed file with ":formated" suffix. + */ +export function createPrettyPrintOriginalSource(id, url) { + return createSourceObject({ + id, + url, + isOriginal: true, + isPrettyPrinted: true, + }); +} + +/** + * Create the "source actor" object that is stored in source-actor.js reducer. + * This will represent server's source actor in the reducer universe. + * + * @param {SOURCE} sourceResource + * SOURCE resource coming from the ResourceCommand API. + * This represents the `SourceActor` from the server codebase. + * @param {Object} sourceObject + * Source object stored in redux, i.e. created via createSourceObject. + */ +export function createSourceActor(sourceResource, sourceObject) { + const actorId = sourceResource.actor; + + return { + id: actorId, + actor: actorId, + // As sourceResource is only SourceActor's form and not the SourceFront, + // we have to go through the target to retrieve the related ThreadActor's ID. + thread: sourceResource.targetFront.getCachedFront("thread").actorID, + // `source` is the reducer source ID + source: makeSourceId(sourceResource), + sourceObject, + sourceMapBaseURL: sourceResource.sourceMapBaseURL, + sourceMapURL: sourceResource.sourceMapURL, + url: sourceResource.url, + introductionType: sourceResource.introductionType, + sourceStartLine: sourceResource.sourceStartLine, + sourceStartColumn: sourceResource.sourceStartColumn, + sourceLength: sourceResource.sourceLength, + }; +} + +export async function createPause(thread, packet) { + const frame = await createFrame(thread, packet.frame); + return { + ...packet, + thread, + frame, + }; +} + +export function createThread(targetFront) { + const name = targetFront.isTopLevel + ? L10N.getStr("mainThread") + : targetFront.name; + + return { + actor: targetFront.targetForm.threadActor, + url: targetFront.url, + isTopLevel: targetFront.isTopLevel, + targetType: targetFront.targetType, + name, + serviceWorkerStatus: targetFront.debuggerServiceWorkerStatus, + isWebExtension: targetFront.isWebExtension, + processID: targetFront.processID, + }; +} + +/** + * Defines the shape of a breakpoint + */ +export function createBreakpoint({ + id, + thread, + disabled = false, + options = {}, + location, + generatedLocation, + text, + originalText, +}) { + return { + // The unique identifier (string) for the breakpoint, for details on its format and creation See `makeBreakpointId` + id, + + // The thread actor id (string) which the source this breakpoint is created in belongs to + thread, + + // This (boolean) specifies if the breakpoint is disabled or not + disabled, + + // This (object) stores extra information about the breakpoint, which defines the type of the breakpoint (i.e conditional breakpoints, log points) + // { + // condition: <Boolean>, + // logValue: <String>, + // hidden: <Boolean> + // } + options, + + // The location (object) information for the original source, for details on its format and structure See `createLocation` + location, + + // The location (object) information for the generated source, for details on its format and structure See `createLocation` + generatedLocation, + + // The text (string) on the line which the brekpoint is set in the generated source + text, + + // The text (string) on the line which the breakpoint is set in the original source + originalText, + }; +} diff --git a/devtools/client/debugger/src/client/firefox/moz.build b/devtools/client/debugger/src/client/firefox/moz.build new file mode 100644 index 0000000000..9406133e17 --- /dev/null +++ b/devtools/client/debugger/src/client/firefox/moz.build @@ -0,0 +1,11 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "commands.js", + "create.js", +) diff --git a/devtools/client/debugger/src/client/moz.build b/devtools/client/debugger/src/client/moz.build new file mode 100644 index 0000000000..cbaaa3a2a0 --- /dev/null +++ b/devtools/client/debugger/src/client/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "firefox", +] + +CompiledModules( + "firefox.js", +) diff --git a/devtools/client/debugger/src/components/A11yIntention.css b/devtools/client/debugger/src/components/A11yIntention.css new file mode 100644 index 0000000000..e97a03ad32 --- /dev/null +++ b/devtools/client/debugger/src/components/A11yIntention.css @@ -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/. */ + +.A11y-mouse :focus { + outline: 0; +} diff --git a/devtools/client/debugger/src/components/A11yIntention.js b/devtools/client/debugger/src/components/A11yIntention.js new file mode 100644 index 0000000000..fab894b216 --- /dev/null +++ b/devtools/client/debugger/src/components/A11yIntention.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 React from "react"; +import PropTypes from "prop-types"; +import "./A11yIntention.css"; + +export default class A11yIntention extends React.Component { + static get propTypes() { + return { + children: PropTypes.array.isRequired, + }; + } + + state = { keyboard: false }; + + handleKeyDown = () => { + this.setState({ keyboard: true }); + }; + + handleMouseDown = () => { + this.setState({ keyboard: false }); + }; + + render() { + return ( + <div + className={this.state.keyboard ? "A11y-keyboard" : "A11y-mouse"} + onKeyDown={this.handleKeyDown} + onMouseDown={this.handleMouseDown} + > + {this.props.children} + </div> + ); + } +} diff --git a/devtools/client/debugger/src/components/App.css b/devtools/client/debugger/src/components/App.css new file mode 100644 index 0000000000..6a793c2f48 --- /dev/null +++ b/devtools/client/debugger/src/components/App.css @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} + +#mount { + height: 100%; +} + +button { + background: transparent; + border: none; + font-family: inherit; + font-size: inherit; +} + +button:hover, +button:focus { + background-color: var(--theme-toolbar-background-hover); +} + +.theme-dark button:hover, +.theme-dark button:focus { + background-color: var(--theme-toolbar-hover); +} + +.debugger { + display: flex; + flex: 1; + height: 100%; +} + +.debugger .tree-indent { + width: 16px; + margin-inline-start: 0; + border-inline-start: 0; +} + +.editor-pane { + display: flex; + position: relative; + flex: 1; + background-color: var(--theme-body-background); + height: 100%; + overflow: hidden; +} + +.editor-container { + width: 100%; +} + +/* Utils */ +.absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.d-flex { + display: flex; +} + +.align-items-center { + align-items: center; +} + +.rounded-circle { + border-radius: 50%; +} + +.text-white { + color: white; +} + +.text-center { + text-align: center; +} + +.min-width-0 { + min-width: 0; +} + +/* + Prevents horizontal scrollbar from displaying when + right pane collapsed (#7505) +*/ +.split-box > .splitter:last-child { + display: none; +} + +/** + * In RTL layouts, the Debugger UI overlays the splitters. See Bug 1731233. + * Note: we need to the `.debugger` prefix here to beat the specificity of the + * general rule defined in SlitBox.css for `.split-box.vert > .splitter`. + */ +.debugger .split-box.vert > .splitter { + border-left-width: var(--devtools-splitter-inline-start-width); + border-right-width: var(--devtools-splitter-inline-end-width); + + margin-left: calc(-1 * var(--devtools-splitter-inline-start-width) - 1px); + margin-right: calc(-1 * var(--devtools-splitter-inline-end-width)); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; + background: transparent; +} + +::-webkit-scrollbar-track { + border-radius: 8px; + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: 8px; + background: rgba(113, 113, 113, 0.5); +} diff --git a/devtools/client/debugger/src/components/App.js b/devtools/client/debugger/src/components/App.js new file mode 100644 index 0000000000..011d743cd9 --- /dev/null +++ b/devtools/client/debugger/src/components/App.js @@ -0,0 +1,336 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../utils/connect"; +import { prefs } from "../utils/prefs"; +import { primaryPaneTabs } from "../constants"; +import actions from "../actions"; +import A11yIntention from "./A11yIntention"; +import { ShortcutsModal } from "./ShortcutsModal"; + +import { + getSelectedSource, + getPaneCollapse, + getActiveSearch, + getQuickOpenEnabled, + getOrientation, +} from "../selectors"; + +const KeyShortcuts = require("devtools/client/shared/key-shortcuts"); +const SplitBox = require("devtools/client/shared/components/splitter/SplitBox"); +const AppErrorBoundary = require("devtools/client/shared/components/AppErrorBoundary"); + +const shortcuts = new KeyShortcuts({ window }); + +const horizontalLayoutBreakpoint = window.matchMedia("(min-width: 800px)"); +const verticalLayoutBreakpoint = window.matchMedia( + "(min-width: 10px) and (max-width: 799px)" +); + +import "./variables.css"; +import "./App.css"; + +import "./shared/menu.css"; + +import PrimaryPanes from "./PrimaryPanes"; +import Editor from "./Editor"; +import SecondaryPanes from "./SecondaryPanes"; +import WelcomeBox from "./WelcomeBox"; +import EditorTabs from "./Editor/Tabs"; +import EditorFooter from "./Editor/Footer"; +import QuickOpenModal from "./QuickOpenModal"; + +class App extends Component { + constructor(props) { + super(props); + this.state = { + shortcutsModalEnabled: false, + startPanelSize: 0, + endPanelSize: 0, + }; + } + + static get propTypes() { + return { + activeSearch: PropTypes.oneOf(["file", "project"]), + closeActiveSearch: PropTypes.func.isRequired, + closeQuickOpen: PropTypes.func.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + fluentBundles: PropTypes.array.isRequired, + openQuickOpen: PropTypes.func.isRequired, + orientation: PropTypes.oneOf(["horizontal", "vertical"]).isRequired, + quickOpenEnabled: PropTypes.bool.isRequired, + selectedSource: PropTypes.object, + setActiveSearch: PropTypes.func.isRequired, + setOrientation: PropTypes.func.isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + startPanelCollapsed: PropTypes.bool.isRequired, + toolboxDoc: PropTypes.object.isRequired, + }; + } + + getChildContext() { + return { + fluentBundles: this.props.fluentBundles, + toolboxDoc: this.props.toolboxDoc, + shortcuts, + l10n: L10N, + }; + } + + componentDidMount() { + horizontalLayoutBreakpoint.addListener(this.onLayoutChange); + verticalLayoutBreakpoint.addListener(this.onLayoutChange); + this.setOrientation(); + + shortcuts.on(L10N.getStr("symbolSearch.search.key2"), e => + this.toggleQuickOpenModal(e, "@") + ); + + [ + L10N.getStr("sources.search.key2"), + L10N.getStr("sources.search.alt.key"), + ].forEach(key => shortcuts.on(key, this.toggleQuickOpenModal)); + + shortcuts.on(L10N.getStr("gotoLineModal.key3"), e => + this.toggleQuickOpenModal(e, ":") + ); + + shortcuts.on( + L10N.getStr("projectTextSearch.key"), + this.jumpToProjectSearch + ); + + shortcuts.on("Escape", this.onEscape); + shortcuts.on("CmdOrCtrl+/", this.onCommandSlash); + } + + componentWillUnmount() { + horizontalLayoutBreakpoint.removeListener(this.onLayoutChange); + verticalLayoutBreakpoint.removeListener(this.onLayoutChange); + shortcuts.off( + L10N.getStr("symbolSearch.search.key2"), + this.toggleQuickOpenModal + ); + + [ + L10N.getStr("sources.search.key2"), + L10N.getStr("sources.search.alt.key"), + ].forEach(key => shortcuts.off(key, this.toggleQuickOpenModal)); + + shortcuts.off(L10N.getStr("gotoLineModal.key3"), this.toggleQuickOpenModal); + + shortcuts.off( + L10N.getStr("projectTextSearch.key"), + this.jumpToProjectSearch + ); + + shortcuts.off("Escape", this.onEscape); + shortcuts.off("CmdOrCtrl+/", this.onCommandSlash); + } + + jumpToProjectSearch = e => { + e.preventDefault(); + this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH); + this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH); + }; + + onEscape = e => { + const { + activeSearch, + closeActiveSearch, + closeQuickOpen, + quickOpenEnabled, + } = this.props; + const { shortcutsModalEnabled } = this.state; + + if (activeSearch) { + e.preventDefault(); + closeActiveSearch(); + } + + if (quickOpenEnabled) { + e.preventDefault(); + closeQuickOpen(); + } + + if (shortcutsModalEnabled) { + e.preventDefault(); + this.toggleShortcutsModal(); + } + }; + + onCommandSlash = () => { + this.toggleShortcutsModal(); + }; + + isHorizontal() { + return this.props.orientation === "horizontal"; + } + + toggleQuickOpenModal = (e, query) => { + const { quickOpenEnabled, openQuickOpen, closeQuickOpen } = this.props; + + e.preventDefault(); + e.stopPropagation(); + + if (quickOpenEnabled === true) { + closeQuickOpen(); + return; + } + + if (query != null) { + openQuickOpen(query); + return; + } + openQuickOpen(); + }; + + onLayoutChange = () => { + this.setOrientation(); + }; + + setOrientation() { + // If the orientation does not match (if it is not visible) it will + // not setOrientation, or if it is the same as before, calling + // setOrientation will not cause a rerender. + if (horizontalLayoutBreakpoint.matches) { + this.props.setOrientation("horizontal"); + } else if (verticalLayoutBreakpoint.matches) { + this.props.setOrientation("vertical"); + } + } + + renderEditorPane = () => { + const { startPanelCollapsed, endPanelCollapsed } = this.props; + const { endPanelSize, startPanelSize } = this.state; + const horizontal = this.isHorizontal(); + + return ( + <div className="editor-pane"> + <div className="editor-container"> + <EditorTabs + startPanelCollapsed={startPanelCollapsed} + endPanelCollapsed={endPanelCollapsed} + horizontal={horizontal} + /> + <Editor startPanelSize={startPanelSize} endPanelSize={endPanelSize} /> + {!this.props.selectedSource ? ( + <WelcomeBox + horizontal={horizontal} + toggleShortcutsModal={() => this.toggleShortcutsModal()} + /> + ) : null} + <EditorFooter horizontal={horizontal} /> + </div> + </div> + ); + }; + + toggleShortcutsModal() { + this.setState(prevState => ({ + shortcutsModalEnabled: !prevState.shortcutsModalEnabled, + })); + } + + // Important so that the tabs chevron updates appropriately when + // the user resizes the left or right columns + triggerEditorPaneResize() { + const editorPane = window.document.querySelector(".editor-pane"); + if (editorPane) { + editorPane.dispatchEvent(new Event("resizeend")); + } + } + + renderLayout = () => { + const { startPanelCollapsed, endPanelCollapsed } = this.props; + const horizontal = this.isHorizontal(); + + return ( + <SplitBox + style={{ width: "100vw" }} + initialSize={prefs.endPanelSize} + minSize={30} + maxSize="70%" + splitterSize={1} + vert={horizontal} + onResizeEnd={num => { + prefs.endPanelSize = num; + this.triggerEditorPaneResize(); + }} + startPanel={ + <SplitBox + style={{ width: "100vw" }} + initialSize={prefs.startPanelSize} + minSize={30} + maxSize="85%" + splitterSize={1} + onResizeEnd={num => { + prefs.startPanelSize = num; + }} + startPanelCollapsed={startPanelCollapsed} + startPanel={<PrimaryPanes horizontal={horizontal} />} + endPanel={this.renderEditorPane()} + /> + } + endPanelControl={true} + endPanel={<SecondaryPanes horizontal={horizontal} />} + endPanelCollapsed={endPanelCollapsed} + /> + ); + }; + + render() { + const { quickOpenEnabled } = this.props; + return ( + <div className="debugger"> + <AppErrorBoundary + componentName="Debugger" + panel={L10N.getStr("ToolboxDebugger.label")} + > + <A11yIntention> + {this.renderLayout()} + {quickOpenEnabled === true && ( + <QuickOpenModal + shortcutsModalEnabled={this.state.shortcutsModalEnabled} + toggleShortcutsModal={() => this.toggleShortcutsModal()} + /> + )} + <ShortcutsModal + enabled={this.state.shortcutsModalEnabled} + handleClose={() => this.toggleShortcutsModal()} + /> + </A11yIntention> + </AppErrorBoundary> + </div> + ); + } +} + +App.childContextTypes = { + toolboxDoc: PropTypes.object, + shortcuts: PropTypes.object, + l10n: PropTypes.object, + fluentBundles: PropTypes.array, +}; + +const mapStateToProps = state => ({ + selectedSource: getSelectedSource(state), + startPanelCollapsed: getPaneCollapse(state, "start"), + endPanelCollapsed: getPaneCollapse(state, "end"), + activeSearch: getActiveSearch(state), + quickOpenEnabled: getQuickOpenEnabled(state), + orientation: getOrientation(state), +}); + +export default connect(mapStateToProps, { + setActiveSearch: actions.setActiveSearch, + closeActiveSearch: actions.closeActiveSearch, + openQuickOpen: actions.openQuickOpen, + closeQuickOpen: actions.closeQuickOpen, + setOrientation: actions.setOrientation, + setPrimaryPaneTab: actions.setPrimaryPaneTab, +})(App); diff --git a/devtools/client/debugger/src/components/Editor/BlackboxLines.js b/devtools/client/debugger/src/components/Editor/BlackboxLines.js new file mode 100644 index 0000000000..c81db9c598 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 PropTypes from "prop-types"; +import { Component } from "react"; +import { toEditorLine, fromEditorLine } from "../../utils/editor"; +import { isLineBlackboxed } from "../../utils/source"; +import { isWasm } from "../../utils/wasm"; + +// This renders blackbox line highlighting in the editor +class BlackboxLines extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + blackboxedRangesForSelectedSource: PropTypes.array, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + componentDidMount() { + const { selectedSource, blackboxedRangesForSelectedSource, editor } = + this.props; + + if (this.props.isSourceOnIgnoreList) { + this.setAllBlackboxLines(editor); + return; + } + + // When `blackboxedRangesForSelectedSource` is defined and the array is empty, + // the whole source was blackboxed. + if (!blackboxedRangesForSelectedSource.length) { + this.setAllBlackboxLines(editor); + } else { + editor.codeMirror.operation(() => { + blackboxedRangesForSelectedSource.forEach(range => { + const start = toEditorLine(selectedSource.id, range.start.line); + const end = toEditorLine(selectedSource.id, range.end.line); + editor.codeMirror.eachLine(start, end, lineHandle => { + this.setBlackboxLine(editor, lineHandle); + }); + }); + }); + } + } + + componentDidUpdate() { + const { + selectedSource, + blackboxedRangesForSelectedSource, + editor, + isSourceOnIgnoreList, + } = this.props; + + if (this.props.isSourceOnIgnoreList) { + this.setAllBlackboxLines(editor); + return; + } + + // when unblackboxed + if (!blackboxedRangesForSelectedSource) { + this.clearAllBlackboxLines(editor); + return; + } + + // When the whole source is blackboxed + if (!blackboxedRangesForSelectedSource.length) { + this.setAllBlackboxLines(editor); + return; + } + + const sourceIsWasm = isWasm(selectedSource.id); + + // TODO: Possible perf improvement. Instead of going + // over all the lines each time get diffs of what has + // changed and update those. + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + const line = fromEditorLine( + selectedSource.id, + editor.codeMirror.getLineNumber(lineHandle), + sourceIsWasm + ); + + if ( + isLineBlackboxed( + blackboxedRangesForSelectedSource, + line, + isSourceOnIgnoreList + ) + ) { + this.setBlackboxLine(editor, lineHandle); + } else { + this.clearBlackboxLine(editor, lineHandle); + } + }); + }); + } + + componentWillUnmount() { + // Lets make sure we remove everything relating to + // blackboxing lines when this component is unmounted. + this.clearAllBlackboxLines(this.props.editor); + } + + clearAllBlackboxLines(editor) { + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + this.clearBlackboxLine(editor, lineHandle); + }); + }); + } + + setAllBlackboxLines(editor) { + //TODO:We might be able to handle the whole source + // than adding the blackboxing line by line + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + this.setBlackboxLine(editor, lineHandle); + }); + }); + } + + clearBlackboxLine(editor, lineHandle) { + editor.codeMirror.removeLineClass(lineHandle, "wrap", "blackboxed-line"); + } + + setBlackboxLine(editor, lineHandle) { + editor.codeMirror.addLineClass(lineHandle, "wrap", "blackboxed-line"); + } + + render() { + return null; + } +} + +export default BlackboxLines; diff --git a/devtools/client/debugger/src/components/Editor/Breakpoint.js b/devtools/client/debugger/src/components/Editor/Breakpoint.js new file mode 100644 index 0000000000..cce23c199f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js @@ -0,0 +1,183 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { getDocument, toEditorLine } from "../../utils/editor"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { features } from "../../utils/prefs"; +import { showMenu } from "../../context-menu/menu"; +import { breakpointItems } from "./menus/breakpoints"; +const classnames = require("devtools/client/shared/classnames.js"); + +const breakpointSvg = document.createElement("div"); +breakpointSvg.innerHTML = + '<svg viewBox="0 0 60 15" width="60" height="15"><path d="M53.07.5H1.5c-.54 0-1 .46-1 1v12c0 .54.46 1 1 1h51.57c.58 0 1.15-.26 1.53-.7l4.7-6.3-4.7-6.3c-.38-.44-.95-.7-1.53-.7z"/></svg>'; + +class Breakpoint extends PureComponent { + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + breakpoint: PropTypes.object.isRequired, + breakpointActions: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + editorActions: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + blackboxedRangesForSelectedSource: PropTypes.array, + isSelectedSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + componentDidMount() { + this.addBreakpoint(this.props); + } + + componentDidUpdate(prevProps) { + this.removeBreakpoint(prevProps); + this.addBreakpoint(this.props); + } + + componentWillUnmount() { + this.removeBreakpoint(this.props); + } + + makeMarker() { + const { breakpoint } = this.props; + const bp = breakpointSvg.cloneNode(true); + + bp.className = classnames("editor new-breakpoint", { + "breakpoint-disabled": breakpoint.disabled, + "folding-enabled": features.codeFolding, + }); + bp.onmousedown = this.onClick; + bp.oncontextmenu = this.onContextMenu; + + return bp; + } + + onClick = event => { + const { cx, breakpointActions, editorActions, breakpoint, selectedSource } = + this.props; + + // ignore right clicks + if ((event.ctrlKey && event.button === 0) || event.button === 2) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + if (event.metaKey) { + editorActions.continueToHere(cx, selectedLocation); + return; + } + + if (event.shiftKey) { + breakpointActions.toggleBreakpointsAtLine( + cx, + !breakpoint.disabled, + selectedLocation.line + ); + return; + } + + breakpointActions.removeBreakpointsAtLine( + cx, + selectedLocation.sourceId, + selectedLocation.line + ); + }; + + onContextMenu = event => { + const { + cx, + breakpoint, + selectedSource, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + } = this.props; + event.stopPropagation(); + event.preventDefault(); + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + showMenu( + event, + breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ) + ); + }; + + addBreakpoint(props) { + const { breakpoint, editor, selectedSource } = props; + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + // Hidden Breakpoints are never rendered on the client + if (breakpoint.options.hidden) { + return; + } + + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const line = toEditorLine(sourceId, selectedLocation.line); + const doc = getDocument(sourceId); + + doc.setGutterMarker(line, "breakpoints", this.makeMarker()); + + editor.codeMirror.addLineClass(line, "wrap", "new-breakpoint"); + editor.codeMirror.removeLineClass(line, "wrap", "breakpoint-disabled"); + editor.codeMirror.removeLineClass(line, "wrap", "has-condition"); + editor.codeMirror.removeLineClass(line, "wrap", "has-log"); + + if (breakpoint.disabled) { + editor.codeMirror.addLineClass(line, "wrap", "breakpoint-disabled"); + } + + if (breakpoint.options.logValue) { + editor.codeMirror.addLineClass(line, "wrap", "has-log"); + } else if (breakpoint.options.condition) { + editor.codeMirror.addLineClass(line, "wrap", "has-condition"); + } + } + + removeBreakpoint(props) { + const { selectedSource, breakpoint } = props; + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const doc = getDocument(sourceId); + + if (!doc) { + return; + } + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const line = toEditorLine(sourceId, selectedLocation.line); + + doc.setGutterMarker(line, "breakpoints", null); + doc.removeLineClass(line, "wrap", "new-breakpoint"); + doc.removeLineClass(line, "wrap", "breakpoint-disabled"); + doc.removeLineClass(line, "wrap", "has-condition"); + doc.removeLineClass(line, "wrap", "has-log"); + } + + render() { + return null; + } +} + +export default Breakpoint; diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.css b/devtools/client/debugger/src/components/Editor/Breakpoints.css new file mode 100644 index 0000000000..1269f73f82 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css @@ -0,0 +1,153 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.theme-light { + --gutter-hover-background-color: #dde1e4; + --breakpoint-fill: var(--blue-50); + --breakpoint-stroke: var(--blue-60); +} + +.theme-dark { + --gutter-hover-background-color: #414141; + --breakpoint-fill: var(--blue-55); + --breakpoint-stroke: var(--blue-40); +} + +.theme-light, +.theme-dark { + --logpoint-fill: var(--theme-graphs-purple); + --logpoint-stroke: var(--purple-60); + --breakpoint-condition-fill: var(--theme-graphs-yellow); + --breakpoint-condition-stroke: var(--theme-graphs-orange); + --breakpoint-skipped-opacity: 0.15; + --breakpoint-inactive-opacity: 0.3; + --breakpoint-disabled-opacity: 0.6; +} + +/* Standard gutter breakpoints */ +.editor-wrapper .breakpoints { + position: absolute; + top: 0; + left: 0; +} + +.new-breakpoint .CodeMirror-linenumber { + pointer-events: none; +} + +.editor-wrapper :not(.empty-line, .new-breakpoint) + > .CodeMirror-gutter-wrapper + > .CodeMirror-linenumber:hover::after { + content: ""; + position: absolute; + /* paint below the number */ + z-index: -1; + top: 0; + left: 0; + right: -4px; + bottom: 0; + height: 15px; + background-color: var(--gutter-hover-background-color); + mask: url(chrome://devtools/content/debugger/images/breakpoint.svg) + no-repeat; + mask-size: auto 15px; + mask-position: right; +} + +.editor.new-breakpoint svg { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + width: 60px; + height: 15px; + position: absolute; + top: 0px; + right: -4px; +} + +.editor .breakpoint { + position: absolute; + right: -2px; +} + +.editor.new-breakpoint.folding-enabled svg { + right: -16px; +} + +.new-breakpoint.has-condition .CodeMirror-gutter-wrapper svg { + fill: var(--breakpoint-condition-fill); + stroke: var(--breakpoint-condition-stroke); +} + +.new-breakpoint.has-log .CodeMirror-gutter-wrapper svg { + fill: var(--logpoint-fill); + stroke: var(--logpoint-stroke); +} + +.editor.new-breakpoint.breakpoint-disabled svg, +.blackboxed-line .editor.new-breakpoint svg { + fill-opacity: var(--breakpoint-disabled-opacity); + stroke-opacity: var(--breakpoint-disabled-opacity); +} + +.editor-wrapper.skip-pausing .editor.new-breakpoint svg { + fill-opacity: var(--breakpoint-skipped-opacity); +} + +/* Columnn breakpoints */ +.column-breakpoint { + display: inline; + padding-inline-start: 1px; + padding-inline-end: 1px; +} + +.column-breakpoint:hover { + background-color: transparent; +} + +.column-breakpoint svg { + display: inline-block; + cursor: pointer; + height: 13px; + width: 11px; + vertical-align: top; + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + fill-opacity: var(--breakpoint-inactive-opacity); + stroke-opacity: var(--breakpoint-inactive-opacity); +} + +.column-breakpoint.active svg { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + fill-opacity: 1; + stroke-opacity: 1; +} + +.column-breakpoint.disabled svg { + fill-opacity: var(--breakpoint-disabled-opacity); + stroke-opacity: var(--breakpoint-disabled-opacity); +} + +.column-breakpoint.has-log.disabled svg { + fill-opacity: 0.5; + stroke-opacity: 0.5; +} + +.column-breakpoint.has-condition svg { + fill: var(--breakpoint-condition-fill); + stroke: var(--breakpoint-condition-stroke); +} + +.column-breakpoint.has-log svg { + fill: var(--logpoint-fill); + stroke: var(--logpoint-stroke); +} + +.editor-wrapper.skip-pausing .column-breakpoint svg { + fill-opacity: var(--breakpoint-skipped-opacity); +} + +.img.column-marker { + background-image: url(chrome://devtools/content/debugger/images/column-marker.svg); +} diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.js b/devtools/client/debugger/src/components/Editor/Breakpoints.js new file mode 100644 index 0000000000..36added4ee --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import PropTypes from "prop-types"; +import React, { Component } from "react"; +import Breakpoint from "./Breakpoint"; + +import { + getSelectedSource, + getFirstVisibleBreakpoints, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { connect } from "../../utils/connect"; +import { breakpointItemActions } from "./menus/breakpoints"; +import { editorItemActions } from "./menus/editor"; + +class Breakpoints extends Component { + static get propTypes() { + return { + cx: PropTypes.object, + breakpoints: PropTypes.array, + editor: PropTypes.object, + breakpointActions: PropTypes.object, + editorActions: PropTypes.object, + selectedSource: PropTypes.object, + blackboxedRanges: PropTypes.object, + isSelectedSourceOnIgnoreList: PropTypes.bool, + blackboxedRangesForSelectedSource: PropTypes.array, + }; + } + render() { + const { + cx, + breakpoints, + selectedSource, + editor, + breakpointActions, + editorActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + } = this.props; + + if (!selectedSource || !breakpoints) { + return null; + } + + return ( + <div> + {breakpoints.map(bp => { + return ( + <Breakpoint + cx={cx} + key={makeBreakpointId(bp.location)} + breakpoint={bp} + selectedSource={selectedSource} + blackboxedRangesForSelectedSource={ + blackboxedRangesForSelectedSource + } + isSelectedSourceOnIgnoreList={isSelectedSourceOnIgnoreList} + editor={editor} + breakpointActions={breakpointActions} + editorActions={editorActions} + /> + ); + })} + </div> + ); + } +} + +export default connect( + state => { + const selectedSource = getSelectedSource(state); + const blackboxedRanges = getBlackBoxRanges(state); + return { + // Retrieves only the first breakpoint per line so that the + // breakpoint marker represents only the first breakpoint + breakpoints: getFirstVisibleBreakpoints(state), + selectedSource, + blackboxedRangesForSelectedSource: + selectedSource && blackboxedRanges[selectedSource.url], + isSelectedSourceOnIgnoreList: + selectedSource && + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + }; + }, + dispatch => ({ + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), + }) +)(Breakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js new file mode 100644 index 0000000000..0577a61f5c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; + +import { getDocument } from "../../utils/editor"; +import { breakpointItems, createBreakpointItems } from "./menus/breakpoints"; +import { getSelectedLocation } from "../../utils/selected-location"; +const classnames = require("devtools/client/shared/classnames.js"); + +// eslint-disable-next-line max-len + +const breakpointButton = document.createElement("button"); +breakpointButton.innerHTML = + '<svg viewBox="0 0 11 13" width="11" height="13"><path d="M5.07.5H1.5c-.54 0-1 .46-1 1v10c0 .54.46 1 1 1h3.57c.58 0 1.15-.26 1.53-.7l3.7-5.3-3.7-5.3C6.22.76 5.65.5 5.07.5z"/></svg>'; + +function makeBookmark({ breakpoint }, { onClick, onContextMenu }) { + const bp = breakpointButton.cloneNode(true); + + const isActive = breakpoint && !breakpoint.disabled; + const isDisabled = breakpoint?.disabled; + const condition = breakpoint?.options.condition; + const logValue = breakpoint?.options.logValue; + + bp.className = classnames("column-breakpoint", { + "has-condition": condition, + "has-log": logValue, + active: isActive, + disabled: isDisabled, + }); + + bp.setAttribute("title", logValue || condition || ""); + bp.onclick = onClick; + bp.oncontextmenu = onContextMenu; + + return bp; +} + +export default class ColumnBreakpoint extends PureComponent { + bookmark; + + static get propTypes() { + return { + breakpointActions: PropTypes.object.isRequired, + columnBreakpoint: PropTypes.object.isRequired, + cx: PropTypes.object.isRequired, + source: PropTypes.object.isRequired, + }; + } + + addColumnBreakpoint = nextProps => { + const { columnBreakpoint, source } = nextProps || this.props; + + const sourceId = source.id; + const doc = getDocument(sourceId); + if (!doc) { + return; + } + + const { line, column } = columnBreakpoint.location; + const widget = makeBookmark(columnBreakpoint, { + onClick: this.onClick, + onContextMenu: this.onContextMenu, + }); + + this.bookmark = doc.setBookmark({ line: line - 1, ch: column }, { widget }); + }; + + clearColumnBreakpoint = () => { + if (this.bookmark) { + this.bookmark.clear(); + this.bookmark = null; + } + }; + + onClick = event => { + event.stopPropagation(); + event.preventDefault(); + const { cx, columnBreakpoint, breakpointActions } = this.props; + + // disable column breakpoint on shift-click. + if (event.shiftKey) { + const breakpoint = columnBreakpoint.breakpoint; + breakpointActions.toggleDisabledBreakpoint(cx, breakpoint); + return; + } + + if (columnBreakpoint.breakpoint) { + breakpointActions.removeBreakpoint(cx, columnBreakpoint.breakpoint); + } else { + breakpointActions.addBreakpoint(cx, columnBreakpoint.location); + } + }; + + onContextMenu = event => { + event.stopPropagation(); + event.preventDefault(); + const { + cx, + columnBreakpoint: { breakpoint, location }, + source, + breakpointActions, + } = this.props; + + let items = createBreakpointItems(cx, location, breakpointActions); + + if (breakpoint) { + const selectedLocation = getSelectedLocation(breakpoint, source); + + items = breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions + ); + } + + showMenu(event, items); + }; + + componentDidMount() { + this.addColumnBreakpoint(); + } + + componentWillUnmount() { + this.clearColumnBreakpoint(); + } + + componentDidUpdate() { + this.clearColumnBreakpoint(); + this.addColumnBreakpoint(); + } + + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js new file mode 100644 index 0000000000..62c2ab29e3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import ColumnBreakpoint from "./ColumnBreakpoint"; + +import { + getSelectedSource, + visibleColumnBreakpoints, + getContext, + isSourceBlackBoxed, +} from "../../selectors"; +import { connect } from "../../utils/connect"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { breakpointItemActions } from "./menus/breakpoints"; + +// eslint-disable-next-line max-len + +class ColumnBreakpoints extends Component { + static get propTypes() { + return { + breakpointActions: PropTypes.object.isRequired, + columnBreakpoints: PropTypes.array.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + }; + } + + render() { + const { cx, editor, columnBreakpoints, selectedSource, breakpointActions } = + this.props; + + if (!selectedSource || columnBreakpoints.length === 0) { + return null; + } + + let breakpoints; + editor.codeMirror.operation(() => { + breakpoints = columnBreakpoints.map(breakpoint => ( + <ColumnBreakpoint + cx={cx} + key={makeBreakpointId(breakpoint.location)} + columnBreakpoint={breakpoint} + editor={editor} + source={selectedSource} + breakpointActions={breakpointActions} + /> + )); + }); + return <div>{breakpoints}</div>; + } +} + +const mapStateToProps = state => { + // Avoid rendering this component is there is no selected source, + // or if the selected source is blackboxed. + // Also avoid computing visible column breakpoint when this happens. + const selectedSource = getSelectedSource(state); + if (!selectedSource || isSourceBlackBoxed(state, selectedSource)) { + return {}; + } + return { + cx: getContext(state), + selectedSource, + columnBreakpoints: visibleColumnBreakpoints(state), + }; +}; + +export default connect(mapStateToProps, dispatch => ({ + breakpointActions: breakpointItemActions(dispatch), +}))(ColumnBreakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css new file mode 100644 index 0000000000..4ce8dbcd8c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.conditional-breakpoint-panel { + cursor: initial; + margin: 1em 0; + position: relative; + display: flex; + align-items: center; + background: var(--theme-toolbar-background); + border-top: 1px solid var(--theme-splitter-color); + border-bottom: 1px solid var(--theme-splitter-color); +} + +.conditional-breakpoint-panel .prompt { + font-size: 1.8em; + color: var(--theme-graphs-orange); + padding-left: 3px; + padding-right: 3px; + padding-bottom: 3px; + text-align: right; + width: 30px; + align-self: baseline; + margin-top: 3px; +} + +.conditional-breakpoint-panel.log-point .prompt { + color: var(--purple-60); +} + +.conditional-breakpoint-panel .CodeMirror { + margin: 6px 10px; +} + +.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder { + /* Match the color of the placeholder text to existing inputs in the Debugger */ + color: var(--theme-text-color-alt); +} diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js new file mode 100644 index 0000000000..e451ffa960 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js @@ -0,0 +1,274 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import "./ConditionalPanel.css"; +import { toEditorLine } from "../../utils/editor"; +import { prefs } from "../../utils/prefs"; +import actions from "../../actions"; + +import { + getClosestBreakpoint, + getConditionalPanelLocation, + getLogPointStatus, + getContext, +} from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +function addNewLine(doc) { + const cursor = doc.getCursor(); + const pos = { line: cursor.line, ch: cursor.ch }; + doc.replaceRange("\n", pos); +} + +export class ConditionalPanel extends PureComponent { + cbPanel; + input; + codeMirror; + panelNode; + scrollParent; + + constructor() { + super(); + this.cbPanel = null; + } + + static get propTypes() { + return { + breakpoint: PropTypes.object, + closeConditionalPanel: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + location: PropTypes.any.isRequired, + log: PropTypes.bool.isRequired, + openConditionalPanel: PropTypes.func.isRequired, + setBreakpointOptions: PropTypes.func.isRequired, + }; + } + + keepFocusOnInput() { + if (this.input) { + this.input.focus(); + } + } + + saveAndClose = () => { + if (this.input) { + this.setBreakpoint(this.input.value.trim()); + } + + this.props.closeConditionalPanel(); + }; + + onKey = e => { + if (e.key === "Enter") { + if (this.codeMirror && e.altKey) { + addNewLine(this.codeMirror.doc); + } else { + this.saveAndClose(); + } + } else if (e.key === "Escape") { + this.props.closeConditionalPanel(); + } + }; + + setBreakpoint(value) { + const { cx, log, breakpoint } = this.props; + // If breakpoint is `pending`, props will not contain a breakpoint. + // If source is a URL without location, breakpoint will contain no generatedLocation. + const location = + breakpoint && breakpoint.generatedLocation + ? breakpoint.generatedLocation + : this.props.location; + const options = breakpoint ? breakpoint.options : {}; + const type = log ? "logValue" : "condition"; + return this.props.setBreakpointOptions(cx, location, { + ...options, + [type]: value, + }); + } + + clearConditionalPanel() { + if (this.cbPanel) { + this.cbPanel.clear(); + this.cbPanel = null; + } + if (this.scrollParent) { + this.scrollParent.removeEventListener("scroll", this.repositionOnScroll); + } + } + + repositionOnScroll = () => { + if (this.panelNode && this.scrollParent) { + const { scrollLeft } = this.scrollParent; + this.panelNode.style.transform = `translateX(${scrollLeft}px)`; + } + }; + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + return this.renderToWidget(this.props); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate() { + return this.clearConditionalPanel(); + } + + componentDidUpdate(prevProps) { + this.keepFocusOnInput(); + } + + componentWillUnmount() { + // This is called if CodeMirror is re-initializing itself before the + // user closes the conditional panel. Clear the widget, and re-render it + // as soon as this component gets remounted + return this.clearConditionalPanel(); + } + + renderToWidget(props) { + if (this.cbPanel) { + this.clearConditionalPanel(); + } + const { location, editor } = props; + + const editorLine = toEditorLine(location.sourceId, location.line || 0); + this.cbPanel = editor.codeMirror.addLineWidget( + editorLine, + this.renderConditionalPanel(props), + { + coverGutter: true, + noHScroll: true, + } + ); + + if (this.input) { + let parent = this.input.parentNode; + while (parent) { + if ( + parent instanceof HTMLElement && + parent.classList.contains("CodeMirror-scroll") + ) { + this.scrollParent = parent; + break; + } + parent = parent.parentNode; + } + + if (this.scrollParent) { + this.scrollParent.addEventListener("scroll", this.repositionOnScroll); + this.repositionOnScroll(); + } + } + } + + createEditor = input => { + const { log, editor, closeConditionalPanel } = this.props; + const codeMirror = editor.CodeMirror.fromTextArea(input, { + mode: "javascript", + theme: "mozilla", + placeholder: L10N.getStr( + log + ? "editor.conditionalPanel.logPoint.placeholder2" + : "editor.conditionalPanel.placeholder2" + ), + cursorBlinkRate: prefs.cursorBlinkRate, + }); + + codeMirror.on("keydown", (cm, e) => { + if (e.key === "Enter") { + e.codemirrorIgnore = true; + } + }); + + codeMirror.on("blur", (cm, e) => { + if ( + e?.relatedTarget && + e.relatedTarget.closest(".conditional-breakpoint-panel") + ) { + return; + } + + closeConditionalPanel(); + }); + + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirrorWrapper.addEventListener("keydown", e => { + codeMirror.save(); + this.onKey(e); + }); + + this.input = input; + this.codeMirror = codeMirror; + codeMirror.focus(); + codeMirror.setCursor(codeMirror.lineCount(), 0); + }; + + getDefaultValue() { + const { breakpoint, log } = this.props; + const options = breakpoint?.options || {}; + return log ? options.logValue : options.condition; + } + + renderConditionalPanel(props) { + const { log } = props; + const defaultValue = this.getDefaultValue(); + + const panel = document.createElement("div"); + ReactDOM.render( + <div + className={classnames("conditional-breakpoint-panel", { + "log-point": log, + })} + onClick={() => this.keepFocusOnInput()} + ref={node => (this.panelNode = node)} + > + <div className="prompt">»</div> + <textarea + defaultValue={defaultValue} + ref={input => this.createEditor(input)} + /> + </div>, + panel + ); + return panel; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const location = getConditionalPanelLocation(state); + + if (!location) { + throw new Error("Conditional panel location needed."); + } + + const breakpoint = getClosestBreakpoint(state, location); + + return { + cx: getContext(state), + breakpoint, + location, + log: getLogPointStatus(state), + }; +}; + +const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } = + actions; + +const mapDispatchToProps = { + setBreakpointOptions, + openConditionalPanel, + closeConditionalPanel, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel); diff --git a/devtools/client/debugger/src/components/Editor/DebugLine.js b/devtools/client/debugger/src/components/Editor/DebugLine.js new file mode 100644 index 0000000000..95cfc5a94d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/DebugLine.js @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { + toEditorPosition, + getDocument, + hasDocument, + startOperation, + endOperation, + getTokenEnd, +} from "../../utils/editor"; +import { isException } from "../../utils/pause"; +import { getIndentation } from "../../utils/indentation"; +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getPauseReason, + getSourceTextContent, + getCurrentThread, +} from "../../selectors"; + +export class DebugLine extends PureComponent { + debugExpression; + + static get propTypes() { + return { + location: PropTypes.object, + why: PropTypes.object, + }; + } + + componentDidMount() { + const { why, location } = this.props; + this.setDebugLine(why, location); + } + + componentWillUnmount() { + const { why, location } = this.props; + this.clearDebugLine(why, location); + } + + componentDidUpdate(prevProps) { + const { why, location } = this.props; + + startOperation(); + this.clearDebugLine(prevProps.why, prevProps.location); + this.setDebugLine(why, location); + endOperation(); + } + + setDebugLine(why, location) { + if (!location) { + return; + } + const { sourceId } = location; + const doc = getDocument(sourceId); + + let { line, column } = toEditorPosition(location); + let { markTextClass, lineClass } = this.getTextClasses(why); + doc.addLineClass(line, "wrap", lineClass); + + const lineText = doc.getLine(line); + column = Math.max(column, getIndentation(lineText)); + + // If component updates because user clicks on + // another source tab, codeMirror will be null. + const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null; + + if (columnEnd === null) { + markTextClass += " to-line-end"; + } + + this.debugExpression = doc.markText( + { ch: column, line }, + { ch: columnEnd, line }, + { className: markTextClass } + ); + } + + clearDebugLine(why, location) { + // Avoid clearing the line if we didn't set a debug line before, + // or, if the document is no longer available + if (!location || !hasDocument(location.sourceId)) { + return; + } + + if (this.debugExpression) { + this.debugExpression.clear(); + } + + const { line } = toEditorPosition(location); + const doc = getDocument(location.sourceId); + const { lineClass } = this.getTextClasses(why); + doc.removeLineClass(line, "wrap", lineClass); + } + + getTextClasses(why) { + if (why && isException(why)) { + return { + markTextClass: "debug-expression-error", + lineClass: "new-debug-line-error", + }; + } + + return { markTextClass: "debug-expression", lineClass: "new-debug-line" }; + } + + render() { + return null; + } +} + +function isDocumentReady(location, sourceTextContent) { + return location && sourceTextContent && hasDocument(location.sourceId); +} + +const mapStateToProps = state => { + // Avoid unecessary intermediate updates when there is no location + // or the source text content isn't yet fully loaded + const frame = getVisibleSelectedFrame(state); + const location = frame?.location; + if (!location) { + return {}; + } + const sourceTextContent = getSourceTextContent(state, location); + if (!isDocumentReady(location, sourceTextContent)) { + return {}; + } + return { + location, + why: getPauseReason(state, getCurrentThread(state)), + }; +}; + +export default connect(mapStateToProps)(DebugLine); diff --git a/devtools/client/debugger/src/components/Editor/Editor.css b/devtools/client/debugger/src/components/Editor/Editor.css new file mode 100644 index 0000000000..7ea45c629d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Editor.css @@ -0,0 +1,220 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.editor-wrapper { + --debug-line-border: rgb(145, 188, 219); + --debug-expression-background: rgba(202, 227, 255, 0.5); + --debug-line-error-border: rgb(255, 0, 0); + --debug-expression-error-background: rgba(231, 116, 113, 0.3); + --line-exception-background: hsl(344, 73%, 97%); + --highlight-line-duration: 5000ms; +} + +.theme-dark .editor-wrapper { + --debug-expression-background: rgba(202, 227, 255, 0.3); + --debug-line-border: #7786a2; + --line-exception-background: hsl(345, 23%, 24%); +} + +.editor-wrapper .CodeMirror-linewidget { + margin-right: -7px; +} + +.editor-wrapper { + min-width: 0 !important; +} + +.CodeMirror.cm-s-mozilla, +.CodeMirror-scroll, +.CodeMirror-sizer { + overflow-anchor: none; +} + +/* Prevents inline preview from shifting source height (#1576163) */ +.CodeMirror-linewidget { + padding: 0; + display: flow-root; +} + +/** + * There's a known codemirror flex issue with chrome that this addresses. + * BUG https://github.com/firefox-devtools/debugger/issues/63 + */ +.editor-wrapper { + position: absolute; + width: calc(100% - 1px); + top: var(--editor-header-height); + bottom: var(--editor-footer-height); + left: 0px; +} + +html[dir="rtl"] .editor-mount { + direction: ltr; +} + +.function-search { + max-height: 300px; + overflow: hidden; +} + +.function-search .results { + height: auto; +} + +.editor.hit-marker { + height: 15px; +} + +.editor-wrapper .highlight-lines { + background: var(--theme-selection-background-hover); +} + +.CodeMirror { + width: 100%; + height: 100%; +} + +.editor-wrapper .editor-mount { + width: 100%; + background-color: var(--theme-body-background); + font-size: var(--theme-code-font-size); + line-height: var(--theme-code-line-height); +} + +/* set the linenumber white when there is a breakpoint */ +.editor-wrapper:not(.skip-pausing) + .new-breakpoint + .CodeMirror-gutter-wrapper + .CodeMirror-linenumber { + color: white; +} + +/* move the breakpoint below the other gutter elements */ +.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) { + z-index: 0; +} + +.theme-dark .editor-wrapper .CodeMirror-line .cm-comment { + color: var(--theme-comment); +} + +.debug-expression { + background-color: var(--debug-expression-background); + border-style: solid; + border-color: var(--debug-expression-background); + border-width: 1px 0px 1px 0px; + position: relative; +} + +.debug-expression::before { + content: ""; + line-height: 1px; + border-top: 1px solid var(--blue-50); + background: transparent; + position: absolute; + top: -2px; + left: 0px; + width: 100%; + } + +.debug-expression::after { + content: ""; + line-height: 1px; + border-bottom: 1px solid var(--blue-50); + position: absolute; + bottom: -2px; + left: 0px; + width: 100%; + } + +.to-line-end ~ .CodeMirror-widget { + background-color: var(--debug-expression-background); +} + +.debug-expression-error { + background-color: var(--debug-expression-error-background); +} + +.new-debug-line > .CodeMirror-line { + background-color: transparent !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line .CodeMirror-activeline-background { + display: none; +} + +.new-debug-line-error > .CodeMirror-line { + background-color: var(--debug-expression-error-background) !important; + outline: var(--debug-line-error-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line-error .CodeMirror-activeline-background { + display: none; +} +.highlight-line .CodeMirror-line { + animation-name: fade-highlight-out; + animation-duration: var(--highlight-line-duration); + animation-timing-function: ease-out; + animation-direction: forwards; +} + +@keyframes fade-highlight-out { + 0% { + background-color: var(--theme-contrast-background); + } + 30% { + background-color: var(--theme-contrast-background); + } + 100% { + background-color: transparent; + } +} + +.visible { + visibility: visible; +} + +/* Code folding */ +.editor-wrapper .CodeMirror-foldgutter-open { + color: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open, +.editor-wrapper .CodeMirror-foldgutter-folded { + fill: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open::before, +.editor-wrapper .CodeMirror-foldgutter-open::after { + border-top: none; +} + +.editor-wrapper .CodeMirror-foldgutter-folded::before, +.editor-wrapper .CodeMirror-foldgutter-folded::after { + border-left: none; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle { + visibility: visible; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber { + text-align: left; + padding: 0 0 0 2px; +} + +/* Exception line */ +.line-exception { + background-color: var(--line-exception-background); +} + +.mark-text-exception { + text-decoration: var(--red-50) wavy underline; + text-decoration-skip-ink: none; +} diff --git a/devtools/client/debugger/src/components/Editor/EditorMenu.js b/devtools/client/debugger/src/components/Editor/EditorMenu.js new file mode 100644 index 0000000000..a865fcc9bd --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { Component } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; +import { showMenu } from "../../context-menu/menu"; + +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import { isPretty } from "../../utils/source"; +import { + getPrettySource, + getIsCurrentThreadPaused, + getThreadContext, + isSourceWithMap, + getBlackBoxRanges, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, +} from "../../selectors"; + +import { editorMenuItems, editorItemActions } from "./menus/editor"; + +class EditorMenu extends Component { + static get propTypes() { + return { + clearContextMenu: PropTypes.func.isRequired, + contextMenu: PropTypes.object, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps) { + this.props.clearContextMenu(); + if (nextProps.contextMenu) { + this.showMenu(nextProps); + } + } + + showMenu(props) { + const { + cx, + editor, + selectedSource, + blackboxedRanges, + editorActions, + hasMappedLocation, + isPaused, + editorWrappingEnabled, + contextMenu: event, + isSourceOnIgnoreList, + } = props; + + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + // Use a coercion, as contextMenu is optional + event + ); + + showMenu( + event, + editorMenuItems({ + cx, + editorActions, + selectedSource, + blackboxedRanges, + hasMappedLocation, + location, + isPaused, + editorWrappingEnabled, + selectionText: editor.codeMirror.getSelection().trim(), + isTextSelected: editor.codeMirror.somethingSelected(), + editor, + isSourceOnIgnoreList, + }) + ); + } + + render() { + return null; + } +} + +const mapStateToProps = (state, props) => { + // This component is a no-op when contextmenu is false + if (!props.contextMenu) { + return {}; + } + return { + cx: getThreadContext(state), + blackboxedRanges: getBlackBoxRanges(state), + isPaused: getIsCurrentThreadPaused(state), + hasMappedLocation: + (props.selectedSource.isOriginal || + isSourceWithMap(state, props.selectedSource.id) || + isPretty(props.selectedSource)) && + !getPrettySource(state, props.selectedSource.id), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, props.selectedSource), + }; +}; + +const mapDispatchToProps = dispatch => ({ + editorActions: editorItemActions(dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(EditorMenu); diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js new file mode 100644 index 0000000000..70a8c9c0a7 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { connect } from "../../utils/connect"; +import { Component } from "react"; +import PropTypes from "prop-types"; +import { getSelectedSource, getSelectedBreakableLines } from "../../selectors"; +import { fromEditorLine } from "../../utils/editor"; +import { isWasm } from "../../utils/wasm"; + +class EmptyLines extends Component { + static get propTypes() { + return { + breakableLines: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + this.disableEmptyLines(); + } + + componentDidUpdate() { + this.disableEmptyLines(); + } + + componentWillUnmount() { + const { editor } = this.props; + + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + editor.codeMirror.removeLineClass(lineHandle, "wrap", "empty-line"); + }); + }); + } + + shouldComponentUpdate(nextProps) { + const { breakableLines, selectedSource } = this.props; + return ( + // Breakable lines are something that evolves over time, + // but we either have them loaded or not. So only compare the size + // as sometimes we always get a blank new empty Set instance. + breakableLines.size != nextProps.breakableLines.size || + selectedSource.id != nextProps.selectedSource.id + ); + } + + disableEmptyLines() { + const { breakableLines, selectedSource, editor } = this.props; + + const { codeMirror } = editor; + const isSourceWasm = isWasm(selectedSource.id); + + codeMirror.operation(() => { + const lineCount = codeMirror.lineCount(); + for (let i = 0; i < lineCount; i++) { + const line = fromEditorLine(selectedSource.id, i, isSourceWasm); + + if (breakableLines.has(line)) { + codeMirror.removeLineClass(i, "wrap", "empty-line"); + } else { + codeMirror.addLineClass(i, "wrap", "empty-line"); + } + } + }); + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + throw new Error("no selectedSource"); + } + const breakableLines = getSelectedBreakableLines(state); + + return { + selectedSource, + breakableLines, + }; +}; + +export default connect(mapStateToProps)(EmptyLines); diff --git a/devtools/client/debugger/src/components/Editor/Exception.js b/devtools/client/debugger/src/components/Editor/Exception.js new file mode 100644 index 0000000000..8527cfed07 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exception.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { toEditorPosition, getTokenEnd, hasDocument } from "../../utils/editor"; + +import { getIndentation } from "../../utils/indentation"; +import { createLocation } from "../../utils/location"; + +export default class Exception extends PureComponent { + exceptionLine; + markText; + + static get propTypes() { + return { + exception: PropTypes.object.isRequired, + doc: PropTypes.object.isRequired, + selectedSource: PropTypes.string.isRequired, + }; + } + + componentDidMount() { + this.addEditorExceptionLine(); + } + + componentDidUpdate() { + this.clearEditorExceptionLine(); + this.addEditorExceptionLine(); + } + + componentWillUnmount() { + this.clearEditorExceptionLine(); + } + + setEditorExceptionLine(doc, line, column, lineText) { + doc.addLineClass(line, "wrap", "line-exception"); + + column = Math.max(column, getIndentation(lineText)); + const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null; + + const markText = doc.markText( + { ch: column, line }, + { ch: columnEnd, line }, + { className: "mark-text-exception" } + ); + + this.exceptionLine = line; + this.markText = markText; + } + + addEditorExceptionLine() { + const { exception, doc, selectedSource } = this.props; + const { columnNumber, lineNumber } = exception; + + if (!hasDocument(selectedSource.id)) { + return; + } + + const location = createLocation({ + column: columnNumber - 1, + line: lineNumber, + source: selectedSource, + }); + + const { line, column } = toEditorPosition(location); + const lineText = doc.getLine(line); + + this.setEditorExceptionLine(doc, line, column, lineText); + } + + clearEditorExceptionLine() { + if (this.markText) { + const { selectedSource } = this.props; + + this.markText.clear(); + + if (hasDocument(selectedSource.id)) { + this.props.doc.removeLineClass( + this.exceptionLine, + "wrap", + "line-exception" + ); + } + this.exceptionLine = null; + this.markText = null; + } + } + + // This component is only used as a "proxy" to manipulate the editor. + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/components/Editor/Exceptions.js b/devtools/client/debugger/src/components/Editor/Exceptions.js new file mode 100644 index 0000000000..d1bac48b1b --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exceptions.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import Exception from "./Exception"; + +import { + getSelectedSource, + getSelectedSourceExceptions, +} from "../../selectors"; +import { getDocument } from "../../utils/editor"; + +class Exceptions extends Component { + static get propTypes() { + return { + exceptions: PropTypes.array, + selectedSource: PropTypes.object, + }; + } + + render() { + const { exceptions, selectedSource } = this.props; + + if (!selectedSource || !exceptions.length) { + return null; + } + + const doc = getDocument(selectedSource.id); + + return ( + <> + {exceptions.map(exc => ( + <Exception + exception={exc} + doc={doc} + key={`${exc.sourceActorId}:${exc.lineNumber}`} + selectedSource={selectedSource} + /> + ))} + </> + ); + } +} + +export default connect(state => { + const selectedSource = getSelectedSource(state); + + // Avoid calling getSelectedSourceExceptions when there is no source selected. + if (!selectedSource) { + return {}; + } + + // Avoid causing any update until we start having exceptions + const exceptions = getSelectedSourceExceptions(state); + if (!exceptions.length) { + return {}; + } + + return { + exceptions: getSelectedSourceExceptions(state), + selectedSource, + }; +})(Exceptions); diff --git a/devtools/client/debugger/src/components/Editor/Footer.css b/devtools/client/debugger/src/components/Editor/Footer.css new file mode 100644 index 0000000000..aee6c51d38 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.css @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.source-footer { + background: var(--theme-body-background); + border-top: 1px solid var(--theme-splitter-color); + position: absolute; + display: flex; + bottom: 0; + left: 0; + right: 0; + opacity: 1; + z-index: 1; + width: calc(100% - 1px); + user-select: none; + height: var(--editor-footer-height); + box-sizing: border-box; +} + +.source-footer-start { + display: flex; + align-items: center; + justify-self: start; +} + +.source-footer-end { + display: flex; + margin-left: auto; +} + +.source-footer .commands * { + user-select: none; +} + +.source-footer .commands { + display: flex; +} + +.source-footer .commands .action { + display: flex; + justify-content: center; + align-items: center; + transition: opacity 200ms; + border: none; + background: transparent; + padding: 4px 6px; +} + +.source-footer .commands button.action:hover { + background: var(--theme-toolbar-background-hover); +} + +:root.theme-dark .source-footer .commands .action { + fill: var(--theme-body-color); +} + +:root.theme-dark .source-footer .commands .action:hover { + fill: var(--theme-selection-color); +} + +.source-footer .blackboxed .img.blackBox { + background-color: #806414; +} + +.source-footer .commands button.prettyPrint:disabled { + opacity: 0.6; +} + +.source-footer .mapped-source, +.source-footer .cursor-position { + color: var(--theme-body-color); + padding-right: 2.5px; +} + +.source-footer .mapped-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.source-footer .cursor-position { + padding: 5px; + white-space: nowrap; +} diff --git a/devtools/client/debugger/src/components/Editor/Footer.js b/devtools/client/debugger/src/components/Editor/Footer.js new file mode 100644 index 0000000000..ea9acbc6f6 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.js @@ -0,0 +1,302 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { createLocation } from "../../utils/location"; +import actions from "../../actions"; +import { + getSelectedSource, + getSelectedLocation, + getSelectedSourceTextContent, + getPrettySource, + getPaneCollapse, + getContext, + getGeneratedSource, + isSourceBlackBoxed, + canPrettyPrintSource, + getPrettyPrintMessage, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, +} from "../../selectors"; + +import { isPretty, getFilename, shouldBlackbox } from "../../utils/source"; + +import { PaneToggleButton } from "../shared/Button"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Footer.css"; + +class SourceFooter extends PureComponent { + constructor() { + super(); + + this.state = { cursorPosition: { line: 0, column: 0 } }; + } + + static get propTypes() { + return { + canPrettyPrint: PropTypes.bool.isRequired, + prettyPrintMessage: PropTypes.string.isRequired, + cx: PropTypes.object.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + jumpToMappedLocation: PropTypes.func.isRequired, + mappedSource: PropTypes.object, + selectedSource: PropTypes.object, + isSelectedSourceBlackBoxed: PropTypes.bool.isRequired, + sourceLoaded: PropTypes.bool.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + togglePaneCollapse: PropTypes.func.isRequired, + togglePrettyPrint: PropTypes.func.isRequired, + isSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + componentDidUpdate() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + // querySelector can return null + if (eventDoc) { + this.toggleCodeMirror(eventDoc, true); + } + } + + componentWillUnmount() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + + if (eventDoc) { + this.toggleCodeMirror(eventDoc, false); + } + } + + toggleCodeMirror(eventDoc, toggle) { + if (toggle === true) { + eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange); + } else { + eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange); + } + } + + prettyPrintButton() { + const { + cx, + selectedSource, + canPrettyPrint, + prettyPrintMessage, + togglePrettyPrint, + sourceLoaded, + } = this.props; + + if (!selectedSource) { + return null; + } + + if (!sourceLoaded && selectedSource.isPrettyPrinted) { + return ( + <div className="action" key="pretty-loader"> + <AccessibleImage className="loader spin" /> + </div> + ); + } + + const type = "prettyPrint"; + return ( + <button + onClick={() => { + if (!canPrettyPrint) { + return; + } + togglePrettyPrint(cx, selectedSource.id); + }} + className={classnames("action", type, { + active: sourceLoaded && canPrettyPrint, + pretty: isPretty(selectedSource), + })} + key={type} + title={prettyPrintMessage} + aria-label={prettyPrintMessage} + disabled={!canPrettyPrint} + > + <AccessibleImage className={type} /> + </button> + ); + } + + blackBoxButton() { + const { + cx, + selectedSource, + isSelectedSourceBlackBoxed, + toggleBlackBox, + sourceLoaded, + isSourceOnIgnoreList, + } = this.props; + + if (!selectedSource || !shouldBlackbox(selectedSource)) { + return null; + } + + let tooltip = isSelectedSourceBlackBoxed + ? L10N.getStr("sourceFooter.unignore") + : L10N.getStr("sourceFooter.ignore"); + + if (isSourceOnIgnoreList) { + tooltip = L10N.getStr("sourceFooter.ignoreList"); + } + + const type = "black-box"; + + return ( + <button + onClick={() => toggleBlackBox(cx, selectedSource)} + className={classnames("action", type, { + active: sourceLoaded, + blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList, + })} + key={type} + title={tooltip} + aria-label={tooltip} + disabled={isSourceOnIgnoreList} + > + <AccessibleImage className="blackBox" /> + </button> + ); + } + + renderToggleButton() { + if (this.props.horizontal) { + return null; + } + + return ( + <PaneToggleButton + key="toggle" + collapsed={this.props.endPanelCollapsed} + horizontal={this.props.horizontal} + handleClick={this.props.togglePaneCollapse} + position="end" + /> + ); + } + + renderCommands() { + const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter( + Boolean + ); + + return commands.length ? <div className="commands">{commands}</div> : null; + } + + renderSourceSummary() { + const { cx, mappedSource, jumpToMappedLocation, selectedSource } = + this.props; + + if (!mappedSource || !selectedSource || !selectedSource.isOriginal) { + return null; + } + + const filename = getFilename(mappedSource); + const tooltip = L10N.getFormatStr( + "sourceFooter.mappedSourceTooltip", + filename + ); + const title = L10N.getFormatStr("sourceFooter.mappedSource", filename); + const mappedSourceLocation = createLocation({ + source: selectedSource, + line: 1, + column: 1, + }); + return ( + <button + className="mapped-source" + onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)} + title={tooltip} + > + <span>{title}</span> + </button> + ); + } + + onCursorChange = event => { + const { line, ch } = event.doc.getCursor(); + this.setState({ cursorPosition: { line, column: ch } }); + }; + + renderCursorPosition() { + if (!this.props.selectedSource) { + return null; + } + + const { line, column } = this.state.cursorPosition; + + const text = L10N.getFormatStr( + "sourceFooter.currentCursorPosition", + line + 1, + column + 1 + ); + const title = L10N.getFormatStr( + "sourceFooter.currentCursorPosition.tooltip", + line + 1, + column + 1 + ); + return ( + <div className="cursor-position" title={title}> + {text} + </div> + ); + } + + render() { + return ( + <div className="source-footer"> + <div className="source-footer-start">{this.renderCommands()}</div> + <div className="source-footer-end"> + {this.renderSourceSummary()} + {this.renderCursorPosition()} + {this.renderToggleButton()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + const sourceTextContent = getSelectedSourceTextContent(state); + + return { + cx: getContext(state), + selectedSource, + isSelectedSourceBlackBoxed: selectedSource + ? isSourceBlackBoxed(state, selectedSource) + : null, + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + sourceLoaded: !!sourceTextContent, + mappedSource: getGeneratedSource(state, selectedSource), + prettySource: getPrettySource( + state, + selectedSource ? selectedSource.id : null + ), + endPanelCollapsed: getPaneCollapse(state, "end"), + canPrettyPrint: selectedLocation + ? canPrettyPrintSource(state, selectedLocation) + : false, + prettyPrintMessage: selectedLocation + ? getPrettyPrintMessage(state, selectedLocation) + : null, + }; +}; + +export default connect(mapStateToProps, { + togglePrettyPrint: actions.togglePrettyPrint, + toggleBlackBox: actions.toggleBlackBox, + jumpToMappedLocation: actions.jumpToMappedLocation, + togglePaneCollapse: actions.togglePaneCollapse, +})(SourceFooter); diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.css b/devtools/client/debugger/src/components/Editor/HighlightCalls.css new file mode 100644 index 0000000000..b7e0402cab --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.css @@ -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/>. */ + +.highlight-function-calls { + background-color: rgba(202, 227, 255, 0.5); +} + +.theme-dark .highlight-function-calls { + background-color: #743884; +} + +.highlight-function-calls:hover { + cursor: default; +} diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.js b/devtools/client/debugger/src/components/Editor/HighlightCalls.js new file mode 100644 index 0000000000..0063f66c7a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { + getHighlightedCalls, + getThreadContext, + getCurrentThread, +} from "../../selectors"; +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import actions from "../../actions"; +import "./HighlightCalls.css"; + +export class HighlightCalls extends Component { + previousCalls = null; + + static get propTypes() { + return { + continueToHere: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + highlightedCalls: PropTypes.array, + selectedSource: PropTypes.object, + }; + } + + componentDidUpdate() { + this.unhighlightFunctionCalls(); + this.highlightFunctioCalls(); + } + + markCall = call => { + const { editor } = this.props; + const startLine = call.location.start.line - 1; + const endLine = call.location.end.line - 1; + const startColumn = call.location.start.column; + const endColumn = call.location.end.column; + const markedCall = editor.codeMirror.markText( + { line: startLine, ch: startColumn }, + { line: endLine, ch: endColumn }, + { className: "highlight-function-calls" } + ); + return markedCall; + }; + + onClick = e => { + const { editor, selectedSource, cx, continueToHere } = this.props; + + if (selectedSource) { + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + e + ); + continueToHere(cx, location); + editor.codeMirror.execCommand("singleSelection"); + editor.codeMirror.execCommand("goGroupLeft"); + } + }; + + highlightFunctioCalls() { + const { highlightedCalls } = this.props; + + if (!highlightedCalls) { + return; + } + + let markedCalls = []; + markedCalls = highlightedCalls.map(this.markCall); + + const allMarkedElements = document.getElementsByClassName( + "highlight-function-calls" + ); + + for (let i = 0; i < allMarkedElements.length; i++) { + allMarkedElements[i].addEventListener("click", this.onClick); + } + + this.previousCalls = markedCalls; + } + + unhighlightFunctionCalls() { + if (!this.previousCalls) { + return; + } + this.previousCalls.forEach(call => call.clear()); + this.previousCalls = null; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + }; +}; + +const { continueToHere } = actions; + +const mapDispatchToProps = { continueToHere }; + +export default connect(mapStateToProps, mapDispatchToProps)(HighlightCalls); diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js new file mode 100644 index 0000000000..3df0142127 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js @@ -0,0 +1,183 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { Component } from "react"; +import PropTypes from "prop-types"; +import { toEditorLine, endOperation, startOperation } from "../../utils/editor"; +import { getDocument, hasDocument } from "../../utils/editor/source-documents"; + +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getSelectedLocation, + getSelectedSourceTextContent, + getPauseCommand, + getCurrentThread, +} from "../../selectors"; + +function isDebugLine(selectedFrame, selectedLocation) { + if (!selectedFrame) { + return false; + } + + return ( + selectedFrame.location.sourceId == selectedLocation.sourceId && + selectedFrame.location.line == selectedLocation.line + ); +} + +function isDocumentReady(selectedLocation, selectedSourceTextContent) { + return ( + selectedLocation && + selectedSourceTextContent && + hasDocument(selectedLocation.sourceId) + ); +} + +export class HighlightLine extends Component { + isStepping = false; + previousEditorLine = null; + + static get propTypes() { + return { + pauseCommand: PropTypes.oneOf([ + "expression", + "resume", + "stepOver", + "stepIn", + "stepOut", + ]), + selectedFrame: PropTypes.object, + selectedLocation: PropTypes.object.isRequired, + selectedSourceTextContent: PropTypes.object.isRequired, + }; + } + + shouldComponentUpdate(nextProps) { + const { selectedLocation, selectedSourceTextContent } = nextProps; + return this.shouldSetHighlightLine( + selectedLocation, + selectedSourceTextContent + ); + } + + componentDidUpdate(prevProps) { + this.completeHighlightLine(prevProps); + } + + componentDidMount() { + this.completeHighlightLine(null); + } + + shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) { + const { sourceId, line } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + + if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) { + return false; + } + + if (this.isStepping && editorLine === this.previousEditorLine) { + return false; + } + + return true; + } + + completeHighlightLine(prevProps) { + const { + pauseCommand, + selectedLocation, + selectedFrame, + selectedSourceTextContent, + } = this.props; + if (pauseCommand) { + this.isStepping = true; + } + + startOperation(); + if (prevProps) { + this.clearHighlightLine( + prevProps.selectedLocation, + prevProps.selectedSourceTextContent + ); + } + this.setHighlightLine( + selectedLocation, + selectedFrame, + selectedSourceTextContent + ); + endOperation(); + } + + setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) { + const { sourceId, line } = selectedLocation; + if ( + !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) + ) { + return; + } + + this.isStepping = false; + const editorLine = toEditorLine(sourceId, line); + this.previousEditorLine = editorLine; + + if (!line || isDebugLine(selectedFrame, selectedLocation)) { + return; + } + + const doc = getDocument(sourceId); + doc.addLineClass(editorLine, "wrap", "highlight-line"); + this.resetHighlightLine(doc, editorLine); + } + + resetHighlightLine(doc, editorLine) { + const editorWrapper = document.querySelector(".editor-wrapper"); + + if (editorWrapper === null) { + return; + } + + const duration = parseInt( + getComputedStyle(editorWrapper).getPropertyValue( + "--highlight-line-duration" + ), + 10 + ); + + setTimeout( + () => doc && doc.removeLineClass(editorLine, "wrap", "highlight-line"), + duration + ); + } + + clearHighlightLine(selectedLocation, selectedSourceTextContent) { + if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) { + return; + } + + const { line, sourceId } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + const doc = getDocument(sourceId); + doc.removeLineClass(editorLine, "wrap", "highlight-line"); + } + + render() { + return null; + } +} + +export default connect(state => { + const selectedLocation = getSelectedLocation(state); + + if (!selectedLocation) { + throw new Error("must have selected location"); + } + return { + pauseCommand: getPauseCommand(state, getCurrentThread(state)), + selectedFrame: getVisibleSelectedFrame(state), + selectedLocation, + selectedSourceTextContent: getSelectedSourceTextContent(state), + }; +})(HighlightLine); diff --git a/devtools/client/debugger/src/components/Editor/HighlightLines.js b/devtools/client/debugger/src/components/Editor/HighlightLines.js new file mode 100644 index 0000000000..bffa209e7d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; + +class HighlightLines extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + range: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + this.highlightLineRange(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate() { + this.clearHighlightRange(); + } + + componentDidUpdate() { + this.highlightLineRange(); + } + + componentWillUnmount() { + this.clearHighlightRange(); + } + + clearHighlightRange() { + const { range, editor } = this.props; + + const { codeMirror } = editor; + + if (!range || !codeMirror) { + return; + } + + const { start, end } = range; + codeMirror.operation(() => { + for (let line = start - 1; line < end; line++) { + codeMirror.removeLineClass(line, "wrap", "highlight-lines"); + } + }); + } + + highlightLineRange = () => { + const { range, editor } = this.props; + + const { codeMirror } = editor; + + if (!range || !codeMirror) { + return; + } + + const { start, end } = range; + + codeMirror.operation(() => { + editor.alignLine(start); + for (let line = start - 1; line < end; line++) { + codeMirror.addLineClass(line, "wrap", "highlight-lines"); + } + }); + }; + + render() { + return null; + } +} + +export default HighlightLines; diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.css b/devtools/client/debugger/src/components/Editor/InlinePreview.css new file mode 100644 index 0000000000..13f1b5e23c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.css @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.inline-preview { + display: inline-block; + margin-inline-start: 8px; + user-select: none; +} + +.inline-preview-outer { + background-color: var(--theme-inline-preview-background); + border: 1px solid var(--theme-inline-preview-border-color); + border-radius: 3px; + font-size: 10px; + margin-right: 5px; + white-space: nowrap; +} + +.inline-preview-label { + padding: 0px 2px 0px 4px; + border-radius: 2px 0 0 2px; + color: var(--theme-inline-preview-label-color); + background-color: var(--theme-inline-preview-label-background); +} + +.inline-preview-value { + padding: 2px 6px; +} diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.js b/devtools/client/debugger/src/components/Editor/InlinePreview.js new file mode 100644 index 0000000000..f978965134 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import Reps from "devtools/client/shared/components/reps/index"; + +const { + REPS: { + Rep, + ElementNode: { supportsObject: isElement }, + }, + MODE, +} = Reps; + +// Renders single variable preview inside a codemirror line widget +class InlinePreview extends PureComponent { + static get propTypes() { + return { + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + value: PropTypes.any, + variable: PropTypes.string.isRequired, + }; + } + + showInScopes(variable) { + // TODO: focus on variable value in the scopes sidepanel + // we will need more info from parent comp + } + + render() { + const { + value, + variable, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const mode = isElement(value) ? MODE.TINY : MODE.SHORT; + + return ( + <span + className="inline-preview-outer" + onClick={() => this.showInScopes(variable)} + > + <span className="inline-preview-label">{variable}:</span> + <span className="inline-preview-value"> + <Rep + object={value} + mode={mode} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + /> + </span> + </span> + ); + } +} + +export default InlinePreview; diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js new file mode 100644 index 0000000000..ad2631e01e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.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 React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; + +import actions from "../../actions"; +import assert from "../../utils/assert"; +import { connect } from "../../utils/connect"; +import InlinePreview from "./InlinePreview"; + +import "./InlinePreview.css"; + +// Handles rendering for each line ( row ) +// * Renders single widget for each line in codemirror +// * Renders InlinePreview for each preview inside the widget +class InlinePreviewRow extends PureComponent { + bookmark; + widgetNode; + + componentDidMount() { + this.updatePreviewWidget(this.props, null); + } + + componentDidUpdate(prevProps) { + this.updatePreviewWidget(this.props, prevProps); + } + + componentWillUnmount() { + this.updatePreviewWidget(null, this.props); + } + + updatePreviewWidget(props, prevProps) { + if ( + this.bookmark && + prevProps && + (!props || + prevProps.editor !== props.editor || + prevProps.line !== props.line) + ) { + this.bookmark.clear(); + this.bookmark = null; + this.widgetNode = null; + } + + if (!props) { + assert(!this.bookmark, "Inline Preview widget shouldn't be present."); + return; + } + + const { + editor, + line, + previews, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = props; + + if (!this.bookmark) { + this.widgetNode = document.createElement("div"); + this.widgetNode.classList.add("inline-preview"); + } + + ReactDOM.render( + <React.Fragment> + {previews.map(preview => ( + <InlinePreview + line={line} + key={`${line}-${preview.name}`} + variable={preview.name} + value={preview.value} + openElementInInspector={openElementInInspector} + highlightDomElement={highlightDomElement} + unHighlightDomElement={unHighlightDomElement} + /> + ))} + </React.Fragment>, + this.widgetNode + ); + + this.bookmark = editor.codeMirror.setBookmark( + { + line, + ch: Infinity, + }, + this.widgetNode + ); + } + + render() { + return null; + } +} + +export default connect(() => ({}), { + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(InlinePreviewRow); diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviews.js b/devtools/client/debugger/src/components/Editor/InlinePreviews.js new file mode 100644 index 0000000000..8778cb373c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import InlinePreviewRow from "./InlinePreviewRow"; +import { connect } from "../../utils/connect"; +import { + getSelectedFrame, + getCurrentThread, + getInlinePreviews, +} from "../../selectors"; + +function hasPreviews(previews) { + return !!previews && !!Object.keys(previews).length; +} + +class InlinePreviews extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + previews: PropTypes.object, + selectedFrame: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + }; + } + + shouldComponentUpdate({ previews }) { + return hasPreviews(previews); + } + + render() { + const { editor, selectedFrame, selectedSource, previews } = this.props; + + // Render only if currently open file is the one where debugger is paused + if ( + !selectedFrame || + selectedFrame.location.sourceId !== selectedSource.id || + !hasPreviews(previews) + ) { + return null; + } + const previewsObj = previews; + + let inlinePreviewRows; + editor.codeMirror.operation(() => { + inlinePreviewRows = Object.keys(previewsObj).map(line => { + const lineNum = parseInt(line, 10); + + return ( + <InlinePreviewRow + editor={editor} + key={line} + line={lineNum} + previews={previewsObj[line]} + /> + ); + }); + }); + + return <div>{inlinePreviewRows}</div>; + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + const selectedFrame = getSelectedFrame(state, thread); + + if (!selectedFrame) { + return { + selectedFrame: null, + previews: null, + }; + } + + return { + selectedFrame, + previews: getInlinePreviews(state, thread, selectedFrame.id), + }; +}; + +export default connect(mapStateToProps)(InlinePreviews); diff --git a/devtools/client/debugger/src/components/Editor/Preview.css b/devtools/client/debugger/src/components/Editor/Preview.css new file mode 100644 index 0000000000..35b874315e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview.css @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.popover .preview { + background: var(--theme-body-background); + width: 350px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.theme-dark .popover .preview { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview .header { + width: 100%; + line-height: 20px; + border-bottom: 1px solid #cccccc; + display: flex; + flex-direction: column; +} + +.popover .preview .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.selection, +.debug-expression.selection { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .selection, +.theme-dark .debug-expression.selection { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .selection, +.theme-dark .cm-s-mozilla .debug-expression.selection { + color: #e7ebee; +} + +.popover .preview .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview { + background: var(--theme-toolbar-background); + max-width: inherit; + border: 1px solid var(--theme-splitter-color); + box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt); + padding: 5px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; +} + +.theme-dark .tooltip .preview { + border-color: var(--theme-body-color); +} + +.tooltip .gap { + height: 4px; + padding-top: 4px; +} + +.add-to-expression-bar { + border: 1px solid var(--theme-splitter-color); + border-top: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 14px; + line-height: 30px; + background: var(--theme-toolbar-background); + color: var(--theme-text-color-inactive); + padding: 0 4px; +} + +.add-to-expression-bar .prompt { + width: 1em; +} + +.add-to-expression-bar .expression-to-save-label { + width: calc(100% - 4em); +} + +.add-to-expression-bar .expression-to-save-button { + font-size: 14px; + color: var(--theme-comment); +} diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js new file mode 100644 index 0000000000..624a78fb8b --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js @@ -0,0 +1,164 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../../utils/connect"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { StringRep }, +} = Reps; + +import actions from "../../../actions"; + +import { getThreadContext } from "../../../selectors"; + +import AccessibleImage from "../../shared/AccessibleImage"; + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const classnames = require("devtools/client/shared/classnames.js"); + +const POPUP_SELECTOR = ".preview-popup.exception-popup"; +const ANONYMOUS_FN_NAME = "<anonymous>"; + +// The exception popup works in two modes: +// a. when the stacktrace is closed the exception popup +// gets closed when the mouse leaves the popup. +// b. when the stacktrace is opened the exception popup +// gets closed only by clicking outside the popup. +class ExceptionPopup extends Component { + constructor(props) { + super(props); + this.state = { + isStacktraceExpanded: false, + }; + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + mouseout: PropTypes.func.isRequired, + selectSourceURL: PropTypes.func.isRequired, + exception: PropTypes.object.isRequired, + }; + } + + updateTopWindow() { + // The ChromeWindow is used when the stacktrace is expanded to capture all clicks + // outside the popup so the popup can be closed only by clicking outside of it. + if (this.topWindow) { + this.topWindow.removeEventListener( + "mousedown", + this.onTopWindowClick, + true + ); + this.topWindow = null; + } + this.topWindow = DevToolsUtils.getTopWindow(window.parent); + this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true); + } + + onTopWindowClick = e => { + const { cx, clearPreview } = this.props; + + // When the stactrace is expaned the exception popup gets closed + // only by clicking ouside the popup. + if (!e.target.closest(POPUP_SELECTOR)) { + clearPreview(cx); + } + }; + + onExceptionMessageClick() { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + this.updateTopWindow(); + this.setState({ isStacktraceExpanded: !isStacktraceExpanded }); + } + + buildStackFrame(frame) { + const { cx, selectSourceURL } = this.props; + const { filename, lineNumber } = frame; + const functionName = frame.functionName || ANONYMOUS_FN_NAME; + + return ( + <div + className="frame" + onClick={() => selectSourceURL(cx, filename, { line: lineNumber })} + > + <span className="title">{functionName}</span> + <span className="location"> + <span className="filename">{filename}</span>: + <span className="line">{lineNumber}</span> + </span> + </div> + ); + } + + renderStacktrace(stacktrace) { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + if (stacktrace.length && isStacktraceExpanded) { + return ( + <div className="exception-stacktrace"> + {stacktrace.map(frame => this.buildStackFrame(frame))} + </div> + ); + } + return null; + } + + renderArrowIcon(stacktrace) { + if (stacktrace.length) { + return ( + <AccessibleImage + className={classnames("arrow", { + expanded: this.state.isStacktraceExpanded, + })} + /> + ); + } + return null; + } + + render() { + const { + exception: { stacktrace, errorMessage }, + mouseout, + } = this.props; + + return ( + <div + className="preview-popup exception-popup" + dir="ltr" + onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)} + > + <div + className="exception-message" + onClick={() => this.onExceptionMessageClick()} + > + {this.renderArrowIcon(stacktrace)} + {StringRep.rep({ + object: errorMessage, + useQuotes: false, + className: "exception-text", + })} + </div> + {this.renderStacktrace(stacktrace)} + </div> + ); + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const mapDispatchToProps = { + selectSourceURL: actions.selectSourceURL, + clearPreview: actions.clearPreview, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ExceptionPopup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css new file mode 100644 index 0000000000..3e578becf1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css @@ -0,0 +1,209 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.popover .preview-popup { + background: var(--theme-body-background); + width: 350px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.preview-popup .tree { + /* Setting a fixed line height to avoid issues in custom formatters changing + * the line height like the CLJS DevTools */ + line-height: 15px; +} + +.gap svg { + pointer-events: none; +} + +.gap polygon { + pointer-events: auto; +} + +.theme-dark .popover .preview-popup { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview-popup .header-container { + width: 100%; + line-height: 15px; + display: flex; + flex-direction: row; + margin-bottom: 5px; +} + +.popover .preview-popup .logo { + width: 20px; + margin-right: 5px; +} + +.popover .preview-popup .header-container h3 { + margin: 0; + margin-bottom: 5px; + font-weight: normal; + font-size: 14px; + line-height: 20px; + margin-left: 4px; +} + +.popover .preview-popup .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.popover .preview-popup .object-node { + padding-inline-start: 0px; +} + +.preview-token:hover { + cursor: default; +} + +.preview-token, +.debug-expression.preview-token { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .preview-token, +.theme-dark .debug-expression.preview-token { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .preview-token, +.theme-dark .cm-s-mozilla .debug-expression.preview-token { + color: #e7ebee; +} + +.popover .preview-popup .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview-popup { + background: var(--theme-toolbar-background); + max-width: inherit; + border: 1px solid var(--theme-splitter-color); + box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt); + padding: 5px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; +} + +.theme-dark .tooltip .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip .gap { + height: 4px; + padding-top: 0px; +} + +.add-to-expression-bar { + border: 1px solid var(--theme-splitter-color); + border-top: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 14px; + line-height: 30px; + background: var(--theme-toolbar-background); + color: var(--theme-text-color-inactive); + padding: 0 4px; +} + +.add-to-expression-bar .prompt { + width: 1em; +} + +.add-to-expression-bar .expression-to-save-label { + width: calc(100% - 4em); +} + +.add-to-expression-bar .expression-to-save-button { + font-size: 14px; + color: var(--theme-comment); +} + +/* Exception popup */ +.exception-popup .exception-text { + color: var(--red-70); +} + +.theme-dark .exception-popup .exception-text { + color: var(--red-20); +} + +.exception-popup .exception-message { + display: flex; + align-items: center; +} + +.exception-message .arrow { + margin-inline-end: 4px; +} + +.exception-popup .exception-stacktrace { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 8px; + padding-inline: 2px 3px; + line-height: var(--theme-code-line-height); +} + +.exception-stacktrace .frame { + display: contents; + cursor: pointer; +} + +.exception-stacktrace .title { + grid-column: 1/2; + color: var(--grey-90); +} + +.theme-dark .exception-stacktrace .title { + color: white; +} + +.exception-stacktrace .location { + grid-column: -1/-2; + color: var(--theme-highlight-purple); + direction: rtl; + text-align: end; + white-space: nowrap; + /* Force the location to be on one line and crop at start if wider then max-width */ + overflow: hidden; + text-overflow: ellipsis; + max-width: 350px; +} + +.theme-dark .exception-stacktrace .location { + color: var(--blue-40); +} + +.exception-stacktrace .line { + color: var(--theme-highlight-blue); +} + +.theme-dark .exception-stacktrace .line { + color: hsl(210, 40%, 60%); +} diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js new file mode 100644 index 0000000000..3097d3c945 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js @@ -0,0 +1,382 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../../utils/connect"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, + objectInspector, +} = Reps; + +const { ObjectInspector, utils } = objectInspector; + +const { + node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject }, +} = utils; + +import ExceptionPopup from "./ExceptionPopup"; + +import actions from "../../../actions"; +import { getThreadContext } from "../../../selectors"; +import Popover from "../../shared/Popover"; +import PreviewFunction from "../../shared/PreviewFunction"; + +import "./Popup.css"; + +export class Popup extends Component { + constructor(props) { + super(props); + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editorRef: PropTypes.object.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + preview: PropTypes.object.isRequired, + selectSourceURL: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + this.addHighlightToToken(); + } + + componentWillUnmount() { + this.removeHighlightFromToken(); + } + + addHighlightToToken() { + const { target } = this.props.preview; + if (target) { + target.classList.add("preview-token"); + addHighlightToTargetSiblings(target, this.props); + } + } + + removeHighlightFromToken() { + const { target } = this.props.preview; + if (target) { + target.classList.remove("preview-token"); + removeHighlightForTargetSiblings(target); + } + } + + calculateMaxHeight = () => { + const { editorRef } = this.props; + if (!editorRef) { + return "auto"; + } + + const { height, top } = editorRef.getBoundingClientRect(); + const maxHeight = height + top; + if (maxHeight < 250) { + return maxHeight; + } + + return 250; + }; + + createElement(element) { + return document.createElement(element); + } + + renderFunctionPreview() { + const { + cx, + selectSourceURL, + preview: { resultGrip }, + } = this.props; + + if (!resultGrip) { + return null; + } + + const { location } = resultGrip; + + return ( + <div + className="preview-popup" + onClick={() => + location && + selectSourceURL(cx, location.url, { + line: location.line, + }) + } + > + <PreviewFunction func={resultGrip} /> + </div> + ); + } + + renderObjectPreview() { + const { + preview: { root, properties }, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const usesCustomFormatter = + root?.contents?.value?.useCustomFormatter ?? false; + + if (!properties.length) { + return ( + <div className="preview-popup"> + <span className="label">{L10N.getStr("preview.noProperties")}</span> + </div> + ); + } + + const roots = usesCustomFormatter ? [root] : properties; + + return ( + <div + className="preview-popup" + style={{ maxHeight: this.calculateMaxHeight() }} + > + <ObjectInspector + roots={roots} + autoExpandDepth={0} + autoReleaseObjectActors={false} + mode={usesCustomFormatter ? MODE.LONG : null} + disableWrap={true} + focusable={false} + openLink={openLink} + createElement={this.createElement} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + mayUseCustomFormatter={true} + /> + </div> + ); + } + + renderSimplePreview() { + const { + openLink, + preview: { resultGrip }, + } = this.props; + return ( + <div className="preview-popup"> + {Rep({ + object: resultGrip, + mode: MODE.LONG, + openLink, + })} + </div> + ); + } + + renderExceptionPreview(exception) { + return ( + <ExceptionPopup + exception={exception} + mouseout={this.onMouseOutException} + /> + ); + } + + renderPreview() { + // We don't have to check and + // return on `false`, `""`, `0`, `undefined` etc, + // these falsy simple typed value because we want to + // do `renderSimplePreview` on these values below. + const { + preview: { root, exception }, + } = this.props; + + if (nodeIsFunction(root)) { + return this.renderFunctionPreview(); + } + + if (nodeIsObject(root)) { + return <div>{this.renderObjectPreview()}</div>; + } + + if (exception) { + return this.renderExceptionPreview(exception); + } + + return this.renderSimplePreview(); + } + + getPreviewType() { + const { + preview: { root, properties, exception }, + } = this.props; + if ( + exception || + nodeIsPrimitive(root) || + nodeIsFunction(root) || + !Array.isArray(properties) || + properties.length === 0 + ) { + return "tooltip"; + } + + return "popover"; + } + + onMouseOut = () => { + const { clearPreview, cx } = this.props; + + clearPreview(cx); + }; + + onMouseOutException = (shouldClearOnMouseout, isExceptionStactraceOpen) => { + // onMouseOutException can be called: + // a. when the mouse leaves Popover element + // b. when the mouse leaves ExceptionPopup element + // We want to prevent closing the popup when the stacktrace + // is expanded and the mouse leaves either the Popover element + // or the ExceptionPopup element. + const { clearPreview, cx } = this.props; + + if (shouldClearOnMouseout) { + this.isExceptionStactraceOpen = isExceptionStactraceOpen; + } + + if (!this.isExceptionStactraceOpen) { + clearPreview(cx); + } + }; + + render() { + const { + preview: { cursorPos, resultGrip, exception }, + editorRef, + } = this.props; + + if ( + !exception && + (typeof resultGrip == "undefined" || resultGrip?.optimizedOut) + ) { + return null; + } + + const type = this.getPreviewType(); + return ( + <Popover + targetPosition={cursorPos} + type={type} + editorRef={editorRef} + target={this.props.preview.target} + mouseout={exception ? this.onMouseOutException : this.onMouseOut} + > + {this.renderPreview()} + </Popover> + ); + } +} + +export function addHighlightToTargetSiblings(target, props) { + // This function searches for related tokens that should also be highlighted when previewed. + // Here is the process: + // It conducts a search on the target's next siblings and then another search for the previous siblings. + // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited. + // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added. + + const tokenType = target.classList.item(0); + const previewExpression = props.preview.expression; + + if ( + tokenType && + previewExpression && + target.innerHTML !== previewExpression + ) { + let nextSibling = target.nextSibling; + let nextElementSibling = target.nextElementSibling; + + // Note: Declaring previous/next ELEMENT siblings as well because + // properties like innerHTML can't be checked on nextSibling + // without creating a flow error even if the node is an element type. + while ( + nextSibling && + nextElementSibling && + nextSibling.nodeType === 1 && + nextElementSibling.className.includes(tokenType) && + previewExpression.includes(nextElementSibling.innerHTML) + ) { + // All checks passed, add highlight and continue the search. + nextElementSibling.classList.add("preview-token"); + + nextSibling = nextSibling.nextSibling; + nextElementSibling = nextElementSibling.nextElementSibling; + } + + let previousSibling = target.previousSibling; + let previousElementSibling = target.previousElementSibling; + + while ( + previousSibling && + previousElementSibling && + previousSibling.nodeType === 1 && + previousElementSibling.className.includes(tokenType) && + previewExpression.includes(previousElementSibling.innerHTML) + ) { + // All checks passed, add highlight and continue the search. + previousElementSibling.classList.add("preview-token"); + + previousSibling = previousSibling.previousSibling; + previousElementSibling = previousElementSibling.previousElementSibling; + } + } +} + +export function removeHighlightForTargetSiblings(target) { + // Look at target's previous and next token siblings. + // If they also have the highlight class 'preview-token', + // remove that class. + let nextSibling = target.nextElementSibling; + while (nextSibling && nextSibling.className.includes("preview-token")) { + nextSibling.classList.remove("preview-token"); + nextSibling = nextSibling.nextElementSibling; + } + let previousSibling = target.previousElementSibling; + while ( + previousSibling && + previousSibling.className.includes("preview-token") + ) { + previousSibling.classList.remove("preview-token"); + previousSibling = previousSibling.previousElementSibling; + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const { + addExpression, + selectSourceURL, + openLink, + openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +} = actions; + +const mapDispatchToProps = { + addExpression, + selectSourceURL, + openLink, + openElementInInspector: openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Popup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js new file mode 100644 index 0000000000..0e2c70c557 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/index.js @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 PropTypes from "prop-types"; +import React, { PureComponent } from "react"; +import { connect } from "../../../utils/connect"; + +import Popup from "./Popup"; + +import { + getPreview, + getThreadContext, + getCurrentThread, + getHighlightedCalls, + getIsCurrentThreadPaused, +} from "../../../selectors"; +import actions from "../../../actions"; + +const EXCEPTION_MARKER = "mark-text-exception"; + +class Preview extends PureComponent { + target = null; + constructor(props) { + super(props); + this.state = { selecting: false }; + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + editorRef: PropTypes.object.isRequired, + highlightedCalls: PropTypes.array, + isPaused: PropTypes.bool.isRequired, + preview: PropTypes.object, + setExceptionPreview: PropTypes.func.isRequired, + updatePreview: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + this.updateListeners(); + } + + componentWillUnmount() { + const { codeMirror } = this.props.editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirror.off("tokenenter", this.onTokenEnter); + codeMirror.off("scroll", this.onScroll); + codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp); + codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown); + } + + updateListeners(prevProps) { + const { codeMirror } = this.props.editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + codeMirror.on("tokenenter", this.onTokenEnter); + codeMirror.on("scroll", this.onScroll); + codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp); + codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown); + } + + onTokenEnter = ({ target, tokenPos }) => { + const { cx, editor, updatePreview, highlightedCalls, setExceptionPreview } = + this.props; + + const isTargetException = target.classList.contains(EXCEPTION_MARKER); + + if (isTargetException) { + setExceptionPreview(cx, target, tokenPos, editor.codeMirror); + return; + } + + if ( + this.props.isPaused && + !this.state.selecting && + highlightedCalls === null && + !isTargetException + ) { + updatePreview(cx, target, tokenPos, editor.codeMirror); + } + }; + + onMouseUp = () => { + if (this.props.isPaused) { + this.setState({ selecting: false }); + } + }; + + onMouseDown = () => { + if (this.props.isPaused) { + this.setState({ selecting: true }); + } + }; + + onScroll = () => { + if (this.props.isPaused) { + this.props.clearPreview(this.props.cx); + } + }; + + render() { + const { preview } = this.props; + if (!preview || this.state.selecting) { + return null; + } + + return ( + <Popup + preview={preview} + editor={this.props.editor} + editorRef={this.props.editorRef} + /> + ); + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + preview: getPreview(state), + isPaused: getIsCurrentThreadPaused(state), + }; +}; + +export default connect(mapStateToProps, { + clearPreview: actions.clearPreview, + addExpression: actions.addExpression, + updatePreview: actions.updatePreview, + setExceptionPreview: actions.setExceptionPreview, +})(Preview); diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build new file mode 100644 index 0000000000..362faadc42 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "ExceptionPopup.js", + "index.js", + "Popup.js", +) diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js new file mode 100644 index 0000000000..8c58fe9c63 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + addHighlightToTargetSiblings, + removeHighlightForTargetSiblings, +} from "../Popup"; + +describe("addHighlightToTargetSiblings", () => { + it("should add preview highlight class to related target siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function (span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("cm-property"); + div.appendChild(child); + }); + + const target = div.children[1]; + const props = { + preview: { + expression: "adividedtoken", + }, + }; + + addHighlightToTargetSiblings(target, props); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(true); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(true); + } + }); + + it("should not add preview highlight class to target's related siblings after non-element nodes", () => { + const div = document.createElement("div"); + + const elementBeforePeriod = document.createElement("span"); + elementBeforePeriod.innerHTML = "object"; + elementBeforePeriod.classList.add("cm-property"); + div.appendChild(elementBeforePeriod); + + const period = document.createTextNode("."); + div.appendChild(period); + + const target = document.createElement("span"); + target.innerHTML = "property"; + target.classList.add("cm-property"); + div.appendChild(target); + + const anotherPeriod = document.createTextNode("."); + div.appendChild(anotherPeriod); + + const elementAfterPeriod = document.createElement("span"); + elementAfterPeriod.innerHTML = "anotherProperty"; + elementAfterPeriod.classList.add("cm-property"); + div.appendChild(elementAfterPeriod); + + const props = { + preview: { + expression: "object.property.anotherproperty", + }, + }; + addHighlightToTargetSiblings(target, props); + + expect(elementBeforePeriod.className.includes("preview-token")).toEqual( + false + ); + expect(elementAfterPeriod.className.includes("preview-token")).toEqual( + false + ); + }); +}); + +describe("removeHighlightForTargetSiblings", () => { + it("should remove preview highlight class from target's related siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function (span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("preview-token"); + div.appendChild(child); + }); + const target = div.children[1]; + + removeHighlightForTargetSiblings(target); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(false); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(false); + } + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.css b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css new file mode 100644 index 0000000000..0f75783c00 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.search-bar { + position: relative; + display: flex; + border-top: 1px solid var(--theme-splitter-color); + height: var(--editor-searchbar-height); +} + +/* display a fake outline above the search bar's top border, and above + the source footer's top border */ +.search-bar::before { + content: ""; + position: absolute; + z-index: 10; + top: -1px; + left: 0; + right: 0; + bottom: -1px; + border: solid 1px var(--blue-50); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.search-bar:focus-within::before { + opacity: 1; +} + +.search-bar .search-outline { + flex-grow: 1; + border-width: 0; +} + +.search-bar .result-list { + max-height: 230px; +} diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.js b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js new file mode 100644 index 0000000000..80a6d28fb0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 PropTypes from "prop-types"; +import React, { Component } from "react"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; +import { + getActiveSearch, + getSelectedSource, + getContext, + getSelectedSourceTextContent, + getSearchOptions, +} from "../../selectors"; + +import { searchKeys } from "../../constants"; +import { scrollList } from "../../utils/result-list"; + +import SearchInput from "../shared/SearchInput"; +import "./SearchInFileBar.css"; + +const { PluralForm } = require("devtools/shared/plural-form"); +const { debounce } = require("devtools/shared/debounce"); +import { renderWasmText } from "../../utils/wasm"; +import { + clearSearch, + find, + findNext, + findPrev, + removeOverlay, +} from "../../utils/editor"; +import { isFulfilled } from "../../utils/async-value"; + +function getSearchShortcut() { + return L10N.getStr("sourceSearch.search.key2"); +} + +class SearchInFileBar extends Component { + constructor(props) { + super(props); + this.state = { + query: "", + selectedResultIndex: 0, + results: { + matches: [], + matchIndex: -1, + count: 0, + index: -1, + }, + inputFocused: false, + }; + } + + static get propTypes() { + return { + closeFileSearch: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object, + modifiers: PropTypes.object.isRequired, + searchInFileEnabled: PropTypes.bool.isRequired, + selectedSourceTextContent: PropTypes.bool.isRequired, + selectedSource: PropTypes.object.isRequired, + setActiveSearch: PropTypes.func.isRequired, + querySearchWorker: PropTypes.func.isRequired, + }; + } + + componentWillUnmount() { + const { shortcuts } = this.context; + + shortcuts.off(getSearchShortcut(), this.toggleSearch); + shortcuts.off("Escape", this.onEscape); + + this.doSearch.cancel(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { query } = this.state; + // If a new source is selected update the file search results + if ( + this.props.selectedSource && + nextProps.selectedSource !== this.props.selectedSource && + this.props.searchInFileEnabled && + query + ) { + this.doSearch(query, false); + } + } + + componentDidMount() { + // overwrite this.doSearch with debounced version to + // reduce frequency of queries + this.doSearch = debounce(this.doSearch, 100); + const { shortcuts } = this.context; + + shortcuts.on(getSearchShortcut(), this.toggleSearch); + shortcuts.on("Escape", this.onEscape); + } + + componentDidUpdate(prevProps, prevState) { + if (this.refs.resultList && this.refs.resultList.refs) { + scrollList(this.refs.resultList.refs, this.state.selectedResultIndex); + } + } + + onEscape = e => { + this.closeSearch(e); + }; + + clearSearch = () => { + const { editor: ed } = this.props; + if (ed) { + const ctx = { ed, cm: ed.codeMirror }; + removeOverlay(ctx, this.state.query); + } + }; + + closeSearch = e => { + const { cx, closeFileSearch, editor, searchInFileEnabled } = this.props; + this.clearSearch(); + if (editor && searchInFileEnabled) { + closeFileSearch(cx, editor); + e.stopPropagation(); + e.preventDefault(); + } + this.setState({ inputFocused: false }); + }; + + toggleSearch = e => { + e.stopPropagation(); + e.preventDefault(); + const { editor, searchInFileEnabled, setActiveSearch } = this.props; + + // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus. + this.setState({ inputFocused: false }); + + if (!searchInFileEnabled) { + setActiveSearch("file"); + } + + if (searchInFileEnabled && editor) { + const query = editor.codeMirror.getSelection() || this.state.query; + + if (query !== "") { + this.setState({ query, inputFocused: true }); + this.doSearch(query); + } else { + this.setState({ query: "", inputFocused: true }); + } + } + }; + + doSearch = async (query, focusFirstResult = true) => { + const { editor, modifiers, selectedSourceTextContent } = this.props; + if ( + !editor || + !selectedSourceTextContent || + !isFulfilled(selectedSourceTextContent) || + !modifiers + ) { + return; + } + const selectedContent = selectedSourceTextContent.value; + + const ctx = { ed: editor, cm: editor.codeMirror }; + + if (!query) { + clearSearch(ctx.cm, query); + return; + } + + let text; + if (selectedContent.type === "wasm") { + text = renderWasmText(this.props.selectedSource.id, selectedContent).join( + "\n" + ); + } else { + text = selectedContent.value; + } + + const matches = await this.props.querySearchWorker(query, text, modifiers); + + const res = find(ctx, query, true, modifiers, focusFirstResult); + if (!res) { + return; + } + + const { ch, line } = res; + + const matchIndex = matches.findIndex( + elm => elm.line === line && elm.ch === ch + ); + this.setState({ + results: { + matches, + matchIndex, + count: matches.length, + index: ch, + }, + }); + }; + + traverseResults = (e, reverse = false) => { + e.stopPropagation(); + e.preventDefault(); + const { editor } = this.props; + + if (!editor) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + + const { modifiers } = this.props; + const { query } = this.state; + const { matches } = this.state.results; + + if (query === "" && !this.props.searchInFileEnabled) { + this.props.setActiveSearch("file"); + } + + if (modifiers) { + const findArgs = [ctx, query, true, modifiers]; + const results = reverse ? findPrev(...findArgs) : findNext(...findArgs); + + if (!results) { + return; + } + const { ch, line } = results; + const matchIndex = matches.findIndex( + elm => elm.line === line && elm.ch === ch + ); + this.setState({ + results: { + matches, + matchIndex, + count: matches.length, + index: ch, + }, + }); + } + }; + + // Handlers + + onChange = e => { + this.setState({ query: e.target.value }); + + return this.doSearch(e.target.value); + }; + + onFocus = e => { + this.setState({ inputFocused: true }); + }; + + onBlur = e => { + this.setState({ inputFocused: false }); + }; + + onKeyDown = e => { + if (e.key !== "Enter" && e.key !== "F3") { + return; + } + + this.traverseResults(e, e.shiftKey); + e.preventDefault(); + this.doSearch(e.target.value); + }; + + onHistoryScroll = query => { + this.setState({ query }); + this.doSearch(query); + }; + + // Renderers + buildSummaryMsg() { + const { + query, + results: { matchIndex, count, index }, + } = this.state; + + if (query.trim() == "") { + return ""; + } + + if (count == 0) { + return L10N.getStr("editor.noResultsFound"); + } + + if (index == -1) { + const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary1"); + return PluralForm.get(count, resultsSummaryString).replace("#1", count); + } + + const searchResultsString = L10N.getStr("editor.searchResults1"); + return PluralForm.get(count, searchResultsString) + .replace("#1", count) + .replace("%d", matchIndex + 1); + } + + shouldShowErrorEmoji() { + const { + query, + results: { count }, + } = this.state; + return !!query && !count; + } + + render() { + const { searchInFileEnabled } = this.props; + const { + results: { count }, + } = this.state; + + if (!searchInFileEnabled) { + return <div />; + } + + return ( + <div className="search-bar"> + <SearchInput + query={this.state.query} + count={count} + placeholder={L10N.getStr("sourceSearch.search.placeholder2")} + summaryMsg={this.buildSummaryMsg()} + isLoading={false} + onChange={this.onChange} + onFocus={this.onFocus} + onBlur={this.onBlur} + showErrorEmoji={this.shouldShowErrorEmoji()} + onKeyDown={this.onKeyDown} + onHistoryScroll={this.onHistoryScroll} + handleNext={e => this.traverseResults(e, false)} + handlePrev={e => this.traverseResults(e, true)} + shouldFocus={this.state.inputFocused} + showClose={true} + showExcludePatterns={false} + handleClose={this.closeSearch} + showSearchModifiers={true} + searchKey={searchKeys.FILE_SEARCH} + onToggleSearchModifier={() => this.doSearch(this.state.query)} + /> + </div> + ); + } +} + +SearchInFileBar.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = (state, p) => { + const selectedSource = getSelectedSource(state); + + return { + cx: getContext(state), + searchInFileEnabled: getActiveSearch(state) === "file", + selectedSource, + selectedSourceTextContent: getSelectedSourceTextContent(state), + modifiers: getSearchOptions(state, "file-search"), + }; +}; + +export default connect(mapStateToProps, { + setFileSearchQuery: actions.setFileSearchQuery, + setActiveSearch: actions.setActiveSearch, + closeFileSearch: actions.closeFileSearch, + querySearchWorker: actions.querySearchWorker, +})(SearchInFileBar); diff --git a/devtools/client/debugger/src/components/Editor/Tab.js b/devtools/client/debugger/src/components/Editor/Tab.js new file mode 100644 index 0000000000..2f296f9346 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tab.js @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { showMenu, buildMenu } from "../../context-menu/menu"; + +import SourceIcon from "../shared/SourceIcon"; +import { CloseButton } from "../shared/Button"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +import actions from "../../actions"; + +import { + getDisplayPath, + getFileURL, + getRawSourceURL, + getSourceQueryString, + getTruncatedFileName, + isPretty, + shouldBlackbox, +} from "../../utils/source"; +import { getTabMenuItems } from "../../utils/tabs"; +import { createLocation } from "../../utils/location"; + +import { + getSelectedLocation, + getActiveSearch, + getSourcesForTabs, + isSourceBlackBoxed, + getContext, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class Tab extends PureComponent { + static get propTypes() { + return { + activeSearch: PropTypes.string, + closeTab: PropTypes.func.isRequired, + closeTabs: PropTypes.func.isRequired, + copyToClipboard: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + onDragEnd: PropTypes.func.isRequired, + onDragOver: PropTypes.func.isRequired, + onDragStart: PropTypes.func.isRequired, + selectSource: PropTypes.func.isRequired, + selectedLocation: PropTypes.object, + showSource: PropTypes.func.isRequired, + source: PropTypes.object.isRequired, + sourceActor: PropTypes.object.isRequired, + tabSources: PropTypes.array.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + togglePrettyPrint: PropTypes.func.isRequired, + isBlackBoxed: PropTypes.bool.isRequired, + isSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + onTabContextMenu = (event, tab) => { + event.preventDefault(); + this.showContextMenu(event, tab); + }; + + showContextMenu(e, tab) { + const { + cx, + closeTab, + closeTabs, + copyToClipboard, + tabSources, + showSource, + toggleBlackBox, + togglePrettyPrint, + selectedLocation, + source, + isBlackBoxed, + isSourceOnIgnoreList, + } = this.props; + + const tabCount = tabSources.length; + const otherTabs = tabSources.filter(t => t.id !== tab); + const sourceTab = tabSources.find(t => t.id == tab); + const tabURLs = tabSources.map(t => t.url); + const otherTabURLs = otherTabs.map(t => t.url); + + if (!sourceTab || !selectedLocation || !selectedLocation.sourceId) { + return; + } + + const tabMenuItems = getTabMenuItems(); + const items = [ + { + item: { + ...tabMenuItems.closeTab, + click: () => closeTab(cx, sourceTab), + }, + }, + { + item: { + ...tabMenuItems.closeOtherTabs, + click: () => closeTabs(cx, otherTabURLs), + disabled: otherTabURLs.length === 0, + }, + }, + { + item: { + ...tabMenuItems.closeTabsToEnd, + click: () => { + const tabIndex = tabSources.findIndex(t => t.id == tab); + closeTabs( + cx, + tabURLs.filter((t, i) => i > tabIndex) + ); + }, + disabled: + tabCount === 1 || + tabSources.some((t, i) => t === tab && tabCount - 1 === i), + }, + }, + { + item: { + ...tabMenuItems.closeAllTabs, + click: () => closeTabs(cx, tabURLs), + }, + }, + { item: { type: "separator" } }, + { + item: { + ...tabMenuItems.copySource, + disabled: selectedLocation.sourceId !== tab, + click: () => copyToClipboard(sourceTab), + }, + }, + { + item: { + ...tabMenuItems.copySourceUri2, + disabled: !selectedLocation.sourceUrl, + click: () => copyToTheClipboard(getRawSourceURL(sourceTab.url)), + }, + }, + { + item: { + ...tabMenuItems.showSource, + disabled: !selectedLocation.sourceUrl, + click: () => showSource(cx, tab), + }, + }, + { + item: { + ...tabMenuItems.toggleBlackBox, + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => toggleBlackBox(cx, source), + }, + }, + { + item: { + ...tabMenuItems.prettyPrint, + click: () => togglePrettyPrint(cx, tab), + disabled: isPretty(sourceTab), + }, + }, + ]; + + showMenu(e, buildMenu(items)); + } + + isSourceSearchEnabled() { + return this.props.activeSearch === "source"; + } + + render() { + const { + cx, + selectedLocation, + selectSource, + closeTab, + source, + sourceActor, + tabSources, + onDragOver, + onDragStart, + onDragEnd, + } = this.props; + const sourceId = source.id; + const active = + selectedLocation && + sourceId == selectedLocation.sourceId && + !this.isSourceSearchEnabled(); + const isPrettyCode = isPretty(source); + + function onClickClose(e) { + e.stopPropagation(); + closeTab(cx, source); + } + + function handleTabClick(e) { + e.preventDefault(); + e.stopPropagation(); + return selectSource(cx, source, sourceActor); + } + + const className = classnames("source-tab", { + active, + pretty: isPrettyCode, + blackboxed: this.props.isBlackBoxed, + }); + + const path = getDisplayPath(source, tabSources); + const query = getSourceQueryString(source); + + return ( + <div + draggable + onDragOver={onDragOver} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + className={className} + key={sourceId} + onClick={handleTabClick} + // Accommodate middle click to close tab + onMouseUp={e => e.button === 1 && closeTab(cx, source)} + onContextMenu={e => this.onTabContextMenu(e, sourceId)} + title={getFileURL(source, false)} + > + <SourceIcon + location={createLocation({ source, sourceActor })} + forTab={true} + modifier={icon => + ["file", "javascript"].includes(icon) ? null : icon + } + /> + <div className="filename"> + {getTruncatedFileName(source, query)} + {path && <span>{`../${path}/..`}</span>} + </div> + <CloseButton + handleClick={onClickClose} + tooltip={L10N.getStr("sourceTabs.closeTabButtonTooltip")} + /> + </div> + ); + } +} + +const mapStateToProps = (state, { source }) => { + return { + cx: getContext(state), + tabSources: getSourcesForTabs(state), + selectedLocation: getSelectedLocation(state), + isBlackBoxed: isSourceBlackBoxed(state, source), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), + activeSearch: getActiveSearch(state), + }; +}; + +export default connect( + mapStateToProps, + { + selectSource: actions.selectSource, + copyToClipboard: actions.copyToClipboard, + closeTab: actions.closeTab, + closeTabs: actions.closeTabs, + togglePrettyPrint: actions.togglePrettyPrint, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + }, + null, + { + withRef: true, + } +)(Tab); diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css new file mode 100644 index 0000000000..565d8588f1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.css @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.source-header { + display: flex; + width: 100%; + height: var(--editor-header-height); + border-bottom: 1px solid var(--theme-splitter-color); + background-color: var(--theme-toolbar-background); +} + +.source-header * { + user-select: none; +} + +.source-header .command-bar { + flex: initial; + flex-shrink: 0; + border-bottom: 0; + border-inline-start: 1px solid var(--theme-splitter-color); +} + +.source-tabs { + flex: auto; + align-self: flex-start; + align-items: flex-start; + /* Reserve space for the overflow button (even if not visible) */ + padding-inline-end: 28px; +} + +.source-tab { + display: inline-flex; + align-items: center; + position: relative; + min-width: 40px; + max-width: 100%; + overflow: hidden; + padding: 4px 10px; + cursor: default; + height: calc(var(--editor-header-height) - 1px); + font-size: 12px; + background-color: transparent; + vertical-align: bottom; +} + +.source-tab::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--tab-line-color, transparent); + transition: transform 250ms var(--animation-curve), + opacity 250ms var(--animation-curve); + opacity: 0; + transform: scaleX(0); +} + +.source-tab.active { + --tab-line-color: var(--tab-line-selected-color); + color: var(--theme-toolbar-selected-color); + border-bottom-color: transparent; +} + +.source-tab:not(.active):hover { + --tab-line-color: var(--tab-line-hover-color); + background-color: var(--theme-toolbar-hover); +} + +.source-tab:hover::before, +.source-tab.active::before { + opacity: 1; + transform: scaleX(1); +} + +.source-tab .img:is(.prettyPrint,.blackBox) { + mask-size: 14px; +} + +.source-tab .img.prettyPrint { + background-color: currentColor; +} + +.source-tab .img.source-icon.blackBox { + background-color: #806414; +} + +.source-tab .filename { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-inline-end: 4px; +} + +.source-tab .filename span { + opacity: 0.7; + padding-inline-start: 4px; +} + +.source-tab .close-btn { + visibility: hidden; + margin-inline-end: -6px; +} + +.source-tab.active .close-btn { + color: inherit; +} + +.source-tab.active .close-btn, +.source-tab:hover .close-btn { + visibility: visible; +} + +.source-tab.active .source-icon { + background-color: currentColor; +} + +.source-tab .close-btn:hover, +.source-tab .close-btn:focus { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js new file mode 100644 index 0000000000..3f38f216a0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.js @@ -0,0 +1,332 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { + getSourceTabs, + getSelectedSource, + getSourcesForTabs, + getIsPaused, + getCurrentThread, + getContext, + getBlackBoxRanges, +} from "../../selectors"; +import { isVisible } from "../../utils/ui"; + +import { getHiddenTabs } from "../../utils/tabs"; +import { getFilename, isPretty, getFileURL } from "../../utils/source"; +import actions from "../../actions"; + +import "./Tabs.css"; + +import Tab from "./Tab"; +import { PaneToggleButton } from "../shared/Button"; +import Dropdown from "../shared/Dropdown"; +import AccessibleImage from "../shared/AccessibleImage"; +import CommandBar from "../SecondaryPanes/CommandBar"; + +const { debounce } = require("devtools/shared/debounce"); + +function haveTabSourcesChanged(tabSources, prevTabSources) { + if (tabSources.length !== prevTabSources.length) { + return true; + } + + for (let i = 0; i < tabSources.length; ++i) { + if (tabSources[i].id !== prevTabSources[i].id) { + return true; + } + } + + return false; +} + +class Tabs extends PureComponent { + constructor(props) { + super(props); + this.state = { + dropdownShown: false, + hiddenTabs: [], + }; + + this.onResize = debounce(() => { + this.updateHiddenTabs(); + }); + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + moveTab: PropTypes.func.isRequired, + moveTabBySourceId: PropTypes.func.isRequired, + selectSource: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + blackBoxRanges: PropTypes.object.isRequired, + startPanelCollapsed: PropTypes.bool.isRequired, + tabSources: PropTypes.array.isRequired, + tabs: PropTypes.array.isRequired, + togglePaneCollapse: PropTypes.func.isRequired, + }; + } + + get draggedSource() { + return this._draggedSource == null + ? { url: null, id: null } + : this._draggedSource; + } + + set draggedSource(source) { + this._draggedSource = source; + } + + get draggedSourceIndex() { + return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex; + } + + set draggedSourceIndex(index) { + this._draggedSourceIndex = index; + } + + componentDidUpdate(prevProps) { + if ( + this.props.selectedSource !== prevProps.selectedSource || + haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources) + ) { + this.updateHiddenTabs(); + } + } + + componentDidMount() { + window.requestIdleCallback(this.updateHiddenTabs); + window.addEventListener("resize", this.onResize); + window.document + .querySelector(".editor-pane") + .addEventListener("resizeend", this.onResize); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.onResize); + window.document + .querySelector(".editor-pane") + .removeEventListener("resizeend", this.onResize); + } + + /* + * Updates the hiddenSourceTabs state, by + * finding the source tabs which are wrapped and are not on the top row. + */ + updateHiddenTabs = () => { + if (!this.refs.sourceTabs) { + return; + } + const { selectedSource, tabSources, moveTab } = this.props; + const sourceTabEls = this.refs.sourceTabs.children; + const hiddenTabs = getHiddenTabs(tabSources, sourceTabEls); + + if ( + selectedSource && + isVisible() && + hiddenTabs.find(tab => tab.id == selectedSource.id) + ) { + moveTab(selectedSource.url, 0); + return; + } + + this.setState({ hiddenTabs }); + }; + + toggleSourcesDropdown() { + this.setState(prevState => ({ + dropdownShown: !prevState.dropdownShown, + })); + } + + getIconClass(source) { + if (isPretty(source)) { + return "prettyPrint"; + } + if (this.props.blackBoxRanges[source.url]) { + return "blackBox"; + } + return "file"; + } + + renderDropdownSource = source => { + const { cx, selectSource } = this.props; + const filename = getFilename(source); + + const onClick = () => selectSource(cx, source); + return ( + <li key={source.id} onClick={onClick} title={getFileURL(source, false)}> + <AccessibleImage + className={`dropdown-icon ${this.getIconClass(source)}`} + /> + <span className="dropdown-label">{filename}</span> + </li> + ); + }; + + onTabDragStart = (source, index) => { + this.draggedSource = source; + this.draggedSourceIndex = index; + }; + + onTabDragEnd = () => { + this.draggedSource = null; + this.draggedSourceIndex = null; + }; + + onTabDragOver = (e, source, hoveredTabIndex) => { + const { moveTabBySourceId } = this.props; + if (hoveredTabIndex === this.draggedSourceIndex) { + return; + } + + const tabDOM = ReactDOM.findDOMNode( + this.refs[`tab_${source.id}`].getWrappedInstance() + ); + + const tabDOMRect = tabDOM.getBoundingClientRect(); + const { pageX: mouseCursorX } = e; + if ( + /* Case: the mouse cursor moves into the left half of any target tab */ + mouseCursorX - tabDOMRect.left < + tabDOMRect.width / 2 + ) { + // The current tab goes to the left of the target tab + const targetTab = + hoveredTabIndex > this.draggedSourceIndex + ? hoveredTabIndex - 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } else if ( + /* Case: the mouse cursor moves into the right half of any target tab */ + mouseCursorX - tabDOMRect.left >= + tabDOMRect.width / 2 + ) { + // The current tab goes to the right of the target tab + const targetTab = + hoveredTabIndex < this.draggedSourceIndex + ? hoveredTabIndex + 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } + }; + + renderTabs() { + const { tabs } = this.props; + if (!tabs) { + return null; + } + + return ( + <div className="source-tabs" ref="sourceTabs"> + {tabs.map(({ source, sourceActor }, index) => { + return ( + <Tab + onDragStart={_ => this.onTabDragStart(source, index)} + onDragOver={e => { + this.onTabDragOver(e, source, index); + e.preventDefault(); + }} + onDragEnd={this.onTabDragEnd} + key={index} + source={source} + sourceActor={sourceActor} + ref={`tab_${source.id}`} + /> + ); + })} + </div> + ); + } + + renderDropdown() { + const { hiddenTabs } = this.state; + if (!hiddenTabs || !hiddenTabs.length) { + return null; + } + + const Panel = <ul>{hiddenTabs.map(this.renderDropdownSource)}</ul>; + const icon = <AccessibleImage className="more-tabs" />; + + return <Dropdown panel={Panel} icon={icon} />; + } + + renderCommandBar() { + const { horizontal, endPanelCollapsed, isPaused } = this.props; + if (!endPanelCollapsed || !isPaused) { + return null; + } + + return <CommandBar horizontal={horizontal} />; + } + + renderStartPanelToggleButton() { + return ( + <PaneToggleButton + position="start" + collapsed={this.props.startPanelCollapsed} + handleClick={this.props.togglePaneCollapse} + /> + ); + } + + renderEndPanelToggleButton() { + const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props; + if (!horizontal) { + return null; + } + + return ( + <PaneToggleButton + position="end" + collapsed={endPanelCollapsed} + handleClick={togglePaneCollapse} + horizontal={horizontal} + /> + ); + } + + render() { + return ( + <div className="source-header"> + {this.renderStartPanelToggleButton()} + {this.renderTabs()} + {this.renderDropdown()} + {this.renderEndPanelToggleButton()} + {this.renderCommandBar()} + </div> + ); + } +} + +const mapStateToProps = state => { + return { + cx: getContext(state), + selectedSource: getSelectedSource(state), + tabSources: getSourcesForTabs(state), + tabs: getSourceTabs(state), + blackBoxRanges: getBlackBoxRanges(state), + isPaused: getIsPaused(state, getCurrentThread(state)), + }; +}; + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + moveTab: actions.moveTab, + moveTabBySourceId: actions.moveTabBySourceId, + closeTab: actions.closeTab, + togglePaneCollapse: actions.togglePaneCollapse, + showSource: actions.showSource, +})(Tabs); diff --git a/devtools/client/debugger/src/components/Editor/index.js b/devtools/client/debugger/src/components/Editor/index.js new file mode 100644 index 0000000000..fcaa129944 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/index.js @@ -0,0 +1,808 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 PropTypes from "prop-types"; +import React, { PureComponent } from "react"; +import { bindActionCreators } from "redux"; +import ReactDOM from "react-dom"; +import { connect } from "../../utils/connect"; + +import { getLineText, isLineBlackboxed } from "./../../utils/source"; +import { createLocation } from "./../../utils/location"; +import { features } from "../../utils/prefs"; +import { getIndentation } from "../../utils/indentation"; + +import { showMenu } from "../../context-menu/menu"; +import { + createBreakpointItems, + breakpointItemActions, +} from "./menus/breakpoints"; + +import { + continueToHereItem, + editorItemActions, + blackBoxLineMenuItem, +} from "./menus/editor"; + +import { + getActiveSearch, + getSelectedLocation, + getSelectedSource, + getSelectedSourceTextContent, + getSelectedBreakableLines, + getConditionalPanelLocation, + getSymbols, + getIsCurrentThreadPaused, + getCurrentThread, + getThreadContext, + getSkipPausing, + getInlinePreview, + getEditorWrapping, + getHighlightedCalls, + getBlackBoxRanges, + isSourceBlackBoxed, + getHighlightedLineRangeForSelectedSource, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; + +// Redux actions +import actions from "../../actions"; + +import SearchInFileBar from "./SearchInFileBar"; +import HighlightLines from "./HighlightLines"; +import Preview from "./Preview"; +import Breakpoints from "./Breakpoints"; +import ColumnBreakpoints from "./ColumnBreakpoints"; +import DebugLine from "./DebugLine"; +import HighlightLine from "./HighlightLine"; +import EmptyLines from "./EmptyLines"; +import EditorMenu from "./EditorMenu"; +import ConditionalPanel from "./ConditionalPanel"; +import InlinePreviews from "./InlinePreviews"; +import HighlightCalls from "./HighlightCalls"; +import Exceptions from "./Exceptions"; +import BlackboxLines from "./BlackboxLines"; + +import { + showSourceText, + showLoading, + showErrorMessage, + getEditor, + clearEditor, + getCursorLine, + getCursorColumn, + lineAtHeight, + toSourceLine, + getDocument, + scrollToColumn, + toEditorPosition, + getSourceLocationFromMouseEvent, + hasDocument, + onMouseOver, + startOperation, + endOperation, +} from "../../utils/editor"; + +import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui"; + +const { debounce } = require("devtools/shared/debounce"); +const classnames = require("devtools/client/shared/classnames.js"); + +const { appinfo } = Services; +const isMacOS = appinfo.OS === "Darwin"; + +function isSecondary(ev) { + return isMacOS && ev.ctrlKey && ev.button === 0; +} + +function isCmd(ev) { + return isMacOS ? ev.metaKey : ev.ctrlKey; +} + +import "./Editor.css"; +import "./Breakpoints.css"; +import "./InlinePreview.css"; + +const cssVars = { + searchbarHeight: "var(--editor-searchbar-height)", +}; + +class Editor extends PureComponent { + static get propTypes() { + return { + selectedSource: PropTypes.object, + selectedSourceTextContent: PropTypes.object, + selectedSourceIsBlackBoxed: PropTypes.bool, + cx: PropTypes.object.isRequired, + closeTab: PropTypes.func.isRequired, + toggleBreakpointAtLine: PropTypes.func.isRequired, + conditionalPanelLocation: PropTypes.object, + closeConditionalPanel: PropTypes.func.isRequired, + openConditionalPanel: PropTypes.func.isRequired, + updateViewport: PropTypes.func.isRequired, + isPaused: PropTypes.bool.isRequired, + highlightCalls: PropTypes.func.isRequired, + unhighlightCalls: PropTypes.func.isRequired, + breakpointActions: PropTypes.object.isRequired, + editorActions: PropTypes.object.isRequired, + addBreakpointAtLine: PropTypes.func.isRequired, + continueToHere: PropTypes.func.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + updateCursorPosition: PropTypes.func.isRequired, + jumpToMappedLocation: PropTypes.func.isRequired, + selectedLocation: PropTypes.object, + symbols: PropTypes.object, + startPanelSize: PropTypes.number.isRequired, + endPanelSize: PropTypes.number.isRequired, + searchInFileEnabled: PropTypes.bool.isRequired, + inlinePreviewEnabled: PropTypes.bool.isRequired, + editorWrappingEnabled: PropTypes.bool.isRequired, + skipPausing: PropTypes.bool.isRequired, + blackboxedRanges: PropTypes.object.isRequired, + breakableLines: PropTypes.object.isRequired, + highlightedLineRange: PropTypes.object, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + $editorWrapper; + constructor(props) { + super(props); + + this.state = { + editor: null, + contextMenu: null, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + let { editor } = this.state; + + if (!editor && nextProps.selectedSource) { + editor = this.setupEditor(); + } + + const shouldUpdateText = + nextProps.selectedSource !== this.props.selectedSource || + nextProps.selectedSourceTextContent !== + this.props.selectedSourceTextContent || + nextProps.symbols !== this.props.symbols; + + const shouldUpdateSize = + nextProps.startPanelSize !== this.props.startPanelSize || + nextProps.endPanelSize !== this.props.endPanelSize; + + const shouldScroll = + nextProps.selectedLocation && + this.shouldScrollToLocation(nextProps, editor); + + if (shouldUpdateText || shouldUpdateSize || shouldScroll) { + startOperation(); + if (shouldUpdateText) { + this.setText(nextProps, editor); + } + if (shouldUpdateSize) { + editor.codeMirror.setSize(); + } + if (shouldScroll) { + this.scrollToLocation(nextProps, editor); + } + endOperation(); + } + + if (this.props.selectedSource != nextProps.selectedSource) { + this.props.updateViewport(); + resizeBreakpointGutter(editor.codeMirror); + resizeToggleButton(editor.codeMirror); + } + } + + setupEditor() { + const editor = getEditor(); + + // disables the default search shortcuts + editor._initShortcuts = () => {}; + + const node = ReactDOM.findDOMNode(this); + if (node instanceof HTMLElement) { + editor.appendToLocalElement(node.querySelector(".editor-mount")); + } + + const { codeMirror } = editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirror.on("gutterClick", this.onGutterClick); + + if (features.commandClick) { + document.addEventListener("keydown", this.commandKeyDown); + document.addEventListener("keyup", this.commandKeyUp); + } + + // Set code editor wrapper to be focusable + codeMirrorWrapper.tabIndex = 0; + codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e)); + codeMirrorWrapper.addEventListener("click", e => this.onClick(e)); + codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror)); + + const toggleFoldMarkerVisibility = e => { + if (node instanceof HTMLElement) { + node + .querySelectorAll(".CodeMirror-guttermarker-subtle") + .forEach(elem => { + elem.classList.toggle("visible"); + }); + } + }; + + const codeMirrorGutter = codeMirror.getGutterElement(); + codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility); + codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility); + codeMirrorWrapper.addEventListener("contextmenu", event => + this.openMenu(event) + ); + + codeMirror.on("scroll", this.onEditorScroll); + this.onEditorScroll(); + this.setState({ editor }); + return editor; + } + + componentDidMount() { + const { shortcuts } = this.context; + + shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint); + shortcuts.on( + L10N.getStr("toggleCondPanel.breakpoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("toggleCondPanel.logPoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("sourceTabs.closeTab.key"), + this.onCloseShortcutPress + ); + shortcuts.on("Esc", this.onEscape); + } + + onCloseShortcutPress = e => { + const { cx, selectedSource } = this.props; + if (selectedSource) { + e.preventDefault(); + e.stopPropagation(); + this.props.closeTab(cx, selectedSource, "shortcut"); + } + }; + + componentWillUnmount() { + const { editor } = this.state; + if (editor) { + editor.destroy(); + editor.codeMirror.off("scroll", this.onEditorScroll); + this.setState({ editor: null }); + } + + const { shortcuts } = this.context; + shortcuts.off(L10N.getStr("sourceTabs.closeTab.key")); + shortcuts.off(L10N.getStr("toggleBreakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key")); + } + + getCurrentLine() { + const { codeMirror } = this.state.editor; + const { selectedSource } = this.props; + if (!selectedSource) { + return null; + } + + const line = getCursorLine(codeMirror); + return toSourceLine(selectedSource.id, line); + } + + onToggleBreakpoint = e => { + e.preventDefault(); + e.stopPropagation(); + + const line = this.getCurrentLine(); + if (typeof line !== "number") { + return; + } + + this.props.toggleBreakpointAtLine(this.props.cx, line); + }; + + onToggleConditionalPanel = e => { + e.stopPropagation(); + e.preventDefault(); + + const { + conditionalPanelLocation, + closeConditionalPanel, + openConditionalPanel, + selectedSource, + } = this.props; + + const line = this.getCurrentLine(); + + const { codeMirror } = this.state.editor; + // add one to column for correct position in editor. + const column = getCursorColumn(codeMirror) + 1; + + if (conditionalPanelLocation) { + return closeConditionalPanel(); + } + + if (!selectedSource || typeof line !== "number") { + return null; + } + + return openConditionalPanel( + createLocation({ + line, + column, + source: selectedSource, + }), + false + ); + }; + + onEditorScroll = debounce(this.props.updateViewport, 75); + + commandKeyDown = e => { + const { key } = e; + if (this.props.isPaused && key === "Meta") { + const { cx, highlightCalls } = this.props; + highlightCalls(cx); + } + }; + + commandKeyUp = e => { + const { key } = e; + if (key === "Meta") { + const { cx, unhighlightCalls } = this.props; + unhighlightCalls(cx); + } + }; + + onKeyDown(e) { + const { codeMirror } = this.state.editor; + const { key, target } = e; + const codeWrapper = codeMirror.getWrapperElement(); + const textArea = codeWrapper.querySelector("textArea"); + + if (key === "Escape" && target == textArea) { + e.stopPropagation(); + e.preventDefault(); + codeWrapper.focus(); + } else if (key === "Enter" && target == codeWrapper) { + e.preventDefault(); + // Focus into editor's text area + textArea.focus(); + } + } + + /* + * The default Esc command is overridden in the CodeMirror keymap to allow + * the Esc keypress event to be catched by the toolbox and trigger the + * split console. Restore it here, but preventDefault if and only if there + * is a multiselection. + */ + onEscape = e => { + if (!this.state.editor) { + return; + } + + const { codeMirror } = this.state.editor; + if (codeMirror.listSelections().length > 1) { + codeMirror.execCommand("singleSelection"); + e.preventDefault(); + } + }; + + openMenu(event) { + event.stopPropagation(); + event.preventDefault(); + + const { + cx, + selectedSource, + selectedSourceTextContent, + breakpointActions, + editorActions, + isPaused, + conditionalPanelLocation, + closeConditionalPanel, + isSourceOnIgnoreList, + blackboxedRanges, + } = this.props; + const { editor } = this.state; + if (!selectedSource || !editor) { + return; + } + + // only allow one conditionalPanel location. + if (conditionalPanelLocation) { + closeConditionalPanel(); + } + + const target = event.target; + const { id: sourceId } = selectedSource; + const line = lineAtHeight(editor, sourceId, event); + + if (typeof line != "number") { + return; + } + + const location = createLocation({ + line, + column: undefined, + source: selectedSource, + }); + + if (target.classList.contains("CodeMirror-linenumber")) { + const lineText = getLineText( + sourceId, + selectedSourceTextContent, + line + ).trim(); + + showMenu(event, [ + ...createBreakpointItems(cx, location, breakpointActions, lineText), + { type: "separator" }, + continueToHereItem(cx, location, isPaused, editorActions), + { type: "separator" }, + blackBoxLineMenuItem( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + line + ), + ]); + return; + } + + if (target.getAttribute("id") === "columnmarker") { + return; + } + + this.setState({ contextMenu: event }); + } + + clearContextMenu = () => { + this.setState({ contextMenu: null }); + }; + + onGutterClick = (cm, line, gutter, ev) => { + const { + cx, + selectedSource, + conditionalPanelLocation, + closeConditionalPanel, + addBreakpointAtLine, + continueToHere, + breakableLines, + blackboxedRanges, + isSourceOnIgnoreList, + } = this.props; + + // ignore right clicks in the gutter + if (isSecondary(ev) || ev.button === 2 || !selectedSource) { + return; + } + + if (conditionalPanelLocation) { + closeConditionalPanel(); + return; + } + + if (gutter === "CodeMirror-foldgutter") { + return; + } + + const sourceLine = toSourceLine(selectedSource.id, line); + if (typeof sourceLine !== "number") { + return; + } + + // ignore clicks on a non-breakable line + if (!breakableLines.has(sourceLine)) { + return; + } + + if (isCmd(ev)) { + continueToHere( + cx, + createLocation({ + line: sourceLine, + column: undefined, + source: selectedSource, + }) + ); + return; + } + + addBreakpointAtLine( + cx, + sourceLine, + ev.altKey, + ev.shiftKey || + isLineBlackboxed( + blackboxedRanges[selectedSource.url], + sourceLine, + isSourceOnIgnoreList + ) + ); + }; + + onGutterContextMenu = event => { + this.openMenu(event); + }; + + onClick(e) { + const { cx, selectedSource, updateCursorPosition, jumpToMappedLocation } = + this.props; + + if (selectedSource) { + const sourceLocation = getSourceLocationFromMouseEvent( + this.state.editor, + selectedSource, + e + ); + + if (e.metaKey && e.altKey) { + jumpToMappedLocation(cx, sourceLocation); + } + + updateCursorPosition(sourceLocation); + } + } + + shouldScrollToLocation(nextProps, editor) { + const { selectedLocation, selectedSource, selectedSourceTextContent } = + this.props; + if ( + !editor || + !nextProps.selectedSource || + !nextProps.selectedLocation || + !nextProps.selectedLocation.line || + !nextProps.selectedSourceTextContent + ) { + return false; + } + + const isFirstLoad = + (!selectedSource || !selectedSourceTextContent) && + nextProps.selectedSourceTextContent; + const locationChanged = selectedLocation !== nextProps.selectedLocation; + const symbolsChanged = nextProps.symbols != this.props.symbols; + + return isFirstLoad || locationChanged || symbolsChanged; + } + + scrollToLocation(nextProps, editor) { + const { selectedLocation, selectedSource } = nextProps; + + let { line, column } = toEditorPosition(selectedLocation); + + if (selectedSource && hasDocument(selectedSource.id)) { + const doc = getDocument(selectedSource.id); + const lineText = doc.getLine(line); + column = Math.max(column, getIndentation(lineText)); + } + + scrollToColumn(editor.codeMirror, line, column); + } + + setText(props, editor) { + const { selectedSource, selectedSourceTextContent, symbols } = props; + + if (!editor) { + return; + } + + // check if we previously had a selected source + if (!selectedSource) { + this.clearEditor(); + return; + } + + if (!selectedSourceTextContent?.value) { + showLoading(editor); + return; + } + + if (selectedSourceTextContent.state === "rejected") { + let { value } = selectedSourceTextContent; + if (typeof value !== "string") { + value = "Unexpected source error"; + } + + this.showErrorMessage(value); + return; + } + + showSourceText(editor, selectedSource, selectedSourceTextContent, symbols); + } + + clearEditor() { + const { editor } = this.state; + if (!editor) { + return; + } + + clearEditor(editor); + } + + showErrorMessage(msg) { + const { editor } = this.state; + if (!editor) { + return; + } + + showErrorMessage(editor, msg); + } + + getInlineEditorStyles() { + const { searchInFileEnabled } = this.props; + + if (searchInFileEnabled) { + return { + height: `calc(100% - ${cssVars.searchbarHeight})`, + }; + } + + return { + height: "100%", + }; + } + + renderItems() { + const { + cx, + selectedSource, + conditionalPanelLocation, + isPaused, + inlinePreviewEnabled, + editorWrappingEnabled, + highlightedLineRange, + blackboxedRanges, + isSourceOnIgnoreList, + selectedSourceIsBlackBoxed, + } = this.props; + const { editor, contextMenu } = this.state; + + if (!selectedSource || !editor || !getDocument(selectedSource.id)) { + return null; + } + + return ( + <div> + <HighlightCalls editor={editor} selectedSource={selectedSource} /> + <DebugLine /> + <HighlightLine /> + <EmptyLines editor={editor} /> + <Breakpoints editor={editor} cx={cx} /> + <Preview editor={editor} editorRef={this.$editorWrapper} /> + {highlightedLineRange ? ( + <HighlightLines editor={editor} range={highlightedLineRange} /> + ) : null} + {isSourceOnIgnoreList || selectedSourceIsBlackBoxed ? ( + <BlackboxLines + editor={editor} + selectedSource={selectedSource} + isSourceOnIgnoreList={isSourceOnIgnoreList} + blackboxedRangesForSelectedSource={ + blackboxedRanges[selectedSource.url] + } + /> + ) : null} + <Exceptions /> + <EditorMenu + editor={editor} + contextMenu={contextMenu} + clearContextMenu={this.clearContextMenu} + selectedSource={selectedSource} + editorWrappingEnabled={editorWrappingEnabled} + /> + {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null} + <ColumnBreakpoints editor={editor} /> + {isPaused && inlinePreviewEnabled ? ( + <InlinePreviews editor={editor} selectedSource={selectedSource} /> + ) : null} + </div> + ); + } + + renderSearchInFileBar() { + if (!this.props.selectedSource) { + return null; + } + + return <SearchInFileBar editor={this.state.editor} />; + } + + render() { + const { selectedSourceIsBlackBoxed, skipPausing } = this.props; + return ( + <div + className={classnames("editor-wrapper", { + blackboxed: selectedSourceIsBlackBoxed, + "skip-pausing": skipPausing, + })} + ref={c => (this.$editorWrapper = c)} + > + <div + className="editor-mount devtools-monospace" + style={this.getInlineEditorStyles()} + /> + {this.renderSearchInFileBar()} + {this.renderItems()} + </div> + ); + } +} + +Editor.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + + return { + cx: getThreadContext(state), + selectedLocation, + selectedSource, + selectedSourceTextContent: getSelectedSourceTextContent(state), + selectedSourceIsBlackBoxed: selectedSource + ? isSourceBlackBoxed(state, selectedSource) + : null, + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + searchInFileEnabled: getActiveSearch(state) === "file", + conditionalPanelLocation: getConditionalPanelLocation(state), + symbols: getSymbols(state, selectedLocation), + isPaused: getIsCurrentThreadPaused(state), + skipPausing: getSkipPausing(state), + inlinePreviewEnabled: getInlinePreview(state), + editorWrappingEnabled: getEditorWrapping(state), + highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)), + blackboxedRanges: getBlackBoxRanges(state), + breakableLines: getSelectedBreakableLines(state), + highlightedLineRange: getHighlightedLineRangeForSelectedSource(state), + }; +}; + +const mapDispatchToProps = dispatch => ({ + ...bindActionCreators( + { + openConditionalPanel: actions.openConditionalPanel, + closeConditionalPanel: actions.closeConditionalPanel, + continueToHere: actions.continueToHere, + toggleBreakpointAtLine: actions.toggleBreakpointAtLine, + addBreakpointAtLine: actions.addBreakpointAtLine, + jumpToMappedLocation: actions.jumpToMappedLocation, + updateViewport: actions.updateViewport, + updateCursorPosition: actions.updateCursorPosition, + closeTab: actions.closeTab, + toggleBlackBox: actions.toggleBlackBox, + highlightCalls: actions.highlightCalls, + unhighlightCalls: actions.unhighlightCalls, + }, + dispatch + ), + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Editor); diff --git a/devtools/client/debugger/src/components/Editor/menus/breakpoints.js b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js new file mode 100644 index 0000000000..b130d8a9b7 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js @@ -0,0 +1,293 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 actions from "../../../actions"; +import { bindActionCreators } from "redux"; +import { features } from "../../../utils/prefs"; +import { formatKeyShortcut } from "../../../utils/text"; +import { isLineBlackboxed } from "../../../utils/source"; + +export const addBreakpointItem = (cx, location, breakpointActions) => ({ + id: "node-menu-add-breakpoint", + label: L10N.getStr("editor.addBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.addBreakpoint(cx, location), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const removeBreakpointItem = (cx, breakpoint, breakpointActions) => ({ + id: "node-menu-remove-breakpoint", + label: L10N.getStr("editor.removeBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.removeBreakpoint(cx, breakpoint), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const addConditionalBreakpointItem = (location, breakpointActions) => ({ + id: "node-menu-add-conditional-breakpoint", + label: L10N.getStr("editor.addConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const editConditionalBreakpointItem = (location, breakpointActions) => ({ + id: "node-menu-edit-conditional-breakpoint", + label: L10N.getStr("editor.editConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const conditionalBreakpointItem = ( + breakpoint, + location, + breakpointActions +) => { + const { + options: { condition }, + } = breakpoint; + return condition + ? editConditionalBreakpointItem(location, breakpointActions) + : addConditionalBreakpointItem(location, breakpointActions); +}; + +export const addLogPointItem = (location, breakpointActions) => ({ + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const editLogPointItem = (location, breakpointActions) => ({ + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const logPointItem = (breakpoint, location, breakpointActions) => { + const { + options: { logValue }, + } = breakpoint; + return logValue + ? editLogPointItem(location, breakpointActions) + : addLogPointItem(location, breakpointActions); +}; + +export const toggleDisabledBreakpointItem = ( + cx, + breakpoint, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) => { + return { + accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + breakpoint.location.line, + isSelectedSourceOnIgnoreList + ), + click: () => breakpointActions.toggleDisabledBreakpoint(cx, breakpoint), + ...(breakpoint.disabled + ? { + id: "node-menu-enable-breakpoint", + label: L10N.getStr("editor.enableBreakpoint"), + } + : { + id: "node-menu-disable-breakpoint", + label: L10N.getStr("editor.disableBreakpoint"), + }), + }; +}; + +export const toggleDbgStatementItem = ( + cx, + location, + breakpointActions, + breakpoint +) => { + if (breakpoint && breakpoint.options.condition === "false") { + return { + disabled: false, + id: "node-menu-enable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.enabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + ...breakpoint.options, + condition: null, + }), + }; + } + + return { + disabled: false, + id: "node-menu-disable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.disabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + condition: "false", + }), + }; +}; + +export function breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) { + const items = [ + removeBreakpointItem(cx, breakpoint, breakpointActions), + toggleDisabledBreakpointItem( + cx, + breakpoint, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ), + ]; + + if (breakpoint.originalText.startsWith("debugger")) { + items.push( + { type: "separator" }, + toggleDbgStatementItem( + cx, + selectedLocation, + breakpointActions, + breakpoint + ) + ); + } + + items.push( + { type: "separator" }, + removeBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + breakpoint.disabled + ? enableBreakpointsOnLineItem( + cx, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ) + : disableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + { type: "separator" } + ); + + items.push( + conditionalBreakpointItem(breakpoint, selectedLocation, breakpointActions) + ); + items.push(logPointItem(breakpoint, selectedLocation, breakpointActions)); + + return items; +} + +export function createBreakpointItems( + cx, + location, + breakpointActions, + lineText +) { + const items = [ + addBreakpointItem(cx, location, breakpointActions), + addConditionalBreakpointItem(location, breakpointActions), + ]; + + if (features.logPoints) { + items.push(addLogPointItem(location, breakpointActions)); + } + + if (lineText && lineText.startsWith("debugger")) { + items.push(toggleDbgStatementItem(cx, location, breakpointActions)); + } + return items; +} + +// ToDo: Only enable if there are more than one breakpoints on a line? +export const removeBreakpointsOnLineItem = ( + cx, + location, + breakpointActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.removeBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const enableBreakpointsOnLineItem = ( + cx, + location, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + location.line, + isSelectedSourceOnIgnoreList + ), + click: () => + breakpointActions.enableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const disableBreakpointsOnLineItem = ( + cx, + location, + breakpointActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.disableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export function breakpointItemActions(dispatch) { + return bindActionCreators( + { + addBreakpoint: actions.addBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + removeBreakpointsAtLine: actions.removeBreakpointsAtLine, + enableBreakpointsAtLine: actions.enableBreakpointsAtLine, + disableBreakpointsAtLine: actions.disableBreakpointsAtLine, + disableBreakpoint: actions.disableBreakpoint, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine, + setBreakpointOptions: actions.setBreakpointOptions, + openConditionalPanel: actions.openConditionalPanel, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/editor.js b/devtools/client/debugger/src/components/Editor/menus/editor.js new file mode 100644 index 0000000000..5ed3c96f6f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/editor.js @@ -0,0 +1,403 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { bindActionCreators } from "redux"; + +import { copyToTheClipboard } from "../../../utils/clipboard"; +import { + getRawSourceURL, + getFilename, + shouldBlackbox, + findBlackBoxRange, +} from "../../../utils/source"; +import { toSourceLine } from "../../../utils/editor"; +import { downloadFile } from "../../../utils/utils"; +import { features } from "../../../utils/prefs"; + +import { isFulfilled } from "../../../utils/async-value"; +import actions from "../../../actions"; + +// Menu Items +export const continueToHereItem = (cx, location, isPaused, editorActions) => ({ + accesskey: L10N.getStr("editor.continueToHere.accesskey"), + disabled: !isPaused, + click: () => editorActions.continueToHere(cx, location), + id: "node-menu-continue-to-here", + label: L10N.getStr("editor.continueToHere.label"), +}); + +const copyToClipboardItem = (selectionText, editorActions) => ({ + id: "node-menu-copy-to-clipboard", + label: L10N.getStr("copyToClipboard.label"), + accesskey: L10N.getStr("copyToClipboard.accesskey"), + disabled: selectionText.length === 0, + click: () => copyToTheClipboard(selectionText), +}); + +const copySourceItem = (selectedContent, editorActions) => ({ + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + click: () => + selectedContent.type === "text" && + copyToTheClipboard(selectedContent.value), +}); + +const copySourceUri2Item = (selectedSource, editorActions) => ({ + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: !selectedSource.url, + click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)), +}); + +const jumpToMappedLocationItem = ( + cx, + selectedSource, + location, + hasMappedLocation, + editorActions +) => ({ + id: "node-menu-jump", + label: L10N.getFormatStr( + "editor.jumpToMappedLocation1", + selectedSource.isOriginal + ? L10N.getStr("generated") + : L10N.getStr("original") + ), + accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"), + disabled: !hasMappedLocation, + click: () => editorActions.jumpToMappedLocation(cx, location), +}); + +const showSourceMenuItem = (cx, selectedSource, editorActions) => ({ + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: !selectedSource.url, + click: () => editorActions.showSource(cx, selectedSource.id), +}); + +const blackBoxMenuItem = ( + cx, + selectedSource, + blackboxedRanges, + editorActions, + isSourceOnIgnoreList +) => { + const isBlackBoxed = !!blackboxedRanges[selectedSource.url]; + return { + id: "node-menu-blackbox", + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + accesskey: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore.accesskey") + : L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(selectedSource), + click: () => editorActions.toggleBlackBox(cx, selectedSource), + }; +}; + +export const blackBoxLineMenuItem = ( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + // the clickedLine is passed when the context menu + // is opened from the gutter, it is not available when the + // the context menu is opened from the editor. + clickedLine = null +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = clickedLine ?? toSourceLine(selectedSource.id, from.line); + const endLine = clickedLine ?? toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLineIsBlackBoxed = !!blackboxRange; + + const isSingleLine = selectedLineIsBlackBoxed + ? blackboxRange.start.line == blackboxRange.end.line + : startLine == endLine; + + const isSourceFullyBlackboxed = + blackboxedRanges[selectedSource.url] && + !blackboxedRanges[selectedSource.url].length; + + // The ignore/unignore line context menu item should be disabled when + // 1) The source is on the sourcemap ignore list + // 2) The whole source is blackboxed or + // 3) Multiple lines are blackboxed or + // 4) Multiple lines are selected in the editor + const shouldDisable = + isSourceOnIgnoreList || isSourceFullyBlackboxed || !isSingleLine; + + return { + id: "node-menu-blackbox-line", + label: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine") + : L10N.getStr("ignoreContextItem.unignoreLine"), + accesskey: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLine.accesskey"), + disabled: shouldDisable, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: clickedLine == null ? from.ch : 0, + }, + end: { + line: endLine, + column: clickedLine == null ? to.ch : 0, + }, + }; + + editorActions.toggleBlackBox( + cx, + selectedSource, + !selectedLineIsBlackBoxed, + selectedLineIsBlackBoxed ? [blackboxRange] : [selectionRange] + ); + }, + }; +}; + +const blackBoxLinesMenuItem = ( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = toSourceLine(selectedSource.id, from.line); + const endLine = toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLinesAreBlackBoxed = !!blackboxRange; + + return { + id: "node-menu-blackbox-lines", + label: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines") + : L10N.getStr("ignoreContextItem.unignoreLines"), + accesskey: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLines.accesskey"), + disabled: isSourceOnIgnoreList, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: from.ch, + }, + end: { + line: endLine, + column: to.ch, + }, + }; + + editorActions.toggleBlackBox( + cx, + selectedSource, + !selectedLinesAreBlackBoxed, + selectedLinesAreBlackBoxed ? [blackboxRange] : [selectionRange] + ); + }, + }; +}; + +const watchExpressionItem = ( + cx, + selectedSource, + selectionText, + editorActions +) => ({ + id: "node-menu-add-watch-expression", + label: L10N.getStr("expressions.label"), + accesskey: L10N.getStr("expressions.accesskey"), + click: () => editorActions.addExpression(cx, selectionText), +}); + +const evaluateInConsoleItem = ( + selectedSource, + selectionText, + editorActions +) => ({ + id: "node-menu-evaluate-in-console", + label: L10N.getStr("evaluateInConsole.label"), + click: () => editorActions.evaluateInConsole(selectionText), +}); + +const downloadFileItem = (selectedSource, selectedContent, editorActions) => ({ + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + click: () => downloadFile(selectedContent, getFilename(selectedSource)), +}); + +const inlinePreviewItem = editorActions => ({ + id: "node-menu-inline-preview", + label: features.inlinePreview + ? L10N.getStr("inlinePreview.hide.label") + : L10N.getStr("inlinePreview.show.label"), + click: () => editorActions.toggleInlinePreview(!features.inlinePreview), +}); + +const editorWrappingItem = (editorActions, editorWrappingEnabled) => ({ + id: "node-menu-editor-wrapping", + label: editorWrappingEnabled + ? L10N.getStr("editorWrapping.hide.label") + : L10N.getStr("editorWrapping.show.label"), + click: () => editorActions.toggleEditorWrapping(!editorWrappingEnabled), +}); + +export function editorMenuItems({ + cx, + editorActions, + selectedSource, + blackboxedRanges, + location, + selectionText, + hasMappedLocation, + isTextSelected, + isPaused, + editorWrappingEnabled, + editor, + isSourceOnIgnoreList, +}) { + const items = []; + + const content = + selectedSource.content && isFulfilled(selectedSource.content) + ? selectedSource.content.value + : null; + + items.push( + jumpToMappedLocationItem( + cx, + selectedSource, + location, + hasMappedLocation, + editorActions + ), + continueToHereItem(cx, location, isPaused, editorActions), + { type: "separator" }, + copyToClipboardItem(selectionText, editorActions), + ...(!selectedSource.isWasm + ? [ + ...(content ? [copySourceItem(content, editorActions)] : []), + copySourceUri2Item(selectedSource, editorActions), + ] + : []), + ...(content + ? [downloadFileItem(selectedSource, content, editorActions)] + : []), + { type: "separator" }, + showSourceMenuItem(cx, selectedSource, editorActions), + { type: "separator" }, + blackBoxMenuItem( + cx, + selectedSource, + blackboxedRanges, + editorActions, + isSourceOnIgnoreList + ) + ); + + const startLine = toSourceLine( + selectedSource.id, + editor.codeMirror.getCursor("from").line + ); + const endLine = toSourceLine( + selectedSource.id, + editor.codeMirror.getCursor("to").line + ); + + // Find any blackbox ranges that exist for the selected lines + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const isMultiLineSelection = blackboxRange + ? blackboxRange.start.line !== blackboxRange.end.line + : startLine !== endLine; + + // When the range is defined and is an empty array, + // the whole source is blackboxed + const theWholeSourceIsBlackBoxed = + blackboxedRanges[selectedSource.url] && + !blackboxedRanges[selectedSource.url].length; + + if (!theWholeSourceIsBlackBoxed) { + const blackBoxSourceLinesMenuItem = isMultiLineSelection + ? blackBoxLinesMenuItem + : blackBoxLineMenuItem; + + items.push( + blackBoxSourceLinesMenuItem( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList + ) + ); + } + + if (isTextSelected) { + items.push( + { type: "separator" }, + watchExpressionItem(cx, selectedSource, selectionText, editorActions), + evaluateInConsoleItem(selectedSource, selectionText, editorActions) + ); + } + + items.push( + { type: "separator" }, + inlinePreviewItem(editorActions), + editorWrappingItem(editorActions, editorWrappingEnabled) + ); + + return items; +} + +export function editorItemActions(dispatch) { + return bindActionCreators( + { + addExpression: actions.addExpression, + continueToHere: actions.continueToHere, + evaluateInConsole: actions.evaluateInConsole, + flashLineRange: actions.flashLineRange, + jumpToMappedLocation: actions.jumpToMappedLocation, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + toggleBlackBoxLines: actions.toggleBlackBoxLines, + toggleInlinePreview: actions.toggleInlinePreview, + toggleEditorWrapping: actions.toggleEditorWrapping, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/moz.build b/devtools/client/debugger/src/components/Editor/menus/moz.build new file mode 100644 index 0000000000..18009aa2db --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "breakpoints.js", + "editor.js", + "source.js", +) diff --git a/devtools/client/debugger/src/components/Editor/menus/source.js b/devtools/client/debugger/src/components/Editor/menus/source.js new file mode 100644 index 0000000000..0ba8834e6f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/source.js @@ -0,0 +1,3 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ diff --git a/devtools/client/debugger/src/components/Editor/moz.build b/devtools/client/debugger/src/components/Editor/moz.build new file mode 100644 index 0000000000..b31918f2e0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/moz.build @@ -0,0 +1,34 @@ +# 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 += [ + "menus", + "Preview", +] + +CompiledModules( + "BlackboxLines.js", + "Breakpoint.js", + "Breakpoints.js", + "ColumnBreakpoint.js", + "ColumnBreakpoints.js", + "ConditionalPanel.js", + "DebugLine.js", + "EditorMenu.js", + "EmptyLines.js", + "Exception.js", + "Exceptions.js", + "Footer.js", + "HighlightCalls.js", + "HighlightLine.js", + "HighlightLines.js", + "index.js", + "InlinePreview.js", + "InlinePreviewRow.js", + "InlinePreviews.js", + "SearchInFileBar.js", + "Tab.js", + "Tabs.js", +) diff --git a/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js new file mode 100644 index 0000000000..915b812dff --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import Breakpoints from "../Breakpoints"; + +const BreakpointsComponent = Breakpoints.WrappedComponent; + +function generateDefaults(overrides) { + const sourceId = "server1.conn1.child1/source1"; + const matchingBreakpoints = [{ location: { source: { id: sourceId } } }]; + + return { + selectedSource: { sourceId, get: () => false }, + editor: { + codeMirror: { + setGutterMarker: jest.fn(), + }, + }, + blackboxedRanges: {}, + cx: {}, + breakpointActions: {}, + editorActions: {}, + breakpoints: matchingBreakpoints, + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<BreakpointsComponent {...props} />); + return { component, props }; +} + +describe("Breakpoints Component", () => { + it("should render breakpoints without columns", async () => { + const sourceId = "server1.conn1.child1/source1"; + const breakpoints = [{ location: { source: { id: sourceId } } }]; + + const { component, props } = render({ breakpoints }); + expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length); + }); + + it("should render breakpoints with columns", async () => { + const sourceId = "server1.conn1.child1/source1"; + const breakpoints = [{ location: { column: 2, source: { id: sourceId } } }]; + + const { component, props } = render({ breakpoints }); + expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js new file mode 100644 index 0000000000..05e4dcb727 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js @@ -0,0 +1,77 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { mount } from "enzyme"; +import { ConditionalPanel } from "../ConditionalPanel"; +import * as mocks from "../../../utils/test-mockup"; + +const source = mocks.makeMockSource(); + +function generateDefaults(overrides, log, line, column, condition, logValue) { + const breakpoint = mocks.makeMockBreakpoint(source, line, column); + breakpoint.options.condition = condition; + breakpoint.options.logValue = logValue; + + return { + editor: { + CodeMirror: { + fromTextArea: jest.fn(() => { + return { + on: jest.fn(), + getWrapperElement: jest.fn(() => { + return { + addEventListener: jest.fn(), + }; + }), + focus: jest.fn(), + setCursor: jest.fn(), + lineCount: jest.fn(), + }; + }), + }, + codeMirror: { + addLineWidget: jest.fn(), + }, + }, + location: breakpoint.location, + source, + breakpoint, + log, + getDefaultValue: jest.fn(), + openConditionalPanel: jest.fn(), + closeConditionalPanel: jest.fn(), + ...overrides, + }; +} + +function render(log, line, column, condition, logValue, overrides = {}) { + const defaults = generateDefaults( + overrides, + log, + line, + column, + condition, + logValue + ); + const props = { ...defaults, ...overrides }; + const wrapper = mount(<ConditionalPanel {...props} />); + return { wrapper, props }; +} + +describe("ConditionalPanel", () => { + it("it should render at location of selected breakpoint", () => { + const { wrapper } = render(false, 2, 2); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with condition at selected breakpoint location", () => { + const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log"); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with logpoint at selected breakpoint location", () => { + const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log"); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js new file mode 100644 index 0000000000..a7fcb53a2d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js @@ -0,0 +1,85 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import DebugLine from "../DebugLine"; + +import { setDocument } from "../../../utils/editor"; + +function createMockDocument(clear) { + const doc = { + addLineClass: jest.fn(), + removeLineClass: jest.fn(), + markText: jest.fn(() => ({ clear })), + getLine: line => "", + }; + + return doc; +} + +function generateDefaults(editor, overrides) { + return { + editor, + pauseInfo: { + why: { type: "breakpoint" }, + }, + frame: null, + sourceTextContent: null, + ...overrides, + }; +} + +function createLocation(line) { + return { + source: { + id: "foo", + }, + sourceId: "foo", + line, + column: 2, + }; +} + +function render(overrides = {}) { + const clear = jest.fn(); + const editor = { codeMirror: {} }; + const props = generateDefaults(editor, overrides); + + const doc = createMockDocument(clear); + setDocument("foo", doc); + + const component = shallow(<DebugLine.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, editor, doc }; +} + +describe("DebugLine Component", () => { + describe("pausing at the first location", () => { + describe("when there is no selected frame", () => { + it("should not set the debug line", () => { + const { component, props, doc } = render({ frame: null }); + const line = 2; + const location = createLocation(line); + + component.setProps({ ...props, location }); + expect(doc.removeLineClass).not.toHaveBeenCalled(); + }); + }); + + describe("when there is a different source", () => { + it("should not set the debug line", async () => { + const { component, doc } = render(); + const newSelectedFrame = { location: { sourceId: "bar" } }; + expect(doc.removeLineClass).not.toHaveBeenCalled(); + + component.setProps({ frame: newSelectedFrame }); + expect(doc.removeLineClass).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js new file mode 100644 index 0000000000..b58ba45cb3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js @@ -0,0 +1,67 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import SourceFooter from "../Footer"; +import { createSourceObject } from "../../../utils/test-head"; +import { setDocument } from "../../../utils/editor"; + +function createMockDocument(clear, position) { + const doc = { + getCursor: jest.fn(() => position), + }; + return doc; +} + +function generateDefaults(overrides) { + return { + editor: { + codeMirror: { + doc: {}, + cursorActivity: jest.fn(), + on: jest.fn(), + }, + }, + endPanelCollapsed: false, + selectedSource: { + ...createSourceObject("foo"), + content: null, + }, + ...overrides, + }; +} + +function render(overrides = {}, position = { line: 0, column: 0 }) { + const clear = jest.fn(); + const props = generateDefaults(overrides); + + const doc = createMockDocument(clear, position); + setDocument(props.selectedSource.id, doc); + + const component = shallow(<SourceFooter.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, doc }; +} + +describe("SourceFooter Component", () => { + describe("default case", () => { + it("should render", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + }); + + describe("move cursor", () => { + it("should render new cursor position", () => { + const { component } = render(); + component.setState({ cursorPosition: { line: 5, column: 10 } }); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap new file mode 100644 index 0000000000..48cda915a4 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breakpoints Component should render breakpoints with columns 1`] = ` +<div> + <Breakpoint + breakpoint={ + Object { + "location": Object { + "column": 2, + "source": Object { + "id": "server1.conn1.child1/source1", + }, + }, + } + } + breakpointActions={Object {}} + cx={Object {}} + editor={ + Object { + "codeMirror": Object { + "setGutterMarker": [MockFunction], + }, + } + } + editorActions={Object {}} + key="undefined:undefined:2" + selectedSource={ + Object { + "get": [Function], + "sourceId": "server1.conn1.child1/source1", + } + } + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap new file mode 100644 index 0000000000..d2f52bb6e3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap @@ -0,0 +1,630 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConditionalPanel it should render at location of selected breakpoint 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": undefined, + "logValue": undefined, + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea />, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 1, + <div> + <div + class="conditional-breakpoint-panel" + > + <div + class="prompt" + > + » + </div> + <textarea /> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with condition at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": "I'm a condition", + "logValue": "not a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a condition + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 2, + <div> + <div + class="conditional-breakpoint-panel" + > + <div + class="prompt" + > + » + </div> + <textarea> + I'm a condition + </textarea> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with logpoint at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": "not a condition", + "logValue": "I'm a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a log + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Log message, e.g. displayName", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 3, + <div> + <div + class="conditional-breakpoint-panel log-point" + > + <div + class="prompt" + > + » + </div> + <textarea> + I'm a log + </textarea> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={true} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap new file mode 100644 index 0000000000..d6123d4c67 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SourceFooter Component default case should render 1`] = ` +<div + className="source-footer" +> + <div + className="source-footer-start" + > + <div + className="commands" + > + <button + aria-label="Ignore source" + className="action black-box" + key="black-box" + onClick={[Function]} + title="Ignore source" + > + <AccessibleImage + className="blackBox" + /> + </button> + <button + className="action prettyPrint" + disabled={true} + key="prettyPrint" + onClick={[Function]} + > + <AccessibleImage + className="prettyPrint" + /> + </button> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 1, column 1)" + > + (1, 1) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; + +exports[`SourceFooter Component move cursor should render new cursor position 1`] = ` +<div + className="source-footer" +> + <div + className="source-footer-start" + > + <div + className="commands" + > + <button + aria-label="Ignore source" + className="action black-box" + key="black-box" + onClick={[Function]} + title="Ignore source" + > + <AccessibleImage + className="blackBox" + /> + </button> + <button + className="action prettyPrint" + disabled={true} + key="prettyPrint" + onClick={[Function]} + > + <AccessibleImage + className="prettyPrint" + /> + </button> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 6, column 11)" + > + (6, 11) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.css b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css new file mode 100644 index 0000000000..cbad0bddc3 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css @@ -0,0 +1,205 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + + +.sources-panel .outline { + display: flex; + height: 100%; +} + +.source-outline-tabs { + font-size: 12px; + width: 100%; + background: var(--theme-body-background); + display: flex; + user-select: none; + box-sizing: border-box; + height: var(--editor-header-height); + margin: 0; + padding: 0; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.source-outline-tabs .tab { + align-items: center; + background-color: var(--theme-toolbar-background); + color: var(--theme-toolbar-color); + cursor: default; + display: inline-flex; + flex: 1; + justify-content: center; + overflow: hidden; + padding: 4px 8px; + position: relative; +} + +.source-outline-tabs .tab::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--tab-line-color, transparent); + transition: transform 250ms var(--animation-curve), + opacity 250ms var(--animation-curve); + opacity: 0; + transform: scaleX(0); +} + +.source-outline-tabs .tab.active { + --tab-line-color: var(--tab-line-selected-color); + color: var(--theme-toolbar-selected-color); + border-bottom-color: transparent; +} + +.source-outline-tabs .tab:not(.active):hover { + --tab-line-color: var(--tab-line-hover-color); + background-color: var(--theme-toolbar-hover); +} + +.source-outline-tabs .tab:hover::before, +.source-outline-tabs .tab.active::before { + opacity: 1; + transform: scaleX(1); +} + +.source-outline-panel { + flex: 1; + overflow: auto; +} + +.outline { + overflow-y: hidden; +} + +.outline > div { + width: 100%; + position: relative; +} + +.outline-pane-info { + padding: 0.5em; + width: 100%; + font-style: italic; + text-align: center; + user-select: none; + font-size: 12px; + overflow: hidden; +} + +.outline-list { + margin: 0; + padding: 4px 0; + position: absolute; + top: 25px; + bottom: 25px; + left: 0; + right: 0; + list-style-type: none; + overflow: auto; +} + +.outline-list__class-list { + margin: 0; + padding: 0; + list-style: none; +} + +.outline-list__class-list > .outline-list__element { + padding-inline-start: 2rem; +} + +.outline-list__class-list .function-signature .function-name { + color: var(--theme-highlight-green); +} + +.outline-list .function-signature .paren { + color: inherit; +} + +.outline-list__class h2 { + font-weight: normal; + font-size: 1em; + padding: 3px 0; + padding-inline-start: 10px; + color: var(--blue-55); + margin: 0; +} + +.outline-list__class:not(:first-child) h2 { + margin-top: 12px; +} + +.outline-list h2:hover { + background: var(--theme-toolbar-background-hover); +} + +.theme-dark .outline-list h2 { + color: var(--theme-highlight-blue); +} + +.outline-list h2 .keyword { + color: var(--theme-highlight-red); +} + +.outline-list__class h2.focused { + background: var(--theme-selection-background); +} + +.outline-list__class h2.focused, +.outline-list__class h2.focused .keyword { + color: var(--theme-selection-color); +} + +.outline-list__element { + padding: 3px 10px 3px 10px; + cursor: default; + white-space: nowrap; +} + +.outline-list > .outline-list__element { + padding-inline-start: 1rem; +} + +.outline-list__element-icon { + padding-inline-end: 0.4rem; +} + +.outline-list__element:hover { + background: var(--theme-toolbar-background-hover); +} + +.outline-list__element.focused { + background: var(--theme-selection-background); +} + +.outline-list__element.focused .outline-list__element-icon, +.outline-list__element.focused .function-signature * { + color: var(--theme-selection-color); +} + +.outline-footer { + display: flex; + box-sizing: border-box; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 25px; + background: var(--theme-body-background); + border-top: 1px solid var(--theme-splitter-color); + opacity: 1; + z-index: 1; + user-select: none; +} + +.outline-footer button { + color: var(--theme-body-color); +} + +.outline-footer button.active { + background: var(--theme-selection-background); + color: var(--theme-selection-color); +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.js b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js new file mode 100644 index 0000000000..8e0aa17ca4 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js @@ -0,0 +1,372 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; +import { connect } from "../../utils/connect"; +import { score as fuzzaldrinScore } from "fuzzaldrin-plus"; + +import { containsPosition, positionAfter } from "../../utils/ast"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { findFunctionText } from "../../utils/function"; +import { createLocation } from "../../utils/location"; + +import actions from "../../actions"; +import { + getSelectedLocation, + getSelectedSource, + getSelectedSourceTextContent, + getSymbols, + getCursorPosition, + getContext, +} from "../../selectors"; + +import OutlineFilter from "./OutlineFilter"; +import "./Outline.css"; +import PreviewFunction from "../shared/PreviewFunction"; + +const classnames = require("devtools/client/shared/classnames.js"); + +// Set higher to make the fuzzaldrin filter more specific +const FUZZALDRIN_FILTER_THRESHOLD = 15000; + +/** + * Check whether the name argument matches the fuzzy filter argument + */ +const filterOutlineItem = (name, filter) => { + if (!filter) { + return true; + } + + if (filter.length === 1) { + // when filter is a single char just check if it starts with the char + return filter.toLowerCase() === name.toLowerCase()[0]; + } + return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD; +}; + +// Checks if an element is visible inside its parent element +function isVisible(element, parent) { + const parentRect = parent.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + const parentTop = parentRect.top; + const parentBottom = parentRect.bottom; + const elTop = elementRect.top; + const elBottom = elementRect.bottom; + + return parentTop < elTop && parentBottom > elBottom; +} + +export class Outline extends Component { + constructor(props) { + super(props); + this.focusedElRef = null; + this.state = { filter: "", focusedItem: null }; + } + + static get propTypes() { + return { + alphabetizeOutline: PropTypes.bool.isRequired, + cursorPosition: PropTypes.object, + cx: PropTypes.object.isRequired, + flashLineRange: PropTypes.func.isRequired, + getFunctionText: PropTypes.func.isRequired, + onAlphabetizeClick: PropTypes.func.isRequired, + selectLocation: PropTypes.func.isRequired, + selectedSource: PropTypes.object.isRequired, + symbols: PropTypes.object.isRequired, + }; + } + + componentDidUpdate(prevProps) { + const { cursorPosition, symbols } = this.props; + if ( + cursorPosition && + symbols && + cursorPosition !== prevProps.cursorPosition + ) { + this.setFocus(cursorPosition); + } + + if ( + this.focusedElRef && + !isVisible(this.focusedElRef, this.refs.outlineList) + ) { + this.focusedElRef.scrollIntoView({ block: "center" }); + } + } + + setFocus(cursorPosition) { + const { symbols } = this.props; + let classes = []; + let functions = []; + + if (symbols) { + ({ classes, functions } = symbols); + } + + // Find items that enclose the selected location + const enclosedItems = [...classes, ...functions].filter( + ({ name, location }) => + name != "anonymous" && containsPosition(location, cursorPosition) + ); + + if (!enclosedItems.length) { + this.setState({ focusedItem: null }); + return; + } + + // Find the closest item to the selected location to focus + const closestItem = enclosedItems.reduce((item, closest) => + positionAfter(item.location, closest.location) ? item : closest + ); + + this.setState({ focusedItem: closestItem }); + } + + selectItem(selectedItem) { + const { cx, selectedSource, selectLocation } = this.props; + if (!selectedSource || !selectedItem) { + return; + } + + selectLocation( + cx, + createLocation({ + source: selectedSource, + line: selectedItem.location.start.line, + column: selectedItem.location.start.column, + }) + ); + + this.setState({ focusedItem: selectedItem }); + } + + onContextMenu(event, func) { + event.stopPropagation(); + event.preventDefault(); + + const { selectedSource, flashLineRange, getFunctionText } = this.props; + + if (!selectedSource) { + return; + } + + const sourceLine = func.location.start.line; + const functionText = getFunctionText(sourceLine); + + const copyFunctionItem = { + id: "node-menu-copy-function", + label: L10N.getStr("copyFunction.label"), + accesskey: L10N.getStr("copyFunction.accesskey"), + disabled: !functionText, + click: () => { + flashLineRange({ + start: sourceLine, + end: func.location.end.line, + sourceId: selectedSource.id, + }); + return copyToTheClipboard(functionText); + }, + }; + const menuOptions = [copyFunctionItem]; + showMenu(event, menuOptions); + } + + updateFilter = filter => { + this.setState({ filter: filter.trim() }); + }; + + renderPlaceholder() { + const placeholderMessage = this.props.selectedSource + ? L10N.getStr("outline.noFunctions") + : L10N.getStr("outline.noFileSelected"); + + return <div className="outline-pane-info">{placeholderMessage}</div>; + } + + renderLoading() { + return ( + <div className="outline-pane-info">{L10N.getStr("loadingText")}</div> + ); + } + + renderFunction(func) { + const { focusedItem } = this.state; + const { name, location, parameterNames } = func; + const isFocused = focusedItem === func; + + return ( + <li + key={`${name}:${location.start.line}:${location.start.column}`} + className={classnames("outline-list__element", { focused: isFocused })} + ref={el => { + if (isFocused) { + this.focusedElRef = el; + } + }} + onClick={() => this.selectItem(func)} + onContextMenu={e => this.onContextMenu(e, func)} + > + <span className="outline-list__element-icon">λ</span> + <PreviewFunction func={{ name, parameterNames }} /> + </li> + ); + } + + renderClassHeader(klass) { + return ( + <div> + <span className="keyword">class</span> {klass} + </div> + ); + } + + renderClassFunctions(klass, functions) { + const { symbols } = this.props; + + if (!symbols || klass == null || !functions.length) { + return null; + } + + const { focusedItem } = this.state; + const classFunc = functions.find(func => func.name === klass); + const classFunctions = functions.filter(func => func.klass === klass); + const classInfo = symbols.classes.find(c => c.name === klass); + + const item = classFunc || classInfo; + const isFocused = focusedItem === item; + + return ( + <li + className="outline-list__class" + ref={el => { + if (isFocused) { + this.focusedElRef = el; + } + }} + key={klass} + > + <h2 + className={classnames("", { focused: isFocused })} + onClick={() => this.selectItem(item)} + > + {classFunc + ? this.renderFunction(classFunc) + : this.renderClassHeader(klass)} + </h2> + <ul className="outline-list__class-list"> + {classFunctions.map(func => this.renderFunction(func))} + </ul> + </li> + ); + } + + renderFunctions(functions) { + const { filter } = this.state; + let classes = [...new Set(functions.map(({ klass }) => klass))]; + const namedFunctions = functions.filter( + ({ name, klass }) => + filterOutlineItem(name, filter) && !klass && !classes.includes(name) + ); + + const classFunctions = functions.filter( + ({ name, klass }) => filterOutlineItem(name, filter) && !!klass + ); + + if (this.props.alphabetizeOutline) { + const sortByName = (a, b) => (a.name < b.name ? -1 : 1); + namedFunctions.sort(sortByName); + classes = classes.sort(); + classFunctions.sort(sortByName); + } + + return ( + <ul + ref="outlineList" + className="outline-list devtools-monospace" + dir="ltr" + > + {namedFunctions.map(func => this.renderFunction(func))} + {classes.map(klass => this.renderClassFunctions(klass, classFunctions))} + </ul> + ); + } + + renderFooter() { + return ( + <div className="outline-footer"> + <button + onClick={this.props.onAlphabetizeClick} + className={this.props.alphabetizeOutline ? "active" : ""} + > + {L10N.getStr("outline.sortLabel")} + </button> + </div> + ); + } + + render() { + const { symbols, selectedSource } = this.props; + const { filter } = this.state; + + if (!selectedSource) { + return this.renderPlaceholder(); + } + + if (!symbols) { + return this.renderLoading(); + } + + const symbolsToDisplay = symbols.functions.filter( + ({ name }) => name != "anonymous" + ); + + if (symbolsToDisplay.length === 0) { + return this.renderPlaceholder(); + } + + return ( + <div className="outline"> + <div> + <OutlineFilter filter={filter} updateFilter={this.updateFilter} /> + {this.renderFunctions(symbolsToDisplay)} + {this.renderFooter()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const symbols = getSymbols(state, getSelectedLocation(state)); + + return { + cx: getContext(state), + symbols, + selectedSource, + cursorPosition: getCursorPosition(state), + getFunctionText: line => { + if (selectedSource) { + const selectedSourceTextContent = getSelectedSourceTextContent(state); + return findFunctionText( + line, + selectedSource, + selectedSourceTextContent, + symbols + ); + } + + return null; + }, + }; +}; + +export default connect(mapStateToProps, { + selectLocation: actions.selectLocation, + flashLineRange: actions.flashLineRange, +})(Outline); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css new file mode 100644 index 0000000000..354093fc31 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.outline-filter { + border: 1px solid var(--theme-splitter-color); + border-top: 0px; +} + +.outline-filter-input { + height: 24px; + width: 100%; + background-color: var(--theme-sidebar-background); + color: var(--theme-body-color); + font-size: inherit; + user-select: text; +} + +.outline-filter-input.focused { + border: 1px solid var(--theme-highlight-blue); +} + +.outline-filter-input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.theme-dark .outline-filter-input.focused { + border: 1px solid var(--blue-50); +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js new file mode 100644 index 0000000000..1d3daed0d9 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +const classnames = require("devtools/client/shared/classnames.js"); + +import "./OutlineFilter.css"; + +export default class OutlineFilter extends Component { + state = { focused: false }; + + static get propTypes() { + return { + filter: PropTypes.string.isRequired, + updateFilter: PropTypes.func.isRequired, + }; + } + + setFocus = shouldFocus => { + this.setState({ focused: shouldFocus }); + }; + + onChange = e => { + this.props.updateFilter(e.target.value); + }; + + onKeyDown = e => { + if (e.key === "Escape" && this.props.filter !== "") { + // use preventDefault to override toggling the split-console which is + // also bound to the ESC key + e.preventDefault(); + this.props.updateFilter(""); + } else if (e.key === "Enter") { + // We must prevent the form submission from taking any action + // https://github.com/firefox-devtools/debugger/pull/7308 + e.preventDefault(); + } + }; + + render() { + const { focused } = this.state; + return ( + <div className="outline-filter"> + <form> + <input + className={classnames("outline-filter-input devtools-filterinput", { + focused, + })} + onFocus={() => this.setFocus(true)} + onBlur={() => this.setFocus(false)} + placeholder={L10N.getStr("outline.placeholder")} + value={this.props.filter} + type="text" + onChange={this.onChange} + onKeyDown={this.onKeyDown} + /> + </form> + </div> + ); + } +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css new file mode 100644 index 0000000000..f6d5e132ea --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.search-container { + position: absolute; + top: var(--editor-header-height); + left: 0; + width: calc(100% - 1px); + height: calc(100% - var(--editor-header-height)); + display: flex; + flex-direction: column; + z-index: 20; + overflow-y: hidden; + + /* Using the same colors as the Netmonitor's --table-selection-background-hover */ + --search-result-background-hover: rgba(209, 232, 255, 0.8); +} + +.theme-dark .search-container { + --search-result-background-hover: rgba(53, 59, 72, 1); +} + +.project-text-search { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow-y: hidden; + height: 100%; +} + +.project-text-search .result { + display: contents; + cursor: default; + line-height: 16px; + font-size: 11px; + font-family: var(--monospace-font-family); +} + +.project-text-search .result:hover > * { + background-color: var(--search-result-background-hover); +} + +.project-text-search .result .line-number { + grid-column: 1; + padding-block: 1px; + padding-inline-start: 4px; + padding-inline-end: 6px; + text-align: end; + color: var(--theme-text-color-alt); +} + +.project-text-search .result .line-value { + grid-column: 2; + padding-block: 1px; + padding-inline-end: 4px; + text-overflow: ellipsis; + overflow-x: hidden; +} + +.project-text-search .result .query-match { + border-bottom: 1px solid var(--theme-contrast-border); + color: var(--theme-contrast-color); + background-color: var(--theme-contrast-background); +} + +.project-text-search .result.focused .query-match { + border-bottom: none; + color: var(--theme-selection-background); + background-color: var(--theme-selection-color); +} + +.project-text-search .tree-indent { + display: none; +} + +.project-text-search .no-result-msg { + color: var(--theme-text-color-inactive); + font-size: 24px; + padding: 4px 15px; + max-width: 100%; + overflow-wrap: break-word; + hyphens: auto; +} + +.project-text-search .file-result { + grid-column: 1/3; + display: flex; + align-items: center; + width: 100%; + min-height: 24px; + padding: 2px 4px; + font-weight: bold; + font-size: 12px; + line-height: 16px; + cursor: default; +} + +.project-text-search .file-result .img { + margin-inline: 2px; +} + +.project-text-search .file-result .img.file { + margin-inline-end: 4px; +} + +.project-text-search .file-path { + flex: 0 1 auto; + padding-inline-end: 4px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-text-search .file-path:empty { + display: none; +} + +.project-text-search .search-field { + display: flex; + align-self: stretch; + flex-grow: 1; + width: 100%; + border-bottom: none; +} + +.project-text-search .tree { + overflow-x: hidden; + overflow-y: auto; + height: 100%; + display: grid; + min-width: 100%; + white-space: nowrap; + user-select: none; + align-content: start; + /* Align the second column to the search input's text value */ + grid-template-columns: minmax(40px, auto) 1fr; + padding-top: 4px; +} + +/* Fake padding-bottom using a pseudo-element because Gecko doesn't render the + padding-bottom in a scroll container */ +.project-text-search .tree::after { + content: ""; + display: block; + height: 4px; +} + +.project-text-search .tree .tree-node { + display: contents; +} + +/* Focus values */ + +.project-text-search .file-result.focused, +.project-text-search .result.focused .line-value, +.project-text-search .result.focused .line-number { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +.project-text-search .file-result.focused .img { + background-color: currentColor; +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js new file mode 100644 index 0000000000..922e266c40 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js @@ -0,0 +1,327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; + +import { getEditor } from "../../utils/editor"; +import { searchKeys } from "../../constants"; + +import { statusType } from "../../reducers/project-text-search"; +import { getRelativePath } from "../../utils/sources-tree/utils"; +import { getFormattedSourceId } from "../../utils/source"; +import { + getProjectSearchResults, + getProjectSearchStatus, + getProjectSearchQuery, + getContext, +} from "../../selectors"; + +import SearchInput from "../shared/SearchInput"; +import AccessibleImage from "../shared/AccessibleImage"; + +const { PluralForm } = require("devtools/shared/plural-form"); +const classnames = require("devtools/client/shared/classnames.js"); +const Tree = require("devtools/client/shared/components/Tree"); + +import "./ProjectSearch.css"; + +function getFilePath(item, index) { + return item.type === "RESULT" + ? `${item.location.source.id}-${index || "$"}` + : `${item.location.source.id}-${item.location.line}-${ + item.location.column + }-${index || "$"}`; +} + +export class ProjectSearch extends Component { + constructor(props) { + super(props); + this.state = { + inputValue: this.props.query || "", + inputFocused: false, + focusedItem: null, + expanded: new Set(), + }; + } + + static get propTypes() { + return { + clearSearch: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + doSearchForHighlight: PropTypes.func.isRequired, + query: PropTypes.string.isRequired, + results: PropTypes.array.isRequired, + searchSources: PropTypes.func.isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + status: PropTypes.oneOf([ + "INITIAL", + "FETCHING", + "CANCELED", + "DONE", + "ERROR", + ]).isRequired, + modifiers: PropTypes.object, + toggleProjectSearchModifier: PropTypes.func, + }; + } + + componentDidMount() { + const { shortcuts } = this.context; + shortcuts.on("Enter", this.onEnterPress); + } + + componentWillUnmount() { + const { shortcuts } = this.context; + shortcuts.off("Enter", this.onEnterPress); + } + + componentDidUpdate(prevProps) { + // If the query changes in redux, also change it in the UI + if (prevProps.query !== this.props.query) { + this.setState({ inputValue: this.props.query }); + } + } + + doSearch(searchTerm) { + if (searchTerm) { + this.props.searchSources(this.props.cx, searchTerm); + } + } + + selectMatchItem = matchItem => { + this.props.selectSpecificLocation(this.props.cx, matchItem.location); + this.props.doSearchForHighlight( + this.state.inputValue, + getEditor(), + matchItem.location.line, + matchItem.location.column + ); + }; + + highlightMatches = lineMatch => { + const { value, matchIndex, match } = lineMatch; + const len = match.length; + + return ( + <span className="line-value"> + <span className="line-match" key={0}> + {value.slice(0, matchIndex)} + </span> + <span className="query-match" key={1}> + {value.substr(matchIndex, len)} + </span> + <span className="line-match" key={2}> + {value.slice(matchIndex + len, value.length)} + </span> + </span> + ); + }; + + getResultCount = () => + this.props.results.reduce((count, file) => count + file.matches.length, 0); + + onKeyDown = e => { + if (e.key === "Escape") { + return; + } + + e.stopPropagation(); + + this.setState({ focusedItem: null }); + this.doSearch(this.state.inputValue); + }; + + onHistoryScroll = query => { + this.setState({ inputValue: query }); + }; + + onEnterPress = () => { + // This is to select a match from the search result. + if (!this.state.focusedItem || this.state.inputFocused) { + return; + } + if (this.state.focusedItem.type === "MATCH") { + this.selectMatchItem(this.state.focusedItem); + } + }; + + onFocus = item => { + if (this.state.focusedItem !== item) { + this.setState({ focusedItem: item }); + } + }; + + inputOnChange = e => { + const inputValue = e.target.value; + const { cx, clearSearch } = this.props; + this.setState({ inputValue }); + if (inputValue === "") { + clearSearch(cx); + } + }; + + renderFile = (file, focused, expanded) => { + const matchesLength = file.matches.length; + const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`; + return ( + <div + className={classnames("file-result", { focused })} + key={file.location.source.id} + > + <AccessibleImage className={classnames("arrow", { expanded })} /> + <AccessibleImage className="file" /> + <span className="file-path"> + {file.location.source.url + ? getRelativePath(file.location.source.url) + : getFormattedSourceId(file.location.source.id)} + </span> + <span className="matches-summary">{matches}</span> + </div> + ); + }; + + renderMatch = (match, focused) => { + return ( + <div + className={classnames("result", { focused })} + onClick={() => setTimeout(() => this.selectMatchItem(match), 50)} + > + <span className="line-number" key={match.location.line}> + {match.location.line} + </span> + {this.highlightMatches(match)} + </div> + ); + }; + + renderItem = (item, depth, focused, _, expanded) => { + if (item.type === "RESULT") { + return this.renderFile(item, focused, expanded); + } + return this.renderMatch(item, focused); + }; + + renderResults = () => { + const { status, results } = this.props; + if (!this.props.query) { + return null; + } + if (results.length) { + return ( + <Tree + getRoots={() => results} + getChildren={file => file.matches || []} + itemHeight={24} + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + getParent={item => null} + getPath={getFilePath} + renderItem={this.renderItem} + focused={this.state.focusedItem} + onFocus={this.onFocus} + isExpanded={item => { + return this.state.expanded.has(item); + }} + onExpand={item => { + const { expanded } = this.state; + expanded.add(item); + this.setState({ expanded }); + }} + onCollapse={item => { + const { expanded } = this.state; + expanded.delete(item); + this.setState({ expanded }); + }} + getKey={getFilePath} + /> + ); + } + const msg = + status === statusType.fetching + ? L10N.getStr("loadingText") + : L10N.getStr("projectTextSearch.noResults"); + return <div className="no-result-msg absolute-center">{msg}</div>; + }; + + renderSummary = () => { + if (this.props.query !== "") { + const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2"); + const count = this.getResultCount(); + return PluralForm.get(count, resultsSummaryString).replace("#1", count); + } + return ""; + }; + + shouldShowErrorEmoji() { + return !this.getResultCount() && this.props.status === statusType.done; + } + + renderInput() { + const { status } = this.props; + + return ( + <SearchInput + query={this.state.inputValue} + count={this.getResultCount()} + placeholder={L10N.getStr("projectTextSearch.placeholder")} + size="small" + showErrorEmoji={this.shouldShowErrorEmoji()} + summaryMsg={this.renderSummary()} + isLoading={status === statusType.fetching} + onChange={this.inputOnChange} + onFocus={() => this.setState({ inputFocused: true })} + onBlur={() => this.setState({ inputFocused: false })} + onKeyDown={this.onKeyDown} + onHistoryScroll={this.onHistoryScroll} + showClose={false} + showExcludePatterns={true} + excludePatternsLabel={L10N.getStr( + "projectTextSearch.excludePatterns.label" + )} + excludePatternsPlaceholder={L10N.getStr( + "projectTextSearch.excludePatterns.placeholder" + )} + ref="searchInput" + showSearchModifiers={true} + searchKey={searchKeys.PROJECT_SEARCH} + onToggleSearchModifier={() => this.doSearch(this.state.inputValue)} + /> + ); + } + + render() { + return ( + <div className="search-container"> + <div className="project-text-search"> + <div className="header">{this.renderInput()}</div> + {this.renderResults()} + </div> + </div> + ); + } +} + +ProjectSearch.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => ({ + cx: getContext(state), + results: getProjectSearchResults(state), + query: getProjectSearchQuery(state), + status: getProjectSearchStatus(state), +}); + +export default connect(mapStateToProps, { + searchSources: actions.searchSources, + clearSearch: actions.clearSearch, + selectSpecificLocation: actions.selectSpecificLocation, + setActiveSearch: actions.setActiveSearch, + doSearchForHighlight: actions.doSearchForHighlight, +})(ProjectSearch); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Sources.css b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css new file mode 100644 index 0000000000..e0e251cb47 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.sources-panel { + background-color: var(--theme-sidebar-background); + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.sources-panel * { + user-select: none; +} + +/***********************/ +/* Souces Panel layout */ +/***********************/ + +.sources-list { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.sources-list .sources-clear-root-container { + grid-area: custom-root; +} + +.sources-list :is(.tree, .no-sources-message) { + grid-area: sources-tree-or-empty-message; +} + +/****************/ +/* Custom root */ +/****************/ + +.sources-clear-root { + padding: 4px 8px; + width: 100%; + text-align: start; + white-space: nowrap; + color: inherit; + display: flex; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.sources-clear-root .home { + background-color: var(--theme-icon-dimmed-color); +} + +.sources-clear-root .breadcrumb { + width: 5px; + margin: 0 2px 0 6px; + vertical-align: bottom; + background: var(--theme-text-color-alt); +} + +.sources-clear-root-label { + margin-left: 5px; + line-height: 16px; +} + +/*****************/ +/* Sources tree */ +/*****************/ + +.sources-list .tree { + flex-grow: 1; + padding: 4px 0; + user-select: none; + + white-space: nowrap; + overflow: auto; + min-width: 100%; + + display: grid; + grid-template-columns: 1fr; + align-content: start; + + line-height: 1.4em; +} + +.sources-list .tree .node { + display: flex; + align-items: center; + width: 100%; + padding-block: 8px; + padding-inline: 6px 8px; +} + +.sources-list .tree .tree-node:not(.focused):hover { + background: var(--theme-toolbar-background-hover); +} + +.sources-list .tree button { + display: block; +} + +.sources-list .tree .node { + padding: 2px 3px; + position: relative; +} + +.sources-list .tree .node.focused { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +html:not([dir="rtl"]) .sources-list .tree .node > div { + margin-left: 10px; +} + +html[dir="rtl"] .sources-list .tree .node > div { + margin-right: 10px; +} + +.sources-list .tree-node button { + position: fixed; +} + +.sources-list .img { + margin-inline-end: 4px; +} + +.sources-list .tree .focused .img { + --icon-color: #ffffff; + background-color: var(--icon-color); + fill: var(--icon-color); +} + +/* Use the same width as .img.arrow */ +.sources-list .tree .img.no-arrow { + width: 10px; + visibility: hidden; +} + +.sources-list .tree .label .suffix { + font-style: italic; + font-size: 0.9em; + color: var(--theme-comment); +} + +.sources-list .tree .focused .label .suffix { + color: inherit; +} + +.theme-dark .source-list .node.focused { + background-color: var(--theme-tab-toolbar-background); +} + +.sources-list .tree .blackboxed { + color: #806414; +} + +.sources-list .img.blackBox { + mask-size: 13px; + background-color: #806414; +} + +.sources-list .tree .label { + display: inline-block; + line-height: 16px; +} + +.source-list-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + justify-content: center; + text-align: center; + min-height: var(--editor-footer-height); + border-block-start: 1px solid var(--theme-warning-border); + user-select: none; + padding: 3px 10px; + color: var(--theme-warning-color); + background-color: var(--theme-warning-background); +} + +.source-list-footer .devtools-togglebutton { + background-color: var(--theme-toolbar-hover); +} + +.source-list-footer .devtools-togglebutton:hover { + background-color: var(--theme-toolbar-hover); + cursor: pointer; +} + + +/* Removes start margin when a custom root is used */ +.sources-list-custom-root + .tree + > .tree-node[data-expandable="false"][aria-level="0"] { + padding-inline-start: 4px; +} + +.sources-list .tree-node[data-expandable="false"] .tree-indent:last-of-type { + margin-inline-end: 0; +} + + +/*****************/ +/* No Sources */ +/*****************/ + +.no-sources-message { + display: flex; + justify-content: center; + align-items: center; + font-style: italic; + text-align: center; + padding: 0.5em; + font-size: 12px; + user-select: none; +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js new file mode 100644 index 0000000000..c570bdd5a0 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js @@ -0,0 +1,510 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// Dependencies +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +// Selectors +import { + getSelectedLocation, + getMainThreadHost, + getExpandedState, + getProjectDirectoryRoot, + getProjectDirectoryRootName, + getSourcesTreeSources, + getFocusedSourceItem, + getContext, + getGeneratedSourceByURL, + getBlackBoxRanges, + getHideIgnoredSources, +} from "../../selectors"; + +// Actions +import actions from "../../actions"; + +// Components +import SourcesTreeItem from "./SourcesTreeItem"; +import AccessibleImage from "../shared/AccessibleImage"; + +// Utils +import { getRawSourceURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; + +const classnames = require("devtools/client/shared/classnames.js"); +const Tree = require("devtools/client/shared/components/Tree"); + +function shouldAutoExpand(item, mainThreadHost) { + // There is only one case where we want to force auto expand, + // when we are on the group of the page's domain. + return item.type == "group" && item.groupName === mainThreadHost; +} + +/** + * Get the SourceItem displayed in the SourceTree for a given "tree location". + * + * @param {Object} treeLocation + * An object containing the Source coming from the sources.js reducer and the source actor + * See getTreeLocation(). + * @param {object} rootItems + * Result of getSourcesTreeSources selector, containing all sources sorted in a tree structure. + * items to be displayed in the source tree. + * @return {SourceItem} + * The directory source item where the given source is displayed. + */ +function getSourceItemForTreeLocation(treeLocation, rootItems) { + // Sources without URLs are not visible in the SourceTree + const { source, sourceActor } = treeLocation; + + if (!source.url) { + return null; + } + const { displayURL } = source; + function findSourceInItem(item, path) { + if (item.type == "source") { + if (item.source.url == source.url) { + return item; + } + return null; + } + // Bail out if we the current item doesn't match the source + if (item.type == "thread" && item.threadActorID != sourceActor?.thread) { + return null; + } + if (item.type == "group" && displayURL.group != item.groupName) { + return null; + } + if (item.type == "directory" && !path.startsWith(item.path)) { + return null; + } + // Otherwise, walk down the tree if this ancestor item seems to match + for (const child of item.children) { + const match = findSourceInItem(child, path); + if (match) { + return match; + } + } + + return null; + } + for (const rootItem of rootItems) { + // Note that when we are setting a project root, rootItem + // may no longer be only Thread Item, but also be Group, Directory or Source Items. + const item = findSourceInItem(rootItem, displayURL.path); + if (item) { + return item; + } + } + return null; +} + +class SourcesTree extends Component { + constructor(props) { + super(props); + + this.state = {}; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + mainThreadHost: PropTypes.string.isRequired, + expanded: PropTypes.object.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.object, + projectRoot: PropTypes.string.isRequired, + selectSource: PropTypes.func.isRequired, + selectedTreeLocation: PropTypes.object, + setExpandedState: PropTypes.func.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + rootItems: PropTypes.object.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + projectRootName: PropTypes.string.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + hideIgnoredSources: PropTypes.bool.isRequired, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { selectedTreeLocation } = this.props; + + // We might fail to find the source if its thread is registered late, + // so that we should re-search the selected source if state.focused is null. + if ( + nextProps.selectedTreeLocation?.source && + (nextProps.selectedTreeLocation.source != selectedTreeLocation?.source || + (nextProps.selectedTreeLocation.source === + selectedTreeLocation?.source && + nextProps.selectedTreeLocation.sourceActor != + selectedTreeLocation?.sourceActor) || + !this.props.focused) + ) { + const sourceItem = getSourceItemForTreeLocation( + nextProps.selectedTreeLocation, + this.props.rootItems + ); + if (sourceItem) { + // Walk up the tree to expand all ancestor items up to the root of the tree. + const expanded = new Set(this.props.expanded); + let parentDirectory = sourceItem; + while (parentDirectory) { + expanded.add(this.getKey(parentDirectory)); + parentDirectory = this.getParent(parentDirectory); + } + this.props.setExpandedState(expanded); + this.onFocus(sourceItem); + } + } + } + + selectSourceItem = item => { + this.props.selectSource(this.props.cx, item.source, item.sourceActor); + }; + + onFocus = item => { + this.props.focusItem(item); + }; + + onActivate = item => { + if (item.type == "source") { + this.selectSourceItem(item); + } + }; + + onExpand = (item, shouldIncludeChildren) => { + this.setExpanded(item, true, shouldIncludeChildren); + }; + + onCollapse = (item, shouldIncludeChildren) => { + this.setExpanded(item, false, shouldIncludeChildren); + }; + + setExpanded = (item, isExpanded, shouldIncludeChildren) => { + const { expanded } = this.props; + let changed = false; + const expandItem = i => { + const key = this.getKey(i); + if (isExpanded) { + changed |= !expanded.has(key); + expanded.add(key); + } else { + changed |= expanded.has(key); + expanded.delete(key); + } + }; + expandItem(item); + + if (shouldIncludeChildren) { + let parents = [item]; + while (parents.length) { + const children = []; + for (const parent of parents) { + for (const child of this.getChildren(parent)) { + expandItem(child); + children.push(child); + } + } + parents = children; + } + } + if (changed) { + this.props.setExpandedState(expanded); + } + }; + + isEmpty() { + return !this.getRoots().length; + } + + renderEmptyElement(message) { + return ( + <div key="empty" className="no-sources-message"> + {message} + </div> + ); + } + + getRoots = () => { + return this.props.rootItems; + }; + + getKey = item => { + // As this is used as React key in Tree component, + // we need to update the key when switching to a new project root + // otherwise these items won't be updated and will have a buggy padding start. + const { projectRoot } = this.props; + if (projectRoot) { + return projectRoot + item.uniquePath; + } + return item.uniquePath; + }; + + getChildren = item => { + // This is the precial magic that coalesce "empty" folders, + // i.e folders which have only one sub-folder as children. + function skipEmptyDirectories(directory) { + if (directory.type != "directory") { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.children[0]); + } + return directory; + } + if (item.type == "thread") { + return item.children; + } else if (item.type == "group" || item.type == "directory") { + return item.children.map(skipEmptyDirectories); + } + return []; + }; + + getParent = item => { + if (item.type == "thread") { + return null; + } + const { rootItems } = this.props; + // This is the second magic which skip empty folders + // (See getChildren comment) + function skipEmptyDirectories(directory) { + if ( + directory.type == "group" || + directory.type == "thread" || + rootItems.includes(directory) + ) { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.parent); + } + return directory; + } + return skipEmptyDirectories(item.parent); + }; + + /** + * Computes 4 lists: + * - `sourcesInside`: the list of all Source Items that are + * children of the current item (can be thread/group/directory). + * This include any nested level of children. + * - `sourcesOutside`: all other Source Items. + * i.e. all sources that are in any other folder of any group/thread. + * - `allInsideBlackBoxed`, all sources of `sourcesInside` which are currently + * blackboxed. + * - `allOutsideBlackBoxed`, all sources of `sourcesOutside` which are currently + * blackboxed. + */ + getBlackBoxSourcesGroups = item => { + const allSources = []; + function collectAllSources(list, _item) { + if (_item.children) { + _item.children.forEach(i => collectAllSources(list, i)); + } + if (_item.type == "source") { + list.push(_item.source); + } + } + for (const rootItem of this.props.rootItems) { + collectAllSources(allSources, rootItem); + } + + const sourcesInside = []; + collectAllSources(sourcesInside, item); + + const sourcesOutside = allSources.filter( + source => !sourcesInside.includes(source) + ); + const allInsideBlackBoxed = sourcesInside.every( + source => this.props.blackBoxRanges[source.url] + ); + const allOutsideBlackBoxed = sourcesOutside.every( + source => this.props.blackBoxRanges[source.url] + ); + + return { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + }; + }; + + renderProjectRootHeader() { + const { cx, projectRootName } = this.props; + + if (!projectRootName) { + return null; + } + + return ( + <div key="root" className="sources-clear-root-container"> + <button + className="sources-clear-root" + onClick={() => this.props.clearProjectDirectoryRoot(cx)} + title={L10N.getStr("removeDirectoryRoot.label")} + > + <AccessibleImage className="home" /> + <AccessibleImage className="breadcrumb" /> + <span className="sources-clear-root-label">{projectRootName}</span> + </button> + </div> + ); + } + + renderItem = (item, depth, focused, _, expanded) => { + const { mainThreadHost, projectRoot } = this.props; + return ( + <SourcesTreeItem + item={item} + depth={depth} + focused={focused} + autoExpand={shouldAutoExpand(item, mainThreadHost)} + expanded={expanded} + focusItem={this.onFocus} + selectSourceItem={this.selectSourceItem} + projectRoot={projectRoot} + setExpanded={this.setExpanded} + getBlackBoxSourcesGroups={this.getBlackBoxSourcesGroups} + getParent={this.getParent} + /> + ); + }; + + renderTree() { + const { expanded, focused } = this.props; + + const treeProps = { + autoExpandAll: false, + autoExpandDepth: 1, + expanded, + focused, + getChildren: this.getChildren, + getParent: this.getParent, + getKey: this.getKey, + getRoots: this.getRoots, + itemHeight: 21, + key: this.isEmpty() ? "empty" : "full", + onCollapse: this.onCollapse, + onExpand: this.onExpand, + onFocus: this.onFocus, + isExpanded: item => { + return this.props.expanded.has(this.getKey(item)); + }, + onActivate: this.onActivate, + renderItem: this.renderItem, + preventBlur: true, + }; + + return <Tree {...treeProps} />; + } + + renderPane(child) { + const { projectRoot } = this.props; + + return ( + <div + key="pane" + className={classnames("sources-pane", { + "sources-list-custom-root": !!projectRoot, + })} + > + {child} + </div> + ); + } + + renderFooter() { + if (this.props.hideIgnoredSources) { + return ( + <footer className="source-list-footer"> + {L10N.getStr("ignoredSourcesHidden")} + <button + className="devtools-togglebutton" + onClick={() => this.props.setHideOrShowIgnoredSources(false)} + title={L10N.getStr("showIgnoredSources.tooltip.label")} + > + {L10N.getStr("showIgnoredSources")} + </button> + </footer> + ); + } + return null; + } + + render() { + const { projectRoot } = this.props; + return ( + <div + key="pane" + className={classnames("sources-list", { + "sources-list-custom-root": !!projectRoot, + })} + > + {this.isEmpty() ? ( + this.renderEmptyElement(L10N.getStr("noSourcesText")) + ) : ( + <> + {this.renderProjectRootHeader()} + {this.renderTree()} + {this.renderFooter()} + </> + )} + </div> + ); + } +} + +function getTreeLocation(state, location) { + // In the SourceTree, we never show the pretty printed sources and only + // the minified version, so if we are selecting a pretty file, fake selecting + // the minified version. + if (location?.source.isPrettyPrinted) { + const source = getGeneratedSourceByURL( + state, + getRawSourceURL(location.source.url) + ); + if (source) { + return createLocation({ + source, + // A source actor is required by getSourceItemForTreeLocation + // in order to know in which thread this source relates to. + sourceActor: location.sourceActor, + }); + } + } + return location; +} + +const mapStateToProps = state => { + const rootItems = getSourcesTreeSources(state); + + return { + cx: getContext(state), + selectedTreeLocation: getTreeLocation(state, getSelectedLocation(state)), + mainThreadHost: getMainThreadHost(state), + expanded: getExpandedState(state), + focused: getFocusedSourceItem(state), + projectRoot: getProjectDirectoryRoot(state), + rootItems, + blackBoxRanges: getBlackBoxRanges(state), + projectRootName: getProjectDirectoryRootName(state), + hideIgnoredSources: getHideIgnoredSources(state), + }; +}; + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + setExpandedState: actions.setExpandedState, + focusItem: actions.focusItem, + clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot, + setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources, +})(SourcesTree); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js new file mode 100644 index 0000000000..874df4c77c --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js @@ -0,0 +1,457 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { showMenu } from "../../context-menu/menu"; + +import SourceIcon from "../shared/SourceIcon"; +import AccessibleImage from "../shared/AccessibleImage"; + +import { + getGeneratedSourceByURL, + getContext, + getFirstSourceActorForGeneratedSource, + isSourceOverridden, + getHideIgnoredSources, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; +import actions from "../../actions"; + +import { shouldBlackbox, sourceTypes } from "../../utils/source"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { saveAsLocalFile } from "../../utils/utils"; +import { createLocation } from "../../utils/location"; +import { safeDecodeItemName } from "../../utils/sources-tree/utils"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class SourceTreeItem extends Component { + static get propTypes() { + return { + autoExpand: PropTypes.bool.isRequired, + blackBoxSources: PropTypes.func.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + depth: PropTypes.number.isRequired, + expanded: PropTypes.bool.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.bool.isRequired, + getBlackBoxSourcesGroups: PropTypes.func.isRequired, + hasMatchingGeneratedSource: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + loadSourceText: PropTypes.func.isRequired, + getFirstSourceActorForGeneratedSource: PropTypes.func.isRequired, + projectRoot: PropTypes.string.isRequired, + selectSourceItem: PropTypes.func.isRequired, + setExpanded: PropTypes.func.isRequired, + setProjectDirectoryRoot: PropTypes.func.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + getParent: PropTypes.func.isRequired, + setOverrideSource: PropTypes.func.isRequired, + removeOverrideSource: PropTypes.func.isRequired, + isOverridden: PropTypes.bool, + hideIgnoredSources: PropTypes.bool, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + componentDidMount() { + const { autoExpand, item } = this.props; + if (autoExpand) { + this.props.setExpanded(item, true, false); + } + } + + onClick = e => { + const { item, focusItem, selectSourceItem } = this.props; + + focusItem(item); + if (item.type == "source") { + selectSourceItem(item); + } + }; + + onContextMenu = event => { + const copySourceUri2Label = L10N.getStr("copySourceUri2"); + const copySourceUri2Key = L10N.getStr("copySourceUri2.accesskey"); + const setDirectoryRootLabel = L10N.getStr("setDirectoryRoot.label"); + const setDirectoryRootKey = L10N.getStr("setDirectoryRoot.accesskey"); + const removeDirectoryRootLabel = L10N.getStr("removeDirectoryRoot.label"); + + event.stopPropagation(); + event.preventDefault(); + + const menuOptions = []; + + const { item, isOverridden, cx, isSourceOnIgnoreList } = this.props; + if (item.type == "source") { + const { source } = item; + const copySourceUri2 = { + id: "node-menu-copy-source", + label: copySourceUri2Label, + accesskey: copySourceUri2Key, + disabled: false, + click: () => copyToTheClipboard(source.url), + }; + + const ignoreStr = item.isBlackBoxed ? "unignore" : "ignore"; + const blackBoxMenuItem = { + id: "node-menu-blackbox", + label: L10N.getStr(`ignoreContextItem.${ignoreStr}`), + accesskey: L10N.getStr(`ignoreContextItem.${ignoreStr}.accesskey`), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => this.props.toggleBlackBox(cx, source), + }; + const downloadFileItem = { + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + disabled: false, + click: () => this.saveLocalFile(cx, source), + }; + + const overrideStr = !isOverridden ? "override" : "removeOverride"; + const overridesItem = { + id: "node-menu-overrides", + label: L10N.getStr(`overridesContextItem.${overrideStr}`), + accesskey: L10N.getStr(`overridesContextItem.${overrideStr}.accesskey`), + disabled: !!source.isHTML, + click: () => this.handleLocalOverride(cx, source, isOverridden), + }; + + menuOptions.push( + copySourceUri2, + blackBoxMenuItem, + downloadFileItem, + overridesItem + ); + } + + // All other types other than source are folder-like + if (item.type != "source") { + this.addCollapseExpandAllOptions(menuOptions, item); + + const { depth, projectRoot } = this.props; + + if (projectRoot == item.uniquePath) { + menuOptions.push({ + id: "node-remove-directory-root", + label: removeDirectoryRootLabel, + disabled: false, + click: () => this.props.clearProjectDirectoryRoot(cx), + }); + } else { + menuOptions.push({ + id: "node-set-directory-root", + label: setDirectoryRootLabel, + accesskey: setDirectoryRootKey, + disabled: false, + click: () => + this.props.setProjectDirectoryRoot( + cx, + item.uniquePath, + this.renderItemName(depth) + ), + }); + } + + this.addBlackboxAllOption(menuOptions, item); + } + + showMenu(event, menuOptions); + }; + + saveLocalFile = async (cx, source) => { + if (!source) { + return null; + } + + const data = await this.props.loadSourceText(cx, source); + if (!data) { + return null; + } + return saveAsLocalFile(data.value, source.displayURL.filename); + }; + + handleLocalOverride = async (cx, source, isOverridden) => { + if (!isOverridden) { + const localPath = await this.saveLocalFile(cx, source); + if (localPath) { + this.props.setOverrideSource(cx, source, localPath); + } + } else { + this.props.removeOverrideSource(cx, source); + } + }; + + addBlackboxAllOption = (menuOptions, item) => { + const { cx, depth, projectRoot } = this.props; + const { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + } = this.props.getBlackBoxSourcesGroups(item); + + let blackBoxInsideMenuItemLabel; + let blackBoxOutsideMenuItemLabel; + if (depth === 0 || (depth === 1 && projectRoot === "")) { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInGroup.label") + : L10N.getStr("ignoreAllInGroup.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideGroup.label") + : L10N.getStr("ignoreAllOutsideGroup.label"); + } + } else { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInDir.label") + : L10N.getStr("ignoreAllInDir.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideDir.label") + : L10N.getStr("ignoreAllOutsideDir.label"); + } + } + + const blackBoxInsideMenuItem = { + id: allInsideBlackBoxed + ? "node-unblackbox-all-inside" + : "node-blackbox-all-inside", + label: blackBoxInsideMenuItemLabel, + disabled: false, + click: () => + this.props.blackBoxSources(cx, sourcesInside, !allInsideBlackBoxed), + }; + + if (sourcesOutside.length) { + menuOptions.push({ + id: "node-blackbox-all", + label: L10N.getStr("ignoreAll.label"), + submenu: [ + blackBoxInsideMenuItem, + { + id: allOutsideBlackBoxed + ? "node-unblackbox-all-outside" + : "node-blackbox-all-outside", + label: blackBoxOutsideMenuItemLabel, + disabled: false, + click: () => + this.props.blackBoxSources( + cx, + sourcesOutside, + !allOutsideBlackBoxed + ), + }, + ], + }); + } else { + menuOptions.push(blackBoxInsideMenuItem); + } + }; + + addCollapseExpandAllOptions = (menuOptions, item) => { + const { setExpanded } = this.props; + + menuOptions.push({ + id: "node-menu-collapse-all", + label: L10N.getStr("collapseAll.label"), + disabled: false, + click: () => setExpanded(item, false, true), + }); + + menuOptions.push({ + id: "node-menu-expand-all", + label: L10N.getStr("expandAll.label"), + disabled: false, + click: () => setExpanded(item, true, true), + }); + }; + + renderItemArrow() { + const { item, expanded } = this.props; + return item.type != "source" ? ( + <AccessibleImage className={classnames("arrow", { expanded })} /> + ) : ( + <span className="img no-arrow" /> + ); + } + + renderIcon(item, depth) { + if (item.type == "thread") { + const icon = item.thread.targetType.includes("worker") + ? "worker" + : "window"; + return <AccessibleImage className={classnames(icon)} />; + } + if (item.type == "group") { + if (item.groupName === "Webpack") { + return <AccessibleImage className="webpack" />; + } else if (item.groupName === "Angular") { + return <AccessibleImage className="angular" />; + } + // Check if the group relates to an extension. + // This happens when a webextension injects a content script. + if (item.isForExtensionSource) { + return <AccessibleImage className="extension" />; + } + + return <AccessibleImage className="globe-small" />; + } + if (item.type == "directory") { + return <AccessibleImage className="folder" />; + } + if (item.type == "source") { + const { source, sourceActor } = item; + return ( + <SourceIcon + location={createLocation({ source, sourceActor })} + modifier={icon => { + // In the SourceTree, extension files should use the file-extension based icon, + // whereas we use the extension icon in other Components (eg. source tabs and breakpoints pane). + if (icon === "extension") { + return ( + sourceTypes[source.displayURL.fileExtension] || "javascript" + ); + } + return icon + (this.props.isOverridden ? " override" : ""); + }} + /> + ); + } + + return null; + } + + renderItemName(depth) { + const { item } = this.props; + + if (item.type == "thread") { + const { thread } = item; + return ( + thread.name + + (thread.serviceWorkerStatus ? ` (${thread.serviceWorkerStatus})` : "") + ); + } + if (item.type == "group") { + return safeDecodeItemName(item.groupName); + } + if (item.type == "directory") { + const parentItem = this.props.getParent(item); + return safeDecodeItemName( + item.path.replace(parentItem.path, "").replace(/^\//, "") + ); + } + if (item.type == "source") { + const { displayURL } = item.source; + const name = + displayURL.filename + (displayURL.search ? displayURL.search : ""); + return safeDecodeItemName(name); + } + + return null; + } + + renderItemTooltip() { + const { item } = this.props; + + if (item.type == "thread") { + return item.thread.name; + } + if (item.type == "group") { + return item.groupName; + } + if (item.type == "directory") { + return item.path; + } + if (item.type == "source") { + return item.source.url; + } + + return null; + } + + render() { + const { + item, + depth, + focused, + hasMatchingGeneratedSource, + hideIgnoredSources, + } = this.props; + + if (hideIgnoredSources && item.isBlackBoxed) { + return null; + } + const suffix = hasMatchingGeneratedSource ? ( + <span className="suffix">{L10N.getStr("sourceFooter.mappedSuffix")}</span> + ) : null; + + return ( + <div + className={classnames("node", { + focused, + blackboxed: item.type == "source" && item.isBlackBoxed, + })} + key={item.path} + onClick={this.onClick} + onContextMenu={this.onContextMenu} + title={this.renderItemTooltip()} + > + {this.renderItemArrow()} + {this.renderIcon(item, depth)} + <span className="label"> + {this.renderItemName(depth)} + {suffix} + </span> + </div> + ); + } +} + +function getHasMatchingGeneratedSource(state, source) { + if (!source || !source.isOriginal) { + return false; + } + + return !!getGeneratedSourceByURL(state, source.url); +} + +const mapStateToProps = (state, props) => { + const { item } = props; + if (item.type == "source") { + const { source } = item; + return { + cx: getContext(state), + hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source), + getFirstSourceActorForGeneratedSource: (sourceId, threadId) => + getFirstSourceActorForGeneratedSource(state, sourceId, threadId), + isOverridden: isSourceOverridden(state, source), + hideIgnoredSources: getHideIgnoredSources(state), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), + }; + } + return { + cx: getContext(state), + getFirstSourceActorForGeneratedSource: (sourceId, threadId) => + getFirstSourceActorForGeneratedSource(state, sourceId, threadId), + }; +}; + +export default connect(mapStateToProps, { + setProjectDirectoryRoot: actions.setProjectDirectoryRoot, + clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot, + toggleBlackBox: actions.toggleBlackBox, + loadSourceText: actions.loadSourceText, + blackBoxSources: actions.blackBoxSources, + setBlackBoxAllOutside: actions.setBlackBoxAllOutside, + setOverrideSource: actions.setOverrideSource, + removeOverrideSource: actions.removeOverrideSource, +})(SourceTreeItem); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/index.js b/devtools/client/debugger/src/components/PrimaryPanes/index.js new file mode 100644 index 0000000000..c0ab3075bd --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Tab, Tabs, TabList, TabPanels } from "react-aria-components/src/tabs"; + +import actions from "../../actions"; +import { getSelectedPrimaryPaneTab, getContext } from "../../selectors"; +import { prefs } from "../../utils/prefs"; +import { connect } from "../../utils/connect"; +import { primaryPaneTabs } from "../../constants"; +import { formatKeyShortcut } from "../../utils/text"; + +import Outline from "./Outline"; +import SourcesTree from "./SourcesTree"; +import ProjectSearch from "./ProjectSearch"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Sources.css"; + +const tabs = [ + primaryPaneTabs.SOURCES, + primaryPaneTabs.OUTLINE, + primaryPaneTabs.PROJECT_SEARCH, +]; + +class PrimaryPanes extends Component { + constructor(props) { + super(props); + + this.state = { + alphabetizeOutline: prefs.alphabetizeOutline, + }; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + projectRootName: PropTypes.string.isRequired, + selectedTab: PropTypes.oneOf(tabs).isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + closeActiveSearch: PropTypes.func.isRequired, + }; + } + + onAlphabetizeClick = () => { + const alphabetizeOutline = !prefs.alphabetizeOutline; + prefs.alphabetizeOutline = alphabetizeOutline; + this.setState({ alphabetizeOutline }); + }; + + onActivateTab = index => { + const tab = tabs.at(index); + this.props.setPrimaryPaneTab(tab); + if (tab == primaryPaneTabs.PROJECT_SEARCH) { + this.props.setActiveSearch(tab); + } else { + this.props.closeActiveSearch(); + } + }; + + renderTabList() { + return [ + <Tab + className={classnames("tab sources-tab", { + active: this.props.selectedTab === primaryPaneTabs.SOURCES, + })} + key="sources-tab" + > + {formatKeyShortcut(L10N.getStr("sources.header"))} + </Tab>, + <Tab + className={classnames("tab outline-tab", { + active: this.props.selectedTab === primaryPaneTabs.OUTLINE, + })} + key="outline-tab" + > + {formatKeyShortcut(L10N.getStr("outline.header"))} + </Tab>, + <Tab + className={classnames("tab search-tab", { + active: this.props.selectedTab === primaryPaneTabs.PROJECT_SEARCH, + })} + key="search-tab" + > + {formatKeyShortcut(L10N.getStr("search.header"))} + </Tab>, + ]; + } + + render() { + const { selectedTab } = this.props; + return ( + <Tabs + activeIndex={tabs.indexOf(selectedTab)} + className="sources-panel" + onActivateTab={this.onActivateTab} + > + <TabList className="source-outline-tabs"> + {this.renderTabList()} + </TabList> + <TabPanels className="source-outline-panel" hasFocusableContent> + <SourcesTree /> + <Outline + alphabetizeOutline={this.state.alphabetizeOutline} + onAlphabetizeClick={this.onAlphabetizeClick} + /> + <ProjectSearch /> + </TabPanels> + </Tabs> + ); + } +} + +const mapStateToProps = state => { + return { + cx: getContext(state), + selectedTab: getSelectedPrimaryPaneTab(state), + }; +}; + +const connector = connect(mapStateToProps, { + setPrimaryPaneTab: actions.setPrimaryPaneTab, + setActiveSearch: actions.setActiveSearch, + closeActiveSearch: actions.closeActiveSearch, +}); + +export default connector(PrimaryPanes); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/moz.build b/devtools/client/debugger/src/components/PrimaryPanes/moz.build new file mode 100644 index 0000000000..fc73b7bee7 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/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( + "index.js", + "Outline.js", + "OutlineFilter.js", + "ProjectSearch.js", + "SourcesTree.js", + "SourcesTreeItem.js", +) diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js new file mode 100644 index 0000000000..10f9f197fe --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import PropTypes from "prop-types"; + +import { mount, shallow } from "enzyme"; +import { ProjectSearch } from "../ProjectSearch"; +import { statusType } from "../../../reducers/project-text-search"; +import { mockcx } from "../../../utils/test-mockup"; +import { searchKeys } from "../../../constants"; + +const hooks = { on: [], off: [] }; +const shortcuts = { + dispatch(eventName) { + hooks.on.forEach(hook => { + if (hook.event === eventName) { + hook.cb(); + } + }); + hooks.off.forEach(hook => { + if (hook.event === eventName) { + hook.cb(); + } + }); + }, + on: jest.fn((event, cb) => hooks.on.push({ event, cb })), + off: jest.fn((event, cb) => hooks.off.push({ event, cb })), +}; + +const context = { shortcuts }; + +const testResults = [ + { + location: { + source: { + url: "testFilePath1", + }, + }, + type: "RESULT", + matches: [ + { + match: "match1", + value: "some thing match1", + location: { + source: {}, + column: 30, + }, + type: "MATCH", + }, + { + match: "match2", + value: "some thing match2", + location: { + source: {}, + column: 60, + }, + type: "MATCH", + }, + { + match: "match3", + value: "some thing match3", + location: { + source: {}, + column: 90, + }, + type: "MATCH", + }, + ], + }, + { + location: { + source: { + url: "testFilePath2", + }, + }, + type: "RESULT", + matches: [ + { + match: "match4", + value: "some thing match4", + location: { + source: {}, + column: 80, + }, + type: "MATCH", + }, + { + match: "match5", + value: "some thing match5", + location: { + source: {}, + column: 40, + }, + type: "MATCH", + }, + ], + }, +]; + +const testMatch = { + type: "MATCH", + match: "match1", + value: "some thing match1", + sourceId: "some-target/source42", + location: { + source: { + id: "some-target/source42", + }, + line: 3, + column: 30, + }, +}; + +function render(overrides = {}, mounted = false) { + const mockStore = configureStore([]); + const store = mockStore({ + ui: { + mutableSearchOptions: { + [searchKeys.PROJECT_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + }, + }, + }); + const props = { + cx: mockcx, + status: "DONE", + sources: {}, + results: [], + query: "foo", + activeSearch: "project", + closeProjectSearch: jest.fn(), + searchSources: jest.fn(), + clearSearch: jest.fn(), + updateSearchStatus: jest.fn(), + selectSpecificLocation: jest.fn(), + doSearchForHighlight: jest.fn(), + setActiveSearch: jest.fn(), + ...overrides, + }; + + if (mounted) { + return mount( + <Provider store={store}> + <ProjectSearch {...props} /> + </Provider>, + { context, childContextTypes: { shortcuts: PropTypes.object } } + ).childAt(0); + } + + return shallow( + <Provider store={store}> + <ProjectSearch {...props} /> + </Provider>, + { context } + ).dive(); +} + +describe("ProjectSearch", () => { + beforeEach(() => { + context.shortcuts.on.mockClear(); + context.shortcuts.off.mockClear(); + }); + + it("renders nothing when disabled", () => { + const component = render({ activeSearch: "" }); + expect(component).toMatchSnapshot(); + }); + + it("where <Enter> has not been pressed", () => { + const component = render({ query: "" }); + expect(component).toMatchSnapshot(); + }); + + it("found no search results", () => { + const component = render(); + expect(component).toMatchSnapshot(); + }); + + it("should display loading message while search is in progress", () => { + const component = render({ + query: "match", + status: statusType.fetching, + }); + expect(component).toMatchSnapshot(); + }); + + it("found search results", () => { + const component = render( + { + query: "match", + results: testResults, + }, + true + ); + expect(component).toMatchSnapshot(); + }); + + it("turns off shortcuts on unmount", () => { + const component = render({ + query: "", + }); + expect(component).toMatchSnapshot(); + component.unmount(); + expect(context.shortcuts.off).toHaveBeenCalled(); + }); + + it("calls inputOnChange", () => { + const component = render( + { + results: testResults, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("change", { target: { value: "bar" } }); + expect(component.state().inputValue).toEqual("bar"); + }); + + it("onKeyDown Escape/Other", () => { + const searchSources = jest.fn(); + const component = render( + { + results: testResults, + searchSources, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Escape" }); + expect(searchSources).not.toHaveBeenCalled(); + searchSources.mockClear(); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Other", stopPropagation: jest.fn() }); + expect(searchSources).not.toHaveBeenCalled(); + }); + + it("onKeyDown Enter", () => { + const searchSources = jest.fn(); + const component = render( + { + results: testResults, + searchSources, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Enter", stopPropagation: jest.fn() }); + expect(searchSources).toHaveBeenCalledWith(mockcx, "foo"); + }); + + it("onEnterPress shortcut no match or setExpanded", () => { + const selectSpecificLocation = jest.fn(); + const component = render( + { + results: testResults, + selectSpecificLocation, + }, + true + ); + component.instance().state.focusedItem = null; + shortcuts.dispatch("Enter"); + expect(selectSpecificLocation).not.toHaveBeenCalled(); + }); + + it("onEnterPress shortcut match", () => { + const selectSpecificLocation = jest.fn(); + const component = render( + { + results: testResults, + selectSpecificLocation, + }, + true + ); + component.instance().state.focusedItem = { ...testMatch }; + shortcuts.dispatch("Enter"); + expect(selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + source: { + id: "some-target/source42", + }, + line: 3, + column: 30, + }); + }); + + it("state.inputValue responds to prop.query changes", () => { + const component = render({ query: "foo" }); + expect(component.state().inputValue).toEqual("foo"); + component.setProps({ query: "" }); + expect(component.state().inputValue).toEqual(""); + }); + + describe("showErrorEmoji", () => { + it("false if not done & results", () => { + const component = render({ + status: statusType.fetching, + results: testResults, + }); + expect(component).toMatchSnapshot(); + }); + + it("false if not done & no results", () => { + const component = render({ + status: statusType.fetching, + }); + expect(component).toMatchSnapshot(); + }); + + // "false if done & has results" + // is the same test as "found search results" + + // "true if done & has no results" + // is the same test as "found no search results" + }); +}); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap new file mode 100644 index 0000000000..4be18c4753 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap @@ -0,0 +1,1111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProjectSearch found no search results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + No results found + </div> + </div> +</div> +`; + +exports[`ProjectSearch found search results 1`] = ` +<ProjectSearch + activeSearch="project" + clearSearch={[MockFunction]} + closeProjectSearch={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + doSearchForHighlight={[MockFunction]} + query="match" + results={ + Array [ + Object { + "location": Object { + "source": Object { + "url": "testFilePath1", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + }, + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + }, + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + }, + ], + "type": "RESULT", + }, + Object { + "location": Object { + "source": Object { + "url": "testFilePath2", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + }, + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + }, + ], + "type": "RESULT", + }, + ] + } + searchSources={[MockFunction]} + selectSpecificLocation={[MockFunction]} + setActiveSearch={[MockFunction]} + sources={Object {}} + status="DONE" + updateSearchStatus={[MockFunction]} +> + <div + className="search-container" + > + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + > + <SearchInput + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + expanded={false} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field small" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Find in files…" + spellCheck={false} + value="match" + /> + <div + className="search-field-summary" + > + 5 results + </div> + <div + className="search-buttons-bar" + > + <SearchModifiers + modifiers={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + onToggleSearchModifier={[Function]} + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <button + className="regex-match-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Use Regular Expression" + > + <span + className="regex-match" + /> + </button> + <button + className="case-sensitive-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Match Case" + > + <span + className="case-match" + /> + </button> + <button + className="whole-word-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Match Whole Word" + > + <span + className="whole-word-match" + /> + </button> + </div> + </SearchModifiers> + </div> + </div> + <div + className="exclude-patterns-field small" + > + <label> + files to exclude + </label> + <input + onChange={[Function]} + onKeyDown={[Function]} + placeholder="e.g. **/node_modules/**,app.js" + value="" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + <Tree + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + focused={null} + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getPath={[Function]} + getRoots={[Function]} + isExpanded={[Function]} + itemHeight={24} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + > + <div + aria-activedescendant={null} + className="tree " + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={true} + focused={false} + id="undefined-$" + index={0} + isExpandable={true} + item={ + Object { + "location": Object { + "source": Object { + "url": "testFilePath1", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + }, + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + }, + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + }, + ], + "type": "RESULT", + } + } + key="undefined-$-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node" + data-expandable={true} + id="undefined-$" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <div + className="file-result" + > + <AccessibleImage + className="arrow expanded" + > + <span + className="img arrow expanded" + /> + </AccessibleImage> + <AccessibleImage + className="file" + > + <span + className="img file" + /> + </AccessibleImage> + <span + className="file-path" + /> + <span + className="matches-summary" + > + (3 matches) + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-30-1" + index={1} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + } + } + key="undefined-undefined-30-1-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-30-1" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match1 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match1 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-60-2" + index={2} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + } + } + key="undefined-undefined-60-2-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-60-2" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match2 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match2 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-90-3" + index={3} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + } + } + key="undefined-undefined-90-3-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-90-3" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match3 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match3 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={0} + expanded={true} + focused={false} + id="undefined-4" + index={4} + isExpandable={true} + item={ + Object { + "location": Object { + "source": Object { + "url": "testFilePath2", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + }, + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + }, + ], + "type": "RESULT", + } + } + key="undefined-4-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node" + data-expandable={true} + id="undefined-4" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <div + className="file-result" + > + <AccessibleImage + className="arrow expanded" + > + <span + className="img arrow expanded" + /> + </AccessibleImage> + <AccessibleImage + className="file" + > + <span + className="img file" + /> + </AccessibleImage> + <span + className="file-path" + /> + <span + className="matches-summary" + > + (2 matches) + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-80-5" + index={5} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + } + } + key="undefined-undefined-80-5-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-80-5" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match4 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match4 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-40-6" + index={6} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + } + } + key="undefined-undefined-40-6-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-40-6" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + ​ + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match5 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match5 + </span> + </span> + </div> + </div> + </TreeNode> + </div> + </Tree> + </div> + </div> +</ProjectSearch> +`; + +exports[`ProjectSearch renders nothing when disabled 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + No results found + </div> + </div> +</div> +`; + +exports[`ProjectSearch should display loading message while search is in progress 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + Loading… + </div> + </div> +</div> +`; + +exports[`ProjectSearch showErrorEmoji false if not done & no results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + Loading… + </div> + </div> +</div> +`; + +exports[`ProjectSearch showErrorEmoji false if not done & results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + /> + </div> + <Tree + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + focused={null} + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getPath={[Function]} + getRoots={[Function]} + isExpanded={[Function]} + itemHeight={24} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + /> + </div> +</div> +`; + +exports[`ProjectSearch turns off shortcuts on unmount 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="" + /> + </div> + </div> +</div> +`; + +exports[`ProjectSearch where <Enter> has not been pressed 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="" + /> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/QuickOpenModal.css b/devtools/client/debugger/src/components/QuickOpenModal.css new file mode 100644 index 0000000000..5a2627b99f --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.css @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.result-item .title .highlight { + font-weight: bold; + background-color: transparent; +} + +.selected .highlight { + color: white; +} + +.result-item .subtitle .highlight { + color: var(--grey-90); + font-weight: 500; + background-color: transparent; +} + +.theme-dark .result-item .title .highlight, +.theme-dark .result-item .subtitle .highlight { + color: white; +} + +.loading-indicator { + padding: 5px 0 5px 0; + text-align: center; +} diff --git a/devtools/client/debugger/src/components/QuickOpenModal.js b/devtools/client/debugger/src/components/QuickOpenModal.js new file mode 100644 index 0000000000..f993b0f6c1 --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.js @@ -0,0 +1,524 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../utils/connect"; +import fuzzyAldrin from "fuzzaldrin-plus"; +import { basename } from "../utils/path"; +import { createLocation } from "../utils/location"; + +const { throttle } = require("devtools/shared/throttle"); + +import actions from "../actions"; +import { + getDisplayedSourcesList, + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, + getSelectedSource, + getSelectedLocation, + getSettledSourceTextContent, + getSymbols, + getTabs, + getContext, + getBlackBoxRanges, + getProjectDirectoryRoot, +} from "../selectors"; +import { memoizeLast } from "../utils/memoizeLast"; +import { scrollList } from "../utils/result-list"; +import { searchKeys } from "../constants"; +import { + formatSymbols, + parseLineColumn, + formatShortcutResults, + formatSourceForList, +} from "../utils/quick-open"; +import Modal from "./shared/Modal"; +import SearchInput from "./shared/SearchInput"; +import ResultList from "./shared/ResultList"; + +import "./QuickOpenModal.css"; + +const maxResults = 100; + +const SIZE_BIG = { size: "big" }; +const SIZE_DEFAULT = {}; + +function filter(values, query) { + const preparedQuery = fuzzyAldrin.prepareQuery(query); + + return fuzzyAldrin.filter(values, query, { + key: "value", + maxResults, + preparedQuery, + }); +} + +export class QuickOpenModal extends Component { + // Put it on the class so it can be retrieved in tests + static UPDATE_RESULTS_THROTTLE = 100; + + constructor(props) { + super(props); + this.state = { results: null, selectedIndex: 0 }; + } + + static get propTypes() { + return { + closeQuickOpen: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + displayedSources: PropTypes.array.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + enabled: PropTypes.bool.isRequired, + highlightLineRange: PropTypes.func.isRequired, + clearHighlightLineRange: PropTypes.func.isRequired, + query: PropTypes.string.isRequired, + searchType: PropTypes.oneOf([ + "functions", + "goto", + "gotoSource", + "other", + "shortcuts", + "sources", + "variables", + ]).isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + selectedContentLoaded: PropTypes.bool, + selectedSource: PropTypes.object, + setQuickOpenQuery: PropTypes.func.isRequired, + shortcutsModalEnabled: PropTypes.bool.isRequired, + symbols: PropTypes.object.isRequired, + symbolsLoading: PropTypes.bool.isRequired, + tabUrls: PropTypes.array.isRequired, + toggleShortcutsModal: PropTypes.func.isRequired, + projectDirectoryRoot: PropTypes.string, + }; + } + + setResults(results) { + if (results) { + results = results.slice(0, maxResults); + } + this.setState({ results }); + } + + componentDidMount() { + const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props; + + this.updateResults(query); + + if (shortcutsModalEnabled) { + toggleShortcutsModal(); + } + } + + componentDidUpdate(prevProps) { + const nowEnabled = !prevProps.enabled && this.props.enabled; + const queryChanged = prevProps.query !== this.props.query; + + if (this.refs.resultList && this.refs.resultList.refs) { + scrollList(this.refs.resultList.refs, this.state.selectedIndex); + } + + if (nowEnabled || queryChanged) { + this.updateResults(this.props.query); + } + } + + closeModal = () => { + this.props.closeQuickOpen(); + }; + + dropGoto = query => { + const index = query.indexOf(":"); + return index !== -1 ? query.slice(0, index) : query; + }; + + formatSources = memoizeLast( + (displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot) => { + // Note that we should format all displayed sources, + // the actual filtering will only be done late from `searchSources()` + return displayedSources.map(source => { + const isBlackBoxed = !!blackBoxRanges[source.url]; + const hasTabOpened = tabUrls.includes(source.url); + return formatSourceForList( + source, + hasTabOpened, + isBlackBoxed, + projectDirectoryRoot + ); + }); + } + ); + + searchSources = query => { + const { displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot } = + this.props; + + const sources = this.formatSources( + displayedSources, + tabUrls, + blackBoxRanges, + projectDirectoryRoot + ); + const results = + query == "" ? sources : filter(sources, this.dropGoto(query)); + return this.setResults(results); + }; + + searchSymbols = query => { + const { + symbols: { functions }, + } = this.props; + + let results = functions; + results = results.filter(result => result.title !== "anonymous"); + + if (query === "@" || query === "#") { + return this.setResults(results); + } + results = filter(results, query.slice(1)); + return this.setResults(results); + }; + + searchShortcuts = query => { + const results = formatShortcutResults(); + if (query == "?") { + this.setResults(results); + } else { + this.setResults(filter(results, query.slice(1))); + } + }; + + /** + * This method is called when we just opened the modal and the query input is empty + */ + showTopSources = () => { + const { tabUrls, blackBoxRanges, projectDirectoryRoot } = this.props; + let { displayedSources } = this.props; + + // If there is some tabs opened, only show tab's sources. + // Otherwise, we display all visible sources (per SourceTree definition), + // setResults will restrict the number of results to a maximum limit. + if (tabUrls.length) { + displayedSources = displayedSources.filter( + source => !!source.url && tabUrls.includes(source.url) + ); + } + + this.setResults( + this.formatSources( + displayedSources, + tabUrls, + blackBoxRanges, + projectDirectoryRoot + ) + ); + }; + + updateResults = throttle(query => { + if (this.isGotoQuery()) { + return; + } + + if (query == "" && !this.isShortcutQuery()) { + this.showTopSources(); + return; + } + + if (this.isSymbolSearch()) { + this.searchSymbols(query); + return; + } + + if (this.isShortcutQuery()) { + this.searchShortcuts(query); + return; + } + + this.searchSources(query); + }, QuickOpenModal.UPDATE_RESULTS_THROTTLE); + + setModifier = item => { + if (["@", "#", ":"].includes(item.id)) { + this.props.setQuickOpenQuery(item.id); + } + }; + + selectResultItem = (e, item) => { + if (item == null) { + return; + } + + if (this.isShortcutQuery()) { + this.setModifier(item); + return; + } + + if (this.isGotoSourceQuery()) { + const location = parseLineColumn(this.props.query); + this.gotoLocation({ ...location, source: item.source }); + return; + } + + if (this.isSymbolSearch()) { + this.gotoLocation({ + line: + item.location && item.location.start ? item.location.start.line : 0, + }); + return; + } + + this.gotoLocation({ source: item.source, line: 0 }); + }; + + onSelectResultItem = item => { + const { selectedSource, highlightLineRange, clearHighlightLineRange } = + this.props; + if ( + selectedSource == null || + !this.isSymbolSearch() || + !this.isFunctionQuery() + ) { + return; + } + + if (item.location) { + highlightLineRange({ + start: item.location.start.line, + end: item.location.end.line, + sourceId: selectedSource.id, + }); + } else { + clearHighlightLineRange(); + } + }; + + traverseResults = e => { + const direction = e.key === "ArrowUp" ? -1 : 1; + const { selectedIndex, results } = this.state; + const resultCount = this.getResultCount(); + const index = selectedIndex + direction; + const nextIndex = (index + resultCount) % resultCount || 0; + + this.setState({ selectedIndex: nextIndex }); + + if (results != null) { + this.onSelectResultItem(results[nextIndex]); + } + }; + + gotoLocation = location => { + const { cx, selectSpecificLocation, selectedSource } = this.props; + + if (location != null) { + selectSpecificLocation( + cx, + createLocation({ + source: location.source || selectedSource, + line: location.line, + column: location.column, + }) + ); + this.closeModal(); + } + }; + + onChange = e => { + const { selectedSource, selectedContentLoaded, setQuickOpenQuery } = + this.props; + setQuickOpenQuery(e.target.value); + const noSource = !selectedSource || !selectedContentLoaded; + if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) { + return; + } + + // Wait for the next tick so that reducer updates are complete. + const targetValue = e.target.value; + setTimeout(() => this.updateResults(targetValue), 0); + }; + + onKeyDown = e => { + const { enabled, query } = this.props; + const { results, selectedIndex } = this.state; + const isGoToQuery = this.isGotoQuery(); + + if ((!enabled || !results) && !isGoToQuery) { + return; + } + + if (e.key === "Enter") { + if (isGoToQuery) { + const location = parseLineColumn(query); + this.gotoLocation(location); + return; + } + + if (results) { + this.selectResultItem(e, results[selectedIndex]); + return; + } + } + + if (e.key === "Tab") { + this.closeModal(); + return; + } + + if (["ArrowUp", "ArrowDown"].includes(e.key)) { + e.preventDefault(); + this.traverseResults(e); + } + }; + + getResultCount = () => { + const { results } = this.state; + return results && results.length ? results.length : 0; + }; + + // Query helpers + isFunctionQuery = () => this.props.searchType === "functions"; + isSymbolSearch = () => this.isFunctionQuery(); + isGotoQuery = () => this.props.searchType === "goto"; + isGotoSourceQuery = () => this.props.searchType === "gotoSource"; + isShortcutQuery = () => this.props.searchType === "shortcuts"; + isSourcesQuery = () => this.props.searchType === "sources"; + isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery(); + + /* eslint-disable react/no-danger */ + renderHighlight(candidateString, query, name) { + const options = { + wrap: { + tagOpen: '<mark class="highlight">', + tagClose: "</mark>", + }, + }; + const html = fuzzyAldrin.wrap(candidateString, query, options); + return <div dangerouslySetInnerHTML={{ __html: html }} />; + } + + highlightMatching = (query, results) => { + let newQuery = query; + if (newQuery === "") { + return results; + } + newQuery = query.replace(/[@:#?]/gi, " "); + + return results.map(result => { + if (typeof result.title == "string") { + return { + ...result, + title: this.renderHighlight( + result.title, + basename(newQuery), + "title" + ), + }; + } + return result; + }); + }; + + shouldShowErrorEmoji() { + const { query } = this.props; + if (this.isGotoQuery()) { + return !/^:\d*$/.test(query); + } + return !!query && !this.getResultCount(); + } + + getSummaryMessage() { + let summaryMsg = ""; + if (this.isGotoQuery()) { + summaryMsg = L10N.getStr("shortcuts.gotoLine"); + } else if (this.isFunctionQuery() && this.props.symbolsLoading) { + summaryMsg = L10N.getStr("loadingText"); + } + return summaryMsg; + } + + render() { + const { enabled, query } = this.props; + const { selectedIndex, results } = this.state; + + if (!enabled) { + return null; + } + const items = this.highlightMatching(query, results || []); + const expanded = !!items && !!items.length; + + return ( + <Modal in={enabled} handleClose={this.closeModal}> + <SearchInput + query={query} + hasPrefix={true} + count={this.getResultCount()} + placeholder={L10N.getStr("sourceSearch.search2")} + summaryMsg={this.getSummaryMessage()} + showErrorEmoji={this.shouldShowErrorEmoji()} + isLoading={false} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + handleClose={this.closeModal} + expanded={expanded} + showClose={false} + searchKey={searchKeys.QUICKOPEN_SEARCH} + showExcludePatterns={false} + showSearchModifiers={false} + selectedItemId={ + expanded && items[selectedIndex] ? items[selectedIndex].id : "" + } + {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)} + /> + {results && ( + <ResultList + key="results" + items={items} + selected={selectedIndex} + selectItem={this.selectResultItem} + ref="resultList" + expanded={expanded} + {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)} + /> + )} + </Modal> + ); + } +} + +/* istanbul ignore next: ignoring testing of redux connection stuff */ +function mapStateToProps(state) { + const selectedSource = getSelectedSource(state); + const location = getSelectedLocation(state); + const displayedSources = getDisplayedSourcesList(state); + const tabs = getTabs(state); + const tabUrls = [...new Set(tabs.map(tab => tab.url))]; + const symbols = getSymbols(state, location); + + return { + cx: getContext(state), + enabled: getQuickOpenEnabled(state), + displayedSources, + blackBoxRanges: getBlackBoxRanges(state), + projectDirectoryRoot: getProjectDirectoryRoot(state), + selectedSource, + selectedContentLoaded: location + ? !!getSettledSourceTextContent(state, location) + : undefined, + symbols: formatSymbols(symbols, maxResults), + symbolsLoading: !symbols, + query: getQuickOpenQuery(state), + searchType: getQuickOpenType(state), + tabUrls, + }; +} + +export default connect(mapStateToProps, { + selectSpecificLocation: actions.selectSpecificLocation, + setQuickOpenQuery: actions.setQuickOpenQuery, + highlightLineRange: actions.highlightLineRange, + clearHighlightLineRange: actions.clearHighlightLineRange, + closeQuickOpen: actions.closeQuickOpen, +})(QuickOpenModal); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js new file mode 100644 index 0000000000..368170bed7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../../utils/connect"; +import { createSelector } from "reselect"; +import actions from "../../../actions"; + +import showContextMenu from "./BreakpointsContextMenu"; +import { CloseButton } from "../../shared/Button"; + +import { getSelectedText, makeBreakpointId } from "../../../utils/breakpoint"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { isLineBlackboxed } from "../../../utils/source"; + +import { + getBreakpointsList, + getSelectedFrame, + getSelectedSource, + getCurrentThread, + getContext, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class Breakpoint extends PureComponent { + static get propTypes() { + return { + breakpoint: PropTypes.object.isRequired, + cx: PropTypes.object.isRequired, + disableBreakpoint: PropTypes.func.isRequired, + editor: PropTypes.object.isRequired, + enableBreakpoint: PropTypes.func.isRequired, + frame: PropTypes.object, + openConditionalPanel: PropTypes.func.isRequired, + removeBreakpoint: PropTypes.func.isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + source: PropTypes.object.isRequired, + blackboxedRangesForSource: PropTypes.array.isRequired, + checkSourceOnIgnoreList: PropTypes.func.isRequired, + }; + } + + onContextMenu = e => { + showContextMenu({ ...this.props, contextMenuEvent: e }); + }; + + get selectedLocation() { + const { breakpoint, selectedSource } = this.props; + return getSelectedLocation(breakpoint, selectedSource); + } + + onDoubleClick = () => { + const { breakpoint, openConditionalPanel } = this.props; + if (breakpoint.options.condition) { + openConditionalPanel(this.selectedLocation); + } else if (breakpoint.options.logValue) { + openConditionalPanel(this.selectedLocation, true); + } + }; + + selectBreakpoint = event => { + event.preventDefault(); + const { cx, selectSpecificLocation } = this.props; + selectSpecificLocation(cx, this.selectedLocation); + }; + + removeBreakpoint = event => { + const { cx, removeBreakpoint, breakpoint } = this.props; + event.stopPropagation(); + removeBreakpoint(cx, breakpoint); + }; + + handleBreakpointCheckbox = () => { + const { cx, breakpoint, enableBreakpoint, disableBreakpoint } = this.props; + if (breakpoint.disabled) { + enableBreakpoint(cx, breakpoint); + } else { + disableBreakpoint(cx, breakpoint); + } + }; + + isCurrentlyPausedAtBreakpoint() { + const { frame } = this.props; + if (!frame) { + return false; + } + + const bpId = makeBreakpointId(this.selectedLocation); + const frameId = makeBreakpointId(frame.selectedLocation); + return bpId == frameId; + } + + getBreakpointLocation() { + const { source } = this.props; + const { column, line } = this.selectedLocation; + + const isWasm = source?.isWasm; + const columnVal = column ? `:${column}` : ""; + const bpLocation = isWasm + ? `0x${line.toString(16).toUpperCase()}` + : `${line}${columnVal}`; + + return bpLocation; + } + + getBreakpointText() { + const { breakpoint, selectedSource } = this.props; + const { condition, logValue } = breakpoint.options; + return logValue || condition || getSelectedText(breakpoint, selectedSource); + } + + highlightText(text = "", editor) { + const node = document.createElement("div"); + editor.CodeMirror.runMode(text, "application/javascript", node); + return { __html: node.innerHTML }; + } + + render() { + const { + breakpoint, + editor, + blackboxedRangesForSource, + checkSourceOnIgnoreList, + } = this.props; + const text = this.getBreakpointText(); + const labelId = `${breakpoint.id}-label`; + + return ( + <div + className={classnames({ + breakpoint, + paused: this.isCurrentlyPausedAtBreakpoint(), + disabled: breakpoint.disabled, + "is-conditional": !!breakpoint.options.condition, + "is-log": !!breakpoint.options.logValue, + })} + onClick={this.selectBreakpoint} + onDoubleClick={this.onDoubleClick} + onContextMenu={this.onContextMenu} + > + <input + id={breakpoint.id} + type="checkbox" + className="breakpoint-checkbox" + checked={!breakpoint.disabled} + disabled={isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + )} + onChange={this.handleBreakpointCheckbox} + onClick={ev => ev.stopPropagation()} + aria-labelledby={labelId} + /> + <span + id={labelId} + className="breakpoint-label cm-s-mozilla devtools-monospace" + onClick={this.selectBreakpoint} + title={text} + > + <span dangerouslySetInnerHTML={this.highlightText(text, editor)} /> + </span> + <div className="breakpoint-line-close"> + <div className="breakpoint-line devtools-monospace"> + {this.getBreakpointLocation()} + </div> + <CloseButton + handleClick={e => this.removeBreakpoint(e)} + tooltip={L10N.getStr("breakpoints.removeBreakpointTooltip")} + /> + </div> + </div> + ); + } +} + +const getFormattedFrame = createSelector( + getSelectedSource, + getSelectedFrame, + (selectedSource, frame) => { + if (!frame) { + return null; + } + + return { + ...frame, + selectedLocation: getSelectedLocation(frame, selectedSource), + }; + } +); + +const mapStateToProps = (state, p) => ({ + cx: getContext(state), + breakpoints: getBreakpointsList(state), + frame: getFormattedFrame(state, getCurrentThread(state)), + checkSourceOnIgnoreList: source => + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), +}); + +export default connect(mapStateToProps, { + enableBreakpoint: actions.enableBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + removeBreakpoints: actions.removeBreakpoints, + removeAllBreakpoints: actions.removeAllBreakpoints, + disableBreakpoint: actions.disableBreakpoint, + selectSpecificLocation: actions.selectSpecificLocation, + setBreakpointOptions: actions.setBreakpointOptions, + toggleAllBreakpoints: actions.toggleAllBreakpoints, + toggleBreakpoints: actions.toggleBreakpoints, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + openConditionalPanel: actions.openConditionalPanel, +})(Breakpoint); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js new file mode 100644 index 0000000000..c2c29cc258 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../../utils/connect"; +import actions from "../../../actions"; + +import { + getTruncatedFileName, + getDisplayPath, + getSourceQueryString, + getFileURL, +} from "../../../utils/source"; +import { createLocation } from "../../../utils/location"; +import { + getBreakpointsForSource, + getContext, + getFirstSourceActorForGeneratedSource, +} from "../../../selectors"; + +import SourceIcon from "../../shared/SourceIcon"; + +import showContextMenu from "./BreakpointHeadingsContextMenu"; + +class BreakpointHeading extends PureComponent { + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + sources: PropTypes.array.isRequired, + source: PropTypes.object.isRequired, + firstSourceActor: PropTypes.object, + selectSource: PropTypes.func.isRequired, + }; + } + onContextMenu = e => { + showContextMenu({ ...this.props, contextMenuEvent: e }); + }; + + render() { + const { cx, sources, source, selectSource } = this.props; + + const path = getDisplayPath(source, sources); + const query = getSourceQueryString(source); + + return ( + <div + className="breakpoint-heading" + title={getFileURL(source, false)} + onClick={() => selectSource(cx, source)} + onContextMenu={this.onContextMenu} + > + <SourceIcon + // Breakpoints are displayed per source and may relate to many source actors. + // Arbitrarily pick the first source actor to compute the matching source icon + // The source actor is used to pick one specific source text content and guess + // the related framework icon. + location={createLocation({ + source, + sourceActor: this.props.firstSourceActor, + })} + modifier={icon => + ["file", "javascript"].includes(icon) ? null : icon + } + /> + <div className="filename"> + {getTruncatedFileName(source, query)} + {path && <span>{`../${path}/..`}</span>} + </div> + </div> + ); + } +} + +const mapStateToProps = (state, { source }) => ({ + cx: getContext(state), + breakpointsForSource: getBreakpointsForSource(state, source.id), + firstSourceActor: getFirstSourceActorForGeneratedSource(state, source.id), +}); + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + enableBreakpointsInSource: actions.enableBreakpointsInSource, + disableBreakpointsInSource: actions.disableBreakpointsInSource, + removeBreakpointsInSource: actions.removeBreakpointsInSource, +})(BreakpointHeading); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js new file mode 100644 index 0000000000..cdd3910b00 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { buildMenu, showMenu } from "../../../context-menu/menu"; + +export default function showContextMenu(props) { + const { + cx, + source, + breakpointsForSource, + disableBreakpointsInSource, + enableBreakpointsInSource, + removeBreakpointsInSource, + contextMenuEvent, + } = props; + + contextMenuEvent.preventDefault(); + + const enableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.label" + ); + const disableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.label" + ); + const removeInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.label" + ); + const enableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.accesskey" + ); + const disableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.accesskey" + ); + const removeInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.accesskey" + ); + + const disableInSourceItem = { + id: "node-menu-disable-in-source", + label: disableInSourceLabel, + accesskey: disableInSourceKey, + disabled: false, + click: () => disableBreakpointsInSource(cx, source), + }; + + const enableInSourceItem = { + id: "node-menu-enable-in-source", + label: enableInSourceLabel, + accesskey: enableInSourceKey, + disabled: false, + click: () => enableBreakpointsInSource(cx, source), + }; + + const removeInSourceItem = { + id: "node-menu-enable-in-source", + label: removeInSourceLabel, + accesskey: removeInSourceKey, + disabled: false, + click: () => removeBreakpointsInSource(cx, source), + }; + + const hideDisableInSourceItem = breakpointsForSource.every( + breakpoint => breakpoint.disabled + ); + const hideEnableInSourceItem = breakpointsForSource.every( + breakpoint => !breakpoint.disabled + ); + + const items = [ + { item: disableInSourceItem, hidden: () => hideDisableInSourceItem }, + { item: enableInSourceItem, hidden: () => hideEnableInSourceItem }, + { item: removeInSourceItem, hidden: () => false }, + ]; + + showMenu(contextMenuEvent, buildMenu(items)); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css new file mode 100644 index 0000000000..98075058b8 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.breakpoints-pane > ._content { + overflow-x: auto; +} + +.breakpoints-exceptions-options *, +.breakpoints-list * { + user-select: none; +} + +.breakpoints-list { + padding: 4px 0; +} + +.breakpoints-list .breakpoint-heading { + text-overflow: ellipsis; + width: 100%; + font-size: 12px; + line-height: 16px; +} + +.breakpoint-heading:not(:first-child) { + margin-top: 2px; +} + +.breakpoints-list .breakpoint-heading .filename { + overflow: hidden; + text-overflow: ellipsis; +} + +.breakpoints-list .breakpoint-heading .filename span { + opacity: 0.7; + padding-left: 4px; +} + +.breakpoints-list .breakpoint-heading, +.breakpoints-list .breakpoint { + color: var(--theme-text-color-strong); + position: relative; + cursor: pointer; +} + +.breakpoints-list .breakpoint-heading, +.breakpoints-list .breakpoint, +.breakpoints-exceptions, +.breakpoints-exceptions-caught { + display: flex; + align-items: center; + overflow: hidden; + padding-top: 2px; + padding-bottom: 2px; + padding-inline-start: 16px; + padding-inline-end: 12px; +} + +.breakpoints-exceptions { + padding-bottom: 3px; + padding-top: 3px; + user-select: none; +} + +.breakpoints-exceptions-caught { + padding-bottom: 3px; + padding-top: 3px; + padding-inline-start: 36px; +} + +.breakpoints-exceptions-options { + padding-top: 4px; + padding-bottom: 4px; +} + +.xhr-breakpoints-pane .breakpoints-exceptions-options { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.breakpoints-exceptions-options:not(.empty) { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.breakpoints-exceptions input, +.breakpoints-exceptions-caught input { + padding-inline-start: 2px; + margin-top: 0px; + margin-bottom: 0px; + margin-inline-start: 0; + margin-inline-end: 2px; + vertical-align: text-bottom; +} + +.breakpoint-exceptions-label { + line-height: 14px; + padding-inline-end: 8px; + cursor: default; + overflow: hidden; + text-overflow: ellipsis; +} + +html[dir="rtl"] .breakpoints-list .breakpoint, +html[dir="rtl"] .breakpoints-list .breakpoint-heading, +html[dir="rtl"] .breakpoints-exceptions { + border-right: 4px solid transparent; +} + +html:not([dir="rtl"]) .breakpoints-list .breakpoint, +html:not([dir="rtl"]) .breakpoints-list .breakpoint-heading, +html:not([dir="rtl"]) .breakpoints-exceptions { + border-left: 4px solid transparent; +} + +html .breakpoints-list .breakpoint.is-conditional { + border-inline-start-color: var(--theme-graphs-yellow); +} + +html .breakpoints-list .breakpoint.is-log { + border-inline-start-color: var(--theme-graphs-purple); +} + +html .breakpoints-list .breakpoint.paused { + background-color: var(--theme-toolbar-background-alt); + border-color: var(--breakpoint-active-color); +} + +.breakpoints-list .breakpoint:hover { + background-color: var(--search-overlays-semitransparent); +} + +.breakpoint-line-close { + margin-inline-start: 4px; +} + +.breakpoints-list .breakpoint .breakpoint-line { + font-size: 11px; + color: var(--theme-comment); + min-width: 16px; + text-align: end; + padding-top: 1px; + padding-bottom: 1px; +} + +.breakpoints-list .breakpoint:hover .breakpoint-line, +.breakpoints-list .breakpoint-line-close:focus-within .breakpoint-line { + color: transparent; +} + +.breakpoints-list .breakpoint.paused:hover { + border-color: var(--breakpoint-active-color-hover); +} + +.breakpoints-list .breakpoint-label { + display: inline-block; + cursor: pointer; + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + font-size: 11px; +} + +.breakpoints-list .breakpoint-label span, +.breakpoint-line-close { + display: inline; + line-height: 14px; +} + +.breakpoint-checkbox { + margin-inline-start: 0px; + margin-top: 0px; + margin-bottom: 0px; + vertical-align: text-bottom; +} + +.breakpoint-label .location { + width: 100%; + display: inline-block; + overflow-x: hidden; + text-overflow: ellipsis; + padding: 1px 0; + vertical-align: bottom; +} + +.breakpoints-list .pause-indicator { + flex: 0 1 content; + order: 3; +} + +.breakpoint .close-btn { + position: absolute; + /* hide button outside of row until hovered or focused */ + top: -100px; +} + +[dir="ltr"] .breakpoint .close-btn { + right: 12px; +} + +[dir="rtl"] .breakpoint .close-btn { + left: 12px; +} + +/* Reveal the remove button on hover/focus */ +.breakpoint:hover .close-btn, +.breakpoint .close-btn:focus { + top: calc(50% - 8px); +} + +/* Hide the line number when revealing the remove button (since they're overlayed) */ +.breakpoint-line-close:focus-within .breakpoint-line, +.breakpoint:hover .breakpoint-line { + visibility: hidden; +} + +.CodeMirror.cm-s-mozilla-breakpoint { + cursor: pointer; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-lines { + padding: 0; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-sizer { + min-width: initial !important; +} + +.breakpoints-list .breakpoint .CodeMirror.cm-s-mozilla-breakpoint { + transition: opacity 0.15s linear; +} + +.breakpoints-list .breakpoint.disabled .CodeMirror.cm-s-mozilla-breakpoint { + opacity: 0.5; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-line span[role="presentation"] { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-code, +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-scroll { + pointer-events: none; +} + +.CodeMirror.cm-s-mozilla-breakpoint { + padding-top: 1px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js new file mode 100644 index 0000000000..c2d8f3ff33 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js @@ -0,0 +1,365 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { buildMenu, showMenu } from "../../../context-menu/menu"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { isLineBlackboxed } from "../../../utils/source"; +import { features } from "../../../utils/prefs"; +import { formatKeyShortcut } from "../../../utils/text"; + +export default function showContextMenu(props) { + const { + cx, + breakpoint, + breakpoints, + selectedSource, + removeBreakpoint, + removeBreakpoints, + removeAllBreakpoints, + toggleBreakpoints, + toggleAllBreakpoints, + toggleDisabledBreakpoint, + selectSpecificLocation, + setBreakpointOptions, + openConditionalPanel, + contextMenuEvent, + blackboxedRangesForSource, + checkSourceOnIgnoreList, + } = props; + + contextMenuEvent.preventDefault(); + + const deleteSelfLabel = L10N.getStr("breakpointMenuItem.deleteSelf2.label"); + const deleteAllLabel = L10N.getStr("breakpointMenuItem.deleteAll2.label"); + const deleteOthersLabel = L10N.getStr( + "breakpointMenuItem.deleteOthers2.label" + ); + const enableSelfLabel = L10N.getStr("breakpointMenuItem.enableSelf2.label"); + const enableAllLabel = L10N.getStr("breakpointMenuItem.enableAll2.label"); + const enableOthersLabel = L10N.getStr( + "breakpointMenuItem.enableOthers2.label" + ); + const disableSelfLabel = L10N.getStr("breakpointMenuItem.disableSelf2.label"); + const disableAllLabel = L10N.getStr("breakpointMenuItem.disableAll2.label"); + const disableOthersLabel = L10N.getStr( + "breakpointMenuItem.disableOthers2.label" + ); + const enableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.enabledbg.label" + ); + const disableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.disabledbg.label" + ); + const removeConditionLabel = L10N.getStr( + "breakpointMenuItem.removeCondition2.label" + ); + const addConditionLabel = L10N.getStr( + "breakpointMenuItem.addCondition2.label" + ); + const editConditionLabel = L10N.getStr( + "breakpointMenuItem.editCondition2.label" + ); + + const deleteSelfKey = L10N.getStr("breakpointMenuItem.deleteSelf2.accesskey"); + const deleteAllKey = L10N.getStr("breakpointMenuItem.deleteAll2.accesskey"); + const deleteOthersKey = L10N.getStr( + "breakpointMenuItem.deleteOthers2.accesskey" + ); + const enableSelfKey = L10N.getStr("breakpointMenuItem.enableSelf2.accesskey"); + const enableAllKey = L10N.getStr("breakpointMenuItem.enableAll2.accesskey"); + const enableOthersKey = L10N.getStr( + "breakpointMenuItem.enableOthers2.accesskey" + ); + const disableSelfKey = L10N.getStr( + "breakpointMenuItem.disableSelf2.accesskey" + ); + const disableAllKey = L10N.getStr("breakpointMenuItem.disableAll2.accesskey"); + const disableOthersKey = L10N.getStr( + "breakpointMenuItem.disableOthers2.accesskey" + ); + const removeConditionKey = L10N.getStr( + "breakpointMenuItem.removeCondition2.accesskey" + ); + const editConditionKey = L10N.getStr( + "breakpointMenuItem.editCondition2.accesskey" + ); + const addConditionKey = L10N.getStr( + "breakpointMenuItem.addCondition2.accesskey" + ); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const otherBreakpoints = breakpoints.filter(b => b.id !== breakpoint.id); + const enabledBreakpoints = breakpoints.filter(b => !b.disabled); + const disabledBreakpoints = breakpoints.filter(b => b.disabled); + const otherEnabledBreakpoints = breakpoints.filter( + b => !b.disabled && b.id !== breakpoint.id + ); + const otherDisabledBreakpoints = breakpoints.filter( + b => b.disabled && b.id !== breakpoint.id + ); + + const deleteSelfItem = { + id: "node-menu-delete-self", + label: deleteSelfLabel, + accesskey: deleteSelfKey, + disabled: false, + click: () => { + removeBreakpoint(cx, breakpoint); + }, + }; + + const deleteAllItem = { + id: "node-menu-delete-all", + label: deleteAllLabel, + accesskey: deleteAllKey, + disabled: false, + click: () => removeAllBreakpoints(cx), + }; + + const deleteOthersItem = { + id: "node-menu-delete-other", + label: deleteOthersLabel, + accesskey: deleteOthersKey, + disabled: false, + click: () => removeBreakpoints(cx, otherBreakpoints), + }; + + const enableSelfItem = { + id: "node-menu-enable-self", + label: enableSelfLabel, + accesskey: enableSelfKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => { + toggleDisabledBreakpoint(cx, breakpoint); + }, + }; + + const enableAllItem = { + id: "node-menu-enable-all", + label: enableAllLabel, + accesskey: enableAllKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => toggleAllBreakpoints(cx, false), + }; + + const enableOthersItem = { + id: "node-menu-enable-others", + label: enableOthersLabel, + accesskey: enableOthersKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => toggleBreakpoints(cx, false, otherDisabledBreakpoints), + }; + + const disableSelfItem = { + id: "node-menu-disable-self", + label: disableSelfLabel, + accesskey: disableSelfKey, + disabled: false, + click: () => { + toggleDisabledBreakpoint(cx, breakpoint); + }, + }; + + const disableAllItem = { + id: "node-menu-disable-all", + label: disableAllLabel, + accesskey: disableAllKey, + disabled: false, + click: () => toggleAllBreakpoints(cx, true), + }; + + const disableOthersItem = { + id: "node-menu-disable-others", + label: disableOthersLabel, + accesskey: disableOthersKey, + click: () => toggleBreakpoints(cx, true, otherEnabledBreakpoints), + }; + + const enableDbgStatementItem = { + id: "node-menu-enable-dbgStatement", + label: enableDbgStatementLabel, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: null, + }), + }; + + const disableDbgStatementItem = { + id: "node-menu-disable-dbgStatement", + label: disableDbgStatementLabel, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: "false", + }), + }; + + const removeConditionItem = { + id: "node-menu-remove-condition", + label: removeConditionLabel, + accesskey: removeConditionKey, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: null, + }), + }; + + const addConditionItem = { + id: "node-menu-add-condition", + label: addConditionLabel, + accesskey: addConditionKey, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const editConditionItem = { + id: "node-menu-edit-condition", + label: editConditionLabel, + accesskey: editConditionKey, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const addLogPointItem = { + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation, true); + }, + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), + }; + + const editLogPointItem = { + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation, true); + }, + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), + }; + + const removeLogPointItem = { + id: "node-menu-remove-log", + label: L10N.getStr("editor.removeLogPoint.label"), + accesskey: L10N.getStr("editor.removeLogPoint.accesskey"), + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + logValue: null, + }), + }; + + const logPointItem = breakpoint.options.logValue + ? editLogPointItem + : addLogPointItem; + + const hideEnableSelfItem = !breakpoint.disabled; + const hideEnableAllItem = disabledBreakpoints.length === 0; + const hideEnableOthersItem = otherDisabledBreakpoints.length === 0; + const hideDisableAllItem = enabledBreakpoints.length === 0; + const hideDisableOthersItem = otherEnabledBreakpoints.length === 0; + const hideDisableSelfItem = breakpoint.disabled; + const hideEnableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition !== "false"); + const hideDisableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition === "false"); + const items = [ + { item: enableSelfItem, hidden: () => hideEnableSelfItem }, + { item: enableAllItem, hidden: () => hideEnableAllItem }, + { item: enableOthersItem, hidden: () => hideEnableOthersItem }, + { + item: { type: "separator" }, + hidden: () => + hideEnableSelfItem && hideEnableAllItem && hideEnableOthersItem, + }, + { item: deleteSelfItem }, + { item: deleteAllItem }, + { item: deleteOthersItem, hidden: () => breakpoints.length === 1 }, + { + item: { type: "separator" }, + hidden: () => + hideDisableSelfItem && hideDisableAllItem && hideDisableOthersItem, + }, + + { item: disableSelfItem, hidden: () => hideDisableSelfItem }, + { item: disableAllItem, hidden: () => hideDisableAllItem }, + { item: disableOthersItem, hidden: () => hideDisableOthersItem }, + { + item: { type: "separator" }, + }, + { + item: enableDbgStatementItem, + hidden: () => hideEnableDbgStatementItem, + }, + { + item: disableDbgStatementItem, + hidden: () => hideDisableDbgStatementItem, + }, + { + item: { type: "separator" }, + hidden: () => hideDisableDbgStatementItem && hideEnableDbgStatementItem, + }, + { + item: addConditionItem, + hidden: () => breakpoint.options.condition, + }, + { + item: editConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: removeConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: logPointItem, + hidden: () => !features.logPoints, + }, + { + item: removeLogPointItem, + hidden: () => !features.logPoints || !breakpoint.options.logValue, + }, + ]; + + showMenu(contextMenuEvent, buildMenu(items)); + return null; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js new file mode 100644 index 0000000000..0b7d70fc62 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +export default function ExceptionOption({ + className, + isChecked = false, + label, + onChange, +}) { + return ( + <div className={className} onClick={onChange}> + <input + type="checkbox" + checked={isChecked ? "checked" : ""} + onChange={e => e.stopPropagation() && onChange()} + /> + <div className="breakpoint-exceptions-label">{label}</div> + </div> + ); +} + +ExceptionOption.propTypes = { + className: PropTypes.string.isRequired, + isChecked: PropTypes.bool.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js new file mode 100644 index 0000000000..3a3cc19afa --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../../utils/connect"; + +import ExceptionOption from "./ExceptionOption"; + +import Breakpoint from "./Breakpoint"; +import BreakpointHeading from "./BreakpointHeading"; + +import actions from "../../../actions"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { createHeadlessEditor } from "../../../utils/editor/create-editor"; + +import { makeBreakpointId } from "../../../utils/breakpoint"; + +import { + getSelectedSource, + getBreakpointSources, + getBlackBoxRanges, +} from "../../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Breakpoints.css"; + +class Breakpoints extends Component { + static get propTypes() { + return { + breakpointSources: PropTypes.array.isRequired, + pauseOnExceptions: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired, + shouldPauseOnExceptions: PropTypes.bool.isRequired, + blackboxedRanges: PropTypes.array.isRequired, + }; + } + + componentWillUnmount() { + this.removeEditor(); + } + + getEditor() { + if (!this.headlessEditor) { + this.headlessEditor = createHeadlessEditor(); + } + return this.headlessEditor; + } + + removeEditor() { + if (!this.headlessEditor) { + return; + } + this.headlessEditor.destroy(); + this.headlessEditor = null; + } + + renderExceptionsOptions() { + const { + breakpointSources, + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + pauseOnExceptions, + } = this.props; + + const isEmpty = !breakpointSources.length; + + return ( + <div + className={classnames("breakpoints-exceptions-options", { + empty: isEmpty, + })} + > + <ExceptionOption + className="breakpoints-exceptions" + label={L10N.getStr("pauseOnExceptionsItem2")} + isChecked={shouldPauseOnExceptions} + onChange={() => pauseOnExceptions(!shouldPauseOnExceptions, false)} + /> + + {shouldPauseOnExceptions && ( + <ExceptionOption + className="breakpoints-exceptions-caught" + label={L10N.getStr("pauseOnCaughtExceptionsItem")} + isChecked={shouldPauseOnCaughtExceptions} + onChange={() => + pauseOnExceptions(true, !shouldPauseOnCaughtExceptions) + } + /> + )} + </div> + ); + } + + renderBreakpoints() { + const { breakpointSources, selectedSource, blackboxedRanges } = this.props; + if (!breakpointSources.length) { + return null; + } + + const editor = this.getEditor(); + const sources = breakpointSources.map(({ source }) => source); + + return ( + <div className="pane breakpoints-list"> + {breakpointSources.map(({ source, breakpoints }) => { + return [ + <BreakpointHeading + key={source.id} + source={source} + sources={sources} + />, + breakpoints.map(breakpoint => ( + <Breakpoint + breakpoint={breakpoint} + source={source} + blackboxedRangesForSource={blackboxedRanges[source.url]} + selectedSource={selectedSource} + editor={editor} + key={makeBreakpointId( + getSelectedLocation(breakpoint, selectedSource) + )} + /> + )), + ]; + })} + </div> + ); + } + + render() { + return ( + <div className="pane"> + {this.renderExceptionsOptions()} + {this.renderBreakpoints()} + </div> + ); + } +} + +const mapStateToProps = state => ({ + breakpointSources: getBreakpointSources(state), + selectedSource: getSelectedSource(state), + blackboxedRanges: getBlackBoxRanges(state), +}); + +export default connect(mapStateToProps, { + pauseOnExceptions: actions.pauseOnExceptions, +})(Breakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build new file mode 100644 index 0000000000..2b075efdd4 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/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( + "Breakpoint.js", + "BreakpointHeading.js", + "BreakpointHeadingsContextMenu.js", + "BreakpointsContextMenu.js", + "ExceptionOption.js", + "index.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js new file mode 100644 index 0000000000..a28f9b06d5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import Breakpoint from "../Breakpoint"; +import { + createSourceObject, + createOriginalSourceObject, +} from "../../../../utils/test-head"; + +describe("Breakpoint", () => { + it("simple", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("disabled", () => { + const { component } = render({}, makeBreakpoint({ disabled: true })); + expect(component).toMatchSnapshot(); + }); + + it("paused at a generatedLocation", () => { + const { component } = render({ + frame: { selectedLocation: generatedLocation }, + }); + expect(component).toMatchSnapshot(); + }); + + it("paused at an original location", () => { + const source = createSourceObject("foo"); + const origSource = createOriginalSourceObject(source); + + const { component } = render( + { + selectedSource: origSource, + frame: { selectedLocation: location }, + }, + { location, options: {} } + ); + + expect(component).toMatchSnapshot(); + }); + + it("paused at a different", () => { + const { component } = render({ + frame: { selectedLocation: { ...generatedLocation, line: 14 } }, + }); + expect(component).toMatchSnapshot(); + }); +}); + +const generatedLocation = { source: { id: "foo" }, line: 53, column: 73 }; +const location = { source: { id: "foo/original" }, line: 5, column: 7 }; + +function render(overrides = {}, breakpointOverrides = {}) { + const props = generateDefaults(overrides, breakpointOverrides); + const component = shallow(<Breakpoint.WrappedComponent {...props} />); + const defaultState = component.state(); + const instance = component.instance(); + + return { component, props, defaultState, instance }; +} + +function makeBreakpoint(overrides = {}) { + return { + location, + generatedLocation, + disabled: false, + options: {}, + ...overrides, + id: 1, + }; +} + +function generateDefaults(overrides = {}, breakpointOverrides = {}) { + const source = createSourceObject("foo"); + const breakpoint = makeBreakpoint(breakpointOverrides); + const selectedSource = createSourceObject("foo"); + return { + cx: {}, + disableBreakpoint: () => {}, + enableBreakpoint: () => {}, + openConditionalPanel: () => {}, + removeBreakpoint: () => {}, + selectSpecificLocation: () => {}, + blackboxedRangesForSource: [], + checkSourceOnIgnoreList: () => {}, + source, + breakpoint, + selectedSource, + frame: null, + editor: { + CodeMirror: { + runMode: function () { + return ""; + }, + }, + }, + ...overrides, + }; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js new file mode 100644 index 0000000000..87194f762d --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.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 React from "react"; +import { shallow } from "enzyme"; + +import BreakpointsContextMenu from "../BreakpointsContextMenu"; +import { buildMenu } from "../../../../context-menu/menu"; + +import { + makeMockBreakpoint, + makeMockSource, + mockcx, +} from "../../../../utils/test-mockup"; + +jest.mock("../../../../context-menu/menu"); + +function render(disabled = false) { + const props = generateDefaults(disabled); + const component = shallow(<BreakpointsContextMenu {...props} />); + return { component, props }; +} + +function generateDefaults(disabled) { + const source = makeMockSource( + "https://example.com/main.js", + "source-https://example.com/main.js" + ); + const breakpoints = [ + { + ...makeMockBreakpoint(source, 1), + id: "https://example.com/main.js:1:", + disabled, + options: { + condition: "", + logValue: "", + hidden: false, + }, + }, + { + ...makeMockBreakpoint(source, 2), + id: "https://example.com/main.js:2:", + disabled, + options: { + hidden: false, + }, + }, + { + ...makeMockBreakpoint(source, 3), + id: "https://example.com/main.js:3:", + disabled, + }, + ]; + + const props = { + cx: mockcx, + breakpoints, + breakpoint: breakpoints[0], + removeBreakpoint: jest.fn(), + removeBreakpoints: jest.fn(), + removeAllBreakpoints: jest.fn(), + toggleBreakpoints: jest.fn(), + toggleAllBreakpoints: jest.fn(), + toggleDisabledBreakpoint: jest.fn(), + selectSpecificLocation: jest.fn(), + setBreakpointCondition: jest.fn(), + openConditionalPanel: jest.fn(), + contextMenuEvent: { preventDefault: jest.fn() }, + selectedSource: makeMockSource(), + setBreakpointOptions: jest.fn(), + checkSourceOnIgnoreList: jest.fn(), + }; + return props; +} + +describe("BreakpointsContextMenu", () => { + afterEach(() => { + buildMenu.mockReset(); + }); + + describe("context menu actions affecting other breakpoints", () => { + it("'remove others' calls removeBreakpoints with proper arguments", () => { + const { props } = render(); + const menuItems = buildMenu.mock.calls[0][0]; + const deleteOthers = menuItems.find( + item => item.item.id === "node-menu-delete-other" + ); + deleteOthers.item.click(); + + expect(props.removeBreakpoints).toHaveBeenCalled(); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.removeBreakpoints.mock.calls[0][1]).toEqual( + otherBreakpoints + ); + }); + + it("'enable others' calls toggleBreakpoints with proper arguments", () => { + const { props } = render(true); + const menuItems = buildMenu.mock.calls[0][0]; + const enableOthers = menuItems.find( + item => item.item.id === "node-menu-enable-others" + ); + enableOthers.item.click(); + + expect(props.toggleBreakpoints).toHaveBeenCalled(); + + expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(false); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual( + otherBreakpoints + ); + }); + + it("'disable others' calls toggleBreakpoints with proper arguments", () => { + const { props } = render(); + const menuItems = buildMenu.mock.calls[0][0]; + const disableOthers = menuItems.find( + item => item.item.id === "node-menu-disable-others" + ); + disableOthers.item.click(); + + expect(props.toggleBreakpoints).toHaveBeenCalled(); + expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(true); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual( + otherBreakpoints + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js new file mode 100644 index 0000000000..238551cc10 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.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 React from "react"; +import { shallow } from "enzyme"; + +import ExceptionOption from "../ExceptionOption"; + +describe("ExceptionOption renders", () => { + it("with values", () => { + const component = shallow( + <ExceptionOption + label="testLabel" + isChecked={true} + onChange={() => null} + className="testClassName" + /> + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap new file mode 100644 index 0000000000..45f44e42f7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breakpoint disabled 1`] = ` +<div + className="breakpoint disabled" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={false} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at a different 1`] = ` +<div + className="breakpoint" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at a generatedLocation 1`] = ` +<div + className="breakpoint paused" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at an original location 1`] = ` +<div + className="breakpoint paused" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 5:7 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint simple 1`] = ` +<div + className="breakpoint" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap new file mode 100644 index 0000000000..19b5937676 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionOption renders with values 1`] = ` +<div + className="testClassName" + onClick={[Function]} +> + <input + checked="checked" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + testLabel + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css new file mode 100644 index 0000000000..68bd0bfcdd --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.command-bar { + flex: 0 0 29px; + border-bottom: 1px solid var(--theme-splitter-color); + display: flex; + overflow: hidden; + z-index: 1; + background-color: var(--theme-toolbar-background); +} + +html[dir="rtl"] .command-bar { + border-right: 1px solid var(--theme-splitter-color); +} + +.command-bar .filler { + flex-grow: 1; +} + +.command-bar .step-position { + color: var(--theme-text-color-inactive); + padding-top: 8px; + margin-inline-end: 4px; +} + +.command-bar .divider { + width: 1px; + background: var(--theme-splitter-color); + height: 10px; + margin: 11px 6px 0 6px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js new file mode 100644 index 0000000000..a8f4173924 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js @@ -0,0 +1,433 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; +import { features, prefs } from "../../utils/prefs"; +import { + getIsWaitingOnBreak, + getSkipPausing, + getCurrentThread, + isTopFrameSelected, + getThreadContext, + getIsCurrentThreadPaused, + getIsThreadCurrentlyTracing, + getJavascriptTracingLogMethod, +} from "../../selectors"; +import { formatKeyShortcut } from "../../utils/text"; +import actions from "../../actions"; +import { debugBtn } from "../shared/Button/CommandBarButton"; +import AccessibleImage from "../shared/AccessibleImage"; +import "./CommandBar.css"; +import { showMenu } from "../../context-menu/menu"; + +const classnames = require("devtools/client/shared/classnames.js"); +const MenuButton = require("devtools/client/shared/components/menu/MenuButton"); +const MenuItem = require("devtools/client/shared/components/menu/MenuItem"); +const MenuList = require("devtools/client/shared/components/menu/MenuList"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +// NOTE: the "resume" command will call either the resume or breakOnNext action +// depending on whether or not the debugger is paused or running +const COMMANDS = ["resume", "stepOver", "stepIn", "stepOut"]; + +const KEYS = { + WINNT: { + resume: "F8", + stepOver: "F10", + stepIn: "F11", + stepOut: "Shift+F11", + }, + Darwin: { + resume: "Cmd+\\", + stepOver: "Cmd+'", + stepIn: "Cmd+;", + stepOut: "Cmd+Shift+:", + stepOutDisplay: "Cmd+Shift+;", + }, + Linux: { + resume: "F8", + stepOver: "F10", + stepIn: "F11", + stepOut: "Shift+F11", + }, +}; + +const LOG_METHODS = { + CONSOLE: "console", + STDOUT: "stdout", +}; + +function getKey(action) { + return getKeyForOS(Services.appinfo.OS, action); +} + +function getKeyForOS(os, action) { + const osActions = KEYS[os] || KEYS.Linux; + return osActions[action]; +} + +function formatKey(action) { + const key = getKey(`${action}Display`) || getKey(action); + if (isMacOS) { + const winKey = + getKeyForOS("WINNT", `${action}Display`) || getKeyForOS("WINNT", action); + // display both Windows type and Mac specific keys + return formatKeyShortcut([key, winKey].join(" ")); + } + return formatKeyShortcut(key); +} + +class CommandBar extends Component { + constructor() { + super(); + + this.state = {}; + } + static get propTypes() { + return { + breakOnNext: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + horizontal: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + isTracingEnabled: PropTypes.bool.isRequired, + isWaitingOnBreak: PropTypes.bool.isRequired, + javascriptEnabled: PropTypes.bool.isRequired, + trace: PropTypes.func.isRequired, + resume: PropTypes.func.isRequired, + skipPausing: PropTypes.bool.isRequired, + stepIn: PropTypes.func.isRequired, + stepOut: PropTypes.func.isRequired, + stepOver: PropTypes.func.isRequired, + toggleEditorWrapping: PropTypes.func.isRequired, + toggleInlinePreview: PropTypes.func.isRequired, + toggleJavaScriptEnabled: PropTypes.func.isRequired, + toggleSkipPausing: PropTypes.any.isRequired, + toggleSourceMapsEnabled: PropTypes.func.isRequired, + topFrameSelected: PropTypes.bool.isRequired, + toggleTracing: PropTypes.func.isRequired, + logMethod: PropTypes.string.isRequired, + setJavascriptTracingLogMethod: PropTypes.func.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + toggleSourceMapIgnoreList: PropTypes.func.isRequired, + }; + } + + componentWillUnmount() { + const { shortcuts } = this.context; + + COMMANDS.forEach(action => shortcuts.off(getKey(action))); + + if (isMacOS) { + COMMANDS.forEach(action => shortcuts.off(getKeyForOS("WINNT", action))); + } + } + + componentDidMount() { + const { shortcuts } = this.context; + + COMMANDS.forEach(action => + shortcuts.on(getKey(action), e => this.handleEvent(e, action)) + ); + + if (isMacOS) { + // The Mac supports both the Windows Function keys + // as well as the Mac non-Function keys + COMMANDS.forEach(action => + shortcuts.on(getKeyForOS("WINNT", action), e => + this.handleEvent(e, action) + ) + ); + } + } + + handleEvent(e, action) { + const { cx } = this.props; + e.preventDefault(); + e.stopPropagation(); + if (action === "resume") { + this.props.isPaused ? this.props.resume() : this.props.breakOnNext(cx); + } else { + this.props[action](cx); + } + } + + renderStepButtons() { + const { isPaused, topFrameSelected } = this.props; + const className = isPaused ? "active" : "disabled"; + const isDisabled = !isPaused; + + return [ + this.renderTraceButton(), + this.renderPauseButton(), + debugBtn( + () => this.props.stepOver(), + "stepOver", + className, + L10N.getFormatStr("stepOverTooltip", formatKey("stepOver")), + isDisabled + ), + debugBtn( + () => this.props.stepIn(), + "stepIn", + className, + L10N.getFormatStr("stepInTooltip", formatKey("stepIn")), + isDisabled || !topFrameSelected + ), + debugBtn( + () => this.props.stepOut(), + "stepOut", + className, + L10N.getFormatStr("stepOutTooltip", formatKey("stepOut")), + isDisabled + ), + ]; + } + + resume() { + this.props.resume(); + } + + renderTraceButton() { + if (!features.javascriptTracing) { + return null; + } + // Display a button which: + // - on left click, would toggle on/off javascript tracing + // - on right click, would display a context menu allowing to choose the loggin output (console or stdout) + return ( + <button + className={`devtools-button command-bar-button debugger-trace-menu-button ${ + this.props.isTracingEnabled ? "active" : "" + }`} + title={ + this.props.isTracingEnabled + ? L10N.getStr("stopTraceButtonTooltip") + : L10N.getFormatStr("startTraceButtonTooltip", this.props.logMethod) + } + onClick={event => { + this.props.toggleTracing(this.props.logMethod); + }} + onContextMenu={event => { + event.preventDefault(); + event.stopPropagation(); + + // Avoid showing the menu to avoid having to support chaging tracing config "live" + if (this.props.isTracingEnabled) { + return; + } + + const items = [ + { + id: "debugger-trace-menu-item-console", + label: L10N.getStr("traceInWebConsole"), + checked: this.props.logMethod == LOG_METHODS.CONSOLE, + click: () => { + this.props.setJavascriptTracingLogMethod(LOG_METHODS.CONSOLE); + }, + }, + { + id: "debugger-trace-menu-item-stdout", + label: L10N.getStr("traceInStdout"), + checked: this.props.logMethod == LOG_METHODS.STDOUT, + click: () => { + this.props.setJavascriptTracingLogMethod(LOG_METHODS.STDOUT); + }, + }, + ]; + showMenu(event, items); + }} + /> + ); + } + + renderPauseButton() { + const { cx, breakOnNext, isWaitingOnBreak } = this.props; + + if (this.props.isPaused) { + return debugBtn( + () => this.resume(), + "resume", + "active", + L10N.getFormatStr("resumeButtonTooltip", formatKey("resume")) + ); + } + + if (isWaitingOnBreak) { + return debugBtn( + null, + "pause", + "disabled", + L10N.getStr("pausePendingButtonTooltip"), + true + ); + } + + return debugBtn( + () => breakOnNext(cx), + "pause", + "active", + L10N.getFormatStr("pauseButtonTooltip", formatKey("resume")) + ); + } + + renderSkipPausingButton() { + const { skipPausing, toggleSkipPausing } = this.props; + + return ( + <button + className={classnames( + "command-bar-button", + "command-bar-skip-pausing", + { + active: skipPausing, + } + )} + title={ + skipPausing + ? L10N.getStr("undoSkipPausingTooltip.label") + : L10N.getStr("skipPausingTooltip.label") + } + onClick={toggleSkipPausing} + > + <AccessibleImage + className={skipPausing ? "enable-pausing" : "disable-pausing"} + /> + </button> + ); + } + + renderSettingsButton() { + const { toolboxDoc } = this.context; + + return ( + <MenuButton + menuId="debugger-settings-menu-button" + toolboxDoc={toolboxDoc} + className="devtools-button command-bar-button debugger-settings-menu-button" + title={L10N.getStr("settings.button.label")} + > + {() => this.renderSettingsMenuItems()} + </MenuButton> + ); + } + + renderSettingsMenuItems() { + return ( + <MenuList id="debugger-settings-menu-list"> + <MenuItem + key="debugger-settings-menu-item-disable-javascript" + className="menu-item debugger-settings-menu-item-disable-javascript" + checked={!this.props.javascriptEnabled} + label={L10N.getStr("settings.disableJavaScript.label")} + tooltip={L10N.getStr("settings.disableJavaScript.tooltip")} + onClick={() => { + this.props.toggleJavaScriptEnabled(!this.props.javascriptEnabled); + }} + /> + <MenuItem + key="debugger-settings-menu-item-disable-inline-previews" + checked={features.inlinePreview} + label={L10N.getStr("inlinePreview.toggle.label")} + tooltip={L10N.getStr("inlinePreview.toggle.tooltip")} + onClick={() => + this.props.toggleInlinePreview(!features.inlinePreview) + } + /> + <MenuItem + key="debugger-settings-menu-item-disable-wrap-lines" + checked={prefs.editorWrapping} + label={L10N.getStr("editorWrapping.toggle.label")} + tooltip={L10N.getStr("editorWrapping.toggle.tooltip")} + onClick={() => this.props.toggleEditorWrapping(!prefs.editorWrapping)} + /> + <MenuItem + key="debugger-settings-menu-item-disable-sourcemaps" + checked={prefs.clientSourceMapsEnabled} + label={L10N.getStr("settings.toggleSourceMaps.label")} + tooltip={L10N.getStr("settings.toggleSourceMaps.tooltip")} + onClick={() => + this.props.toggleSourceMapsEnabled(!prefs.clientSourceMapsEnabled) + } + /> + <MenuItem + key="debugger-settings-menu-item-hide-ignored-sources" + className="menu-item debugger-settings-menu-item-hide-ignored-sources" + checked={prefs.hideIgnoredSources} + label={L10N.getStr("settings.hideIgnoredSources.label")} + tooltip={L10N.getStr("settings.hideIgnoredSources.tooltip")} + onClick={() => + this.props.setHideOrShowIgnoredSources(!prefs.hideIgnoredSources) + } + /> + <MenuItem + key="debugger-settings-menu-item-enable-sourcemap-ignore-list" + className="menu-item debugger-settings-menu-item-enable-sourcemap-ignore-list" + checked={prefs.sourceMapIgnoreListEnabled} + label={L10N.getStr("settings.enableSourceMapIgnoreList.label")} + tooltip={L10N.getStr("settings.enableSourceMapIgnoreList.tooltip")} + onClick={() => + this.props.toggleSourceMapIgnoreList( + this.props.cx, + !prefs.sourceMapIgnoreListEnabled + ) + } + /> + </MenuList> + ); + } + + render() { + return ( + <div + className={classnames("command-bar", { + vertical: !this.props.horizontal, + })} + > + {this.renderStepButtons()} + <div className="filler" /> + {this.renderSkipPausingButton()} + <div className="devtools-separator" /> + {this.renderSettingsButton()} + </div> + ); + } +} + +CommandBar.contextTypes = { + shortcuts: PropTypes.object, + toolboxDoc: PropTypes.object, +}; + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + isWaitingOnBreak: getIsWaitingOnBreak(state, getCurrentThread(state)), + skipPausing: getSkipPausing(state), + topFrameSelected: isTopFrameSelected(state, getCurrentThread(state)), + javascriptEnabled: state.ui.javascriptEnabled, + isPaused: getIsCurrentThreadPaused(state), + isTracingEnabled: getIsThreadCurrentlyTracing(state, getCurrentThread(state)), + logMethod: getJavascriptTracingLogMethod(state), +}); + +export default connect(mapStateToProps, { + toggleTracing: actions.toggleTracing, + setJavascriptTracingLogMethod: actions.setJavascriptTracingLogMethod, + resume: actions.resume, + stepIn: actions.stepIn, + stepOut: actions.stepOut, + stepOver: actions.stepOver, + breakOnNext: actions.breakOnNext, + pauseOnExceptions: actions.pauseOnExceptions, + toggleSkipPausing: actions.toggleSkipPausing, + toggleInlinePreview: actions.toggleInlinePreview, + toggleEditorWrapping: actions.toggleEditorWrapping, + toggleSourceMapsEnabled: actions.toggleSourceMapsEnabled, + toggleJavaScriptEnabled: actions.toggleJavaScriptEnabled, + setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources, + toggleSourceMapIgnoreList: actions.toggleSourceMapIgnoreList, +})(CommandBar); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css new file mode 100644 index 0000000000..b525783984 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + + .dom-mutation-empty { + padding: 6px 20px; + text-align: center; + font-style: italic; + color: var(--theme-body-color); + white-space: normal; + } + + .dom-mutation-empty a { + text-decoration: underline; + color: var(--theme-toolbar-selected-color); + cursor: pointer; + } + +.dom-mutation-list * { + user-select: none; +} + +.dom-mutation-list { + padding: 4px 0; + list-style-type: none; +} + +.dom-mutation-list li { + position: relative; + + display: flex; + align-items: start; + overflow: hidden; + padding-top: 2px; + padding-bottom: 2px; + padding-inline-start: 20px; + padding-inline-end: 12px; +} + +.dom-mutation-list input { + margin: 2px 3px; + + padding-inline-start: 2px; + margin-top: 0px; + margin-bottom: 0px; + margin-inline-start: 0; + margin-inline-end: 2px; + vertical-align: text-bottom; +} + +.dom-mutation-info { + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + margin-inline-end: 20px; +} + +.dom-mutation-list .close-btn { + position: absolute; + /* hide button outside of row until hovered or focused */ + top: -100px; +} + +/* Reveal the remove button on hover/focus */ +.dom-mutation-list li:hover .close-btn, +.dom-mutation-list li .close-btn:focus { + top: calc(50% - 8px); +} + +[dir="ltr"] .dom-mutation-list .close-btn { + right: 12px; +} + +[dir="rtl"] .dom-mutation-list .close-btn { + left: 12px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js new file mode 100644 index 0000000000..375dad5563 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, +} = Reps; +import { translateNodeFrontToGrip } from "inspector-shared-utils"; + +import { + deleteDOMMutationBreakpoint, + toggleDOMMutationBreakpointState, +} from "framework-actions"; + +import actions from "../../actions"; +import { connect } from "../../utils/connect"; + +import { CloseButton } from "../shared/Button"; + +import "./DOMMutationBreakpoints.css"; + +const localizationTerms = { + subtree: L10N.getStr("domMutationTypes.subtree"), + attribute: L10N.getStr("domMutationTypes.attribute"), + removal: L10N.getStr("domMutationTypes.removal"), +}; + +class DOMMutationBreakpointsContents extends Component { + static get propTypes() { + return { + breakpoints: PropTypes.array.isRequired, + deleteBreakpoint: PropTypes.func.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openInspector: PropTypes.func.isRequired, + setSkipPausing: PropTypes.func.isRequired, + toggleBreakpoint: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + handleBreakpoint(breakpointId, shouldEnable) { + const { toggleBreakpoint, setSkipPausing } = this.props; + + // The user has enabled a mutation breakpoint so we should no + // longer skip pausing + if (shouldEnable) { + setSkipPausing(false); + } + toggleBreakpoint(breakpointId, shouldEnable); + } + + renderItem(breakpoint) { + const { + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + deleteBreakpoint, + } = this.props; + const { enabled, id: breakpointId, nodeFront, mutationType } = breakpoint; + + return ( + <li key={breakpoint.id}> + <input + type="checkbox" + checked={enabled} + onChange={() => this.handleBreakpoint(breakpointId, !enabled)} + /> + <div className="dom-mutation-info"> + <div className="dom-mutation-label"> + {Rep({ + object: translateNodeFrontToGrip(nodeFront), + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(nodeFront), + onInspectIconClick: () => openElementInInspector(nodeFront), + onDOMNodeMouseOver: () => highlightDomElement(nodeFront), + onDOMNodeMouseOut: () => unHighlightDomElement(), + })} + </div> + <div className="dom-mutation-type"> + {localizationTerms[mutationType] || mutationType} + </div> + </div> + <CloseButton + handleClick={() => deleteBreakpoint(nodeFront, mutationType)} + /> + </li> + ); + } + + /* eslint-disable react/no-danger */ + renderEmpty() { + const { openInspector } = this.props; + const text = L10N.getFormatStr( + "noDomMutationBreakpoints", + `<a>${L10N.getStr("inspectorTool")}</a>` + ); + + return ( + <div className="dom-mutation-empty"> + <div + onClick={() => openInspector()} + dangerouslySetInnerHTML={{ __html: text }} + /> + </div> + ); + } + + render() { + const { breakpoints } = this.props; + + if (breakpoints.length === 0) { + return this.renderEmpty(); + } + + return ( + <ul className="dom-mutation-list"> + {breakpoints.map(breakpoint => this.renderItem(breakpoint))} + </ul> + ); + } +} + +const mapStateToProps = state => ({ + breakpoints: state.domMutationBreakpoints.breakpoints, +}); + +const DOMMutationBreakpointsPanel = connect( + mapStateToProps, + { + deleteBreakpoint: deleteDOMMutationBreakpoint, + toggleBreakpoint: toggleDOMMutationBreakpointState, + }, + undefined, + { storeKey: "toolbox-store" } +)(DOMMutationBreakpointsContents); + +class DomMutationBreakpoints extends Component { + static get propTypes() { + return { + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openInspector: PropTypes.func.isRequired, + setSkipPausing: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + render() { + return ( + <DOMMutationBreakpointsPanel + openElementInInspector={this.props.openElementInInspector} + highlightDomElement={this.props.highlightDomElement} + unHighlightDomElement={this.props.unHighlightDomElement} + setSkipPausing={this.props.setSkipPausing} + openInspector={this.props.openInspector} + /> + ); + } +} + +export default connect(undefined, { + // the debugger-specific action bound to the debugger store + // since there is no `storeKey` + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, + setSkipPausing: actions.setSkipPausing, + openInspector: actions.openInspector, +})(DomMutationBreakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css new file mode 100644 index 0000000000..2ca0670367 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.event-listeners-content { + padding-block: 4px; +} + +.event-listeners-content ul { + padding: 0; + list-style-type: none; +} + +.event-listeners-content button:hover, +.event-listeners-content button:focus { + background: none; +} + +.event-listener-group { + user-select: none; +} + +.event-listener-header { + display: flex; + align-items: center; +} + +.event-listener-expand { + border: none; + background: none; + padding: 4px 5px; + line-height: 12px; +} + +.event-listener-expand:hover { + background: transparent; +} + +.event-listener-group input[type="checkbox"] { + margin: 0; + margin-inline-end: 4px; +} + +.event-listener-label { + display: flex; + align-items: center; + padding-inline-end: 10px; +} + +.event-listener-category { + padding: 3px 0; + line-height: 14px; +} + +.event-listeners-content .arrow { + margin-inline-end: 0; +} + +.event-listeners-content .arrow.expanded { + transform: rotate(0deg); +} + +.event-listeners-content .arrow.expanded:dir(rtl) { + transform: rotate(90deg); +} + +.event-listeners-list { + border-block-start: 1px; + padding-inline: 18px 20px; +} + +.event-listener-event { + display: flex; + align-items: center; +} + +.event-listeners-list .event-listener-event { + margin-inline-start: 40px; +} + +.event-search-results-list .event-listener-event { + padding-inline: 20px; +} + +.event-listener-name { + line-height: 14px; + padding: 3px 0; +} + +.event-listener-event input { + margin-inline: 0 4px; + margin-block: 0; +} + +.event-search-container { + display: flex; + border: 1px solid transparent; + border-block-end: 1px solid var(--theme-splitter-color); +} + +.event-search-form { + display: flex; + flex-grow: 1; +} + +.event-search-input { + flex-grow: 1; + margin: 0; + font-size: inherit; + background-color: var(--theme-sidebar-background); + border: 0; + outline: 0; + height: 24px; + color: var(--theme-body-color); + background-image: url("chrome://devtools/skin/images/filter-small.svg"); + background-position-x: 4px; + background-position-y: 50%; + background-repeat: no-repeat; + background-size: 12px; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); + text-align: match-parent; +} + +:root:dir(ltr) .event-search-input { + /* Be explicit about left/right direction to prevent the text/placeholder + * from overlapping the background image when the user changes the text + * direction manually (e.g. via Ctrl+Shift). */ + padding-left: 19px; + padding-right: 12px; +} + +:root:dir(rtl) .event-search-input { + background-position-x: right 4px; + padding-right: 19px; + padding-left: 12px; +} + +.category-label { + color: var(--theme-comment); +} + +.event-search-input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.event-search-container:focus-within { + border: 1px solid var(--theme-highlight-blue); +} + +.devtools-searchinput-clear { + margin-inline-end: 8px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js new file mode 100644 index 0000000000..8b7c9975b0 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js @@ -0,0 +1,295 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; +import actions from "../../actions"; +import { + getActiveEventListeners, + getEventListenerBreakpointTypes, + getEventListenerExpanded, +} from "../../selectors"; + +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./EventListeners.css"; + +class EventListeners extends Component { + state = { + searchText: "", + focused: false, + }; + + static get propTypes() { + return { + activeEventListeners: PropTypes.array.isRequired, + addEventListenerExpanded: PropTypes.func.isRequired, + addEventListeners: PropTypes.func.isRequired, + categories: PropTypes.array.isRequired, + expandedCategories: PropTypes.array.isRequired, + removeEventListenerExpanded: PropTypes.func.isRequired, + removeEventListeners: PropTypes.func.isRequired, + }; + } + + hasMatch(eventOrCategoryName, searchText) { + const lowercaseEventOrCategoryName = eventOrCategoryName.toLowerCase(); + const lowercaseSearchText = searchText.toLowerCase(); + + return lowercaseEventOrCategoryName.includes(lowercaseSearchText); + } + + getSearchResults() { + const { searchText } = this.state; + const { categories } = this.props; + const searchResults = categories.reduce((results, cat, index) => { + const category = categories[index]; + + if (this.hasMatch(category.name, searchText)) { + results[category.name] = category.events; + } else { + results[category.name] = category.events.filter(event => + this.hasMatch(event.name, searchText) + ); + } + + return results; + }, {}); + + return searchResults; + } + + onCategoryToggle(category) { + const { + expandedCategories, + removeEventListenerExpanded, + addEventListenerExpanded, + } = this.props; + + if (expandedCategories.includes(category)) { + removeEventListenerExpanded(category); + } else { + addEventListenerExpanded(category); + } + } + + onCategoryClick(category, isChecked) { + const { addEventListeners, removeEventListeners } = this.props; + const eventsIds = category.events.map(event => event.id); + + if (isChecked) { + addEventListeners(eventsIds); + } else { + removeEventListeners(eventsIds); + } + } + + onEventTypeClick(eventId, isChecked) { + const { addEventListeners, removeEventListeners } = this.props; + if (isChecked) { + addEventListeners([eventId]); + } else { + removeEventListeners([eventId]); + } + } + + onInputChange = event => { + this.setState({ searchText: event.currentTarget.value }); + }; + + onKeyDown = event => { + if (event.key === "Escape") { + this.setState({ searchText: "" }); + } + }; + + onFocus = event => { + this.setState({ focused: true }); + }; + + onBlur = event => { + this.setState({ focused: false }); + }; + + renderSearchInput() { + const { focused, searchText } = this.state; + const placeholder = L10N.getStr("eventListenersHeader1.placeholder"); + + return ( + <form className="event-search-form" onSubmit={e => e.preventDefault()}> + <input + className={classnames("event-search-input", { focused })} + placeholder={placeholder} + value={searchText} + onChange={this.onInputChange} + onKeyDown={this.onKeyDown} + onFocus={this.onFocus} + onBlur={this.onBlur} + /> + </form> + ); + } + + renderClearSearchButton() { + const { searchText } = this.state; + + if (!searchText) { + return null; + } + + return ( + <button + onClick={() => this.setState({ searchText: "" })} + className="devtools-searchinput-clear" + /> + ); + } + + renderCategoriesList() { + const { categories } = this.props; + + return ( + <ul className="event-listeners-list"> + {categories.map((category, index) => { + return ( + <li className="event-listener-group" key={index}> + {this.renderCategoryHeading(category)} + {this.renderCategoryListing(category)} + </li> + ); + })} + </ul> + ); + } + + renderSearchResultsList() { + const searchResults = this.getSearchResults(); + + return ( + <ul className="event-search-results-list"> + {Object.keys(searchResults).map(category => { + return searchResults[category].map(event => { + return this.renderListenerEvent(event, category); + }); + })} + </ul> + ); + } + + renderCategoryHeading(category) { + const { activeEventListeners, expandedCategories } = this.props; + const { events } = category; + + const expanded = expandedCategories.includes(category.name); + const checked = events.every(({ id }) => activeEventListeners.includes(id)); + const indeterminate = + !checked && events.some(({ id }) => activeEventListeners.includes(id)); + + return ( + <div className="event-listener-header"> + <button + className="event-listener-expand" + onClick={() => this.onCategoryToggle(category.name)} + > + <AccessibleImage className={classnames("arrow", { expanded })} /> + </button> + <label className="event-listener-label"> + <input + type="checkbox" + value={category.name} + onChange={e => { + this.onCategoryClick( + category, + // Clicking an indeterminate checkbox should always have the + // effect of disabling any selected items. + indeterminate ? false : e.target.checked + ); + }} + checked={checked} + ref={el => el && (el.indeterminate = indeterminate)} + /> + <span className="event-listener-category">{category.name}</span> + </label> + </div> + ); + } + + renderCategoryListing(category) { + const { expandedCategories } = this.props; + + const expanded = expandedCategories.includes(category.name); + if (!expanded) { + return null; + } + + return ( + <ul> + {category.events.map(event => { + return this.renderListenerEvent(event, category.name); + })} + </ul> + ); + } + + renderCategory(category) { + return <span className="category-label">{category} ▸ </span>; + } + + renderListenerEvent(event, category) { + const { activeEventListeners } = this.props; + const { searchText } = this.state; + + return ( + <li className="event-listener-event" key={event.id}> + <label className="event-listener-label"> + <input + type="checkbox" + value={event.id} + onChange={e => this.onEventTypeClick(event.id, e.target.checked)} + checked={activeEventListeners.includes(event.id)} + /> + <span className="event-listener-name"> + {searchText ? this.renderCategory(category) : null} + {event.name} + </span> + </label> + </li> + ); + } + + render() { + const { searchText } = this.state; + + return ( + <div className="event-listeners"> + <div className="event-search-container"> + {this.renderSearchInput()} + {this.renderClearSearchButton()} + </div> + <div className="event-listeners-content"> + {searchText + ? this.renderSearchResultsList() + : this.renderCategoriesList()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => ({ + activeEventListeners: getActiveEventListeners(state), + categories: getEventListenerBreakpointTypes(state), + expandedCategories: getEventListenerExpanded(state), +}); + +export default connect(mapStateToProps, { + addEventListeners: actions.addEventListenerBreakpoints, + removeEventListeners: actions.removeEventListenerBreakpoints, + addEventListenerExpanded: actions.addEventListenerExpanded, + removeEventListenerExpanded: actions.removeEventListenerExpanded, +})(EventListeners); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css new file mode 100644 index 0000000000..c4291c80ff --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css @@ -0,0 +1,175 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.expression-input-form { + width: 100%; +} + +.input-expression { + width: 100%; + margin: 0; + font-size: inherit; + border: 1px; + background-color: var(--theme-sidebar-background); + height: 24px; + padding-inline-start: 19px; + padding-inline-end: 12px; + color: var(--theme-body-color); + outline: 0; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-10px); + } + 40%, + 80% { + transform: translateX(10px); + } +} + +.input-expression::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.input-expression:focus { + cursor: text; +} + +.expressions-list .expression-input-container { + height: var(--expression-item-height); +} + +.expressions-list .input-expression { + /* Prevent vertical bounce when editing an existing Watch Expression */ + height: 100%; +} + +.expressions-list { + /* TODO: add normalize */ + margin: 0; + padding: 4px 0px; + overflow-x: auto; +} + +.expression-input-container { + display: flex; + border: 1px solid transparent; +} + +.expression-input-container.focused { + border: 1px solid var(--theme-highlight-blue); +} + +:root.theme-dark .expression-input-container.focused { + border: 1px solid var(--blue-50); +} + +.expression-input-container.error { + border: 1px solid red; +} + +.expression-container { + padding-top: 3px; + padding-bottom: 3px; + padding-inline-start: 20px; + padding-inline-end: 12px; + width: 100%; + color: var(--theme-body-color); + background-color: var(--theme-body-background); + display: block; + position: relative; + overflow: hidden; +} + +.expression-container > .tree { + width: 100%; + overflow: hidden; +} + +.expression-container .tree .tree-node[aria-level="1"] { + padding-top: 0px; + /* keep line-height at 14px to prevent row from shifting upon expansion */ + line-height: 14px; +} + +.expression-container .tree-node[aria-level="1"] .object-label { + font-family: var(--monospace-font-family); +} + +:root.theme-light .expression-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +:root.theme-dark .expression-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +.tree .tree-node:not(.focused):hover { + background-color: transparent; +} + +.expression-container__close-btn { + position: absolute; + /* hiding button outside of row until hovered or focused */ + top: -100px; +} + +.expression-container:hover .expression-container__close-btn, +.expression-container:focus-within .expression-container__close-btn, +.expression-container__close-btn:focus-within { + top: 0; +} + +.expression-content .object-node { + padding-inline-start: 0px; + cursor: default; +} + +.expressions-list .tree.object-inspector .node.object-node { + max-width: calc(100% - 20px); + min-width: 0; + text-overflow: ellipsis; + overflow: hidden; +} + +.expression-container__close-btn { + max-height: 16px; + padding-inline-start: 4px; +} + +[dir="ltr"] .expression-container__close-btn { + right: 0; +} + +[dir="rtl"] .expression-container__close-btn { + left: 0; +} + +.expression-content { + display: flex; + align-items: center; + flex-grow: 1; + position: relative; +} + +.expression-content .tree { + overflow: hidden; + flex-grow: 1; + line-height: 15px; +} + +.expression-content .tree-node[data-expandable="false"][aria-level="1"] { + padding-inline-start: 0px; +} + +.input-expression:not(:placeholder-shown) { + font-family: var(--monospace-font-family); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js new file mode 100644 index 0000000000..308e6d4de5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { features } from "../../utils/prefs"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +import actions from "../../actions"; +import { + getExpressions, + getExpressionError, + getAutocompleteMatchset, + getThreadContext, +} from "../../selectors"; +import { getExpressionResultGripAndFront } from "../../utils/expressions"; + +import { CloseButton } from "../shared/Button"; + +import "./Expressions.css"; + +const { debounce } = require("devtools/shared/debounce"); +const classnames = require("devtools/client/shared/classnames.js"); + +const { ObjectInspector } = objectInspector; + +class Expressions extends Component { + constructor(props) { + super(props); + + this.state = { + editing: false, + editIndex: -1, + inputValue: "", + focused: false, + }; + } + + static get propTypes() { + return { + addExpression: PropTypes.func.isRequired, + autocomplete: PropTypes.func.isRequired, + autocompleteMatches: PropTypes.array, + clearAutocomplete: PropTypes.func.isRequired, + clearExpressionError: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + deleteExpression: PropTypes.func.isRequired, + expressionError: PropTypes.bool.isRequired, + expressions: PropTypes.array.isRequired, + highlightDomElement: PropTypes.func.isRequired, + onExpressionAdded: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.any.isRequired, + showInput: PropTypes.bool.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + updateExpression: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + const { showInput } = this.props; + + // Ensures that the input is focused when the "+" + // is clicked while the panel is collapsed + if (showInput && this._input) { + this._input.focus(); + } + } + + clear = () => { + this.setState(() => { + this.props.clearExpressionError(); + return { editing: false, editIndex: -1, inputValue: "", focused: false }; + }); + }; + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.state.editing && !nextProps.expressionError) { + this.clear(); + } + + // Ensures that the add watch expression input + // is no longer visible when the new watch expression is rendered + if (this.props.expressions.length < nextProps.expressions.length) { + this.hideInput(); + } + } + + shouldComponentUpdate(nextProps, nextState) { + const { editing, inputValue, focused } = this.state; + const { expressions, expressionError, showInput, autocompleteMatches } = + this.props; + + return ( + autocompleteMatches !== nextProps.autocompleteMatches || + expressions !== nextProps.expressions || + expressionError !== nextProps.expressionError || + editing !== nextState.editing || + inputValue !== nextState.inputValue || + nextProps.showInput !== showInput || + focused !== nextState.focused + ); + } + + componentDidUpdate(prevProps, prevState) { + const input = this._input; + + if (!input) { + return; + } + + if (!prevState.editing && this.state.editing) { + input.setSelectionRange(0, input.value.length); + input.focus(); + } else if (this.props.showInput && !this.state.focused) { + input.focus(); + } + } + + editExpression(expression, index) { + this.setState({ + inputValue: expression.input, + editing: true, + editIndex: index, + }); + } + + deleteExpression(e, expression) { + e.stopPropagation(); + const { deleteExpression } = this.props; + deleteExpression(expression); + } + + handleChange = e => { + const { target } = e; + if (features.autocompleteExpression) { + this.findAutocompleteMatches(target.value, target.selectionStart); + } + this.setState({ inputValue: target.value }); + }; + + findAutocompleteMatches = debounce((value, selectionStart) => { + const { autocomplete } = this.props; + autocomplete(this.props.cx, value, selectionStart); + }, 250); + + handleKeyDown = e => { + if (e.key === "Escape") { + this.clear(); + } + }; + + hideInput = () => { + this.setState({ focused: false }); + this.props.onExpressionAdded(); + this.props.clearExpressionError(); + }; + + createElement = element => { + return document.createElement(element); + }; + + onFocus = () => { + this.setState({ focused: true }); + }; + + onBlur() { + this.clear(); + this.hideInput(); + } + + handleExistingSubmit = async (e, expression) => { + e.preventDefault(); + e.stopPropagation(); + + this.props.updateExpression( + this.props.cx, + this.state.inputValue, + expression + ); + }; + + handleNewSubmit = async e => { + const { inputValue } = this.state; + e.preventDefault(); + e.stopPropagation(); + + this.props.clearExpressionError(); + await this.props.addExpression(this.props.cx, this.state.inputValue); + this.setState({ + editing: false, + editIndex: -1, + inputValue: this.props.expressionError ? inputValue : "", + }); + + this.props.clearAutocomplete(); + }; + + renderExpression = (expression, index) => { + const { + expressionError, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const { editing, editIndex } = this.state; + const { input, updating } = expression; + const isEditingExpr = editing && editIndex === index; + if (isEditingExpr || (isEditingExpr && expressionError)) { + return this.renderExpressionEditInput(expression); + } + + if (updating) { + return null; + } + + const { expressionResultGrip, expressionResultFront } = + getExpressionResultGripAndFront(expression); + + const root = { + name: expression.input, + path: input, + contents: { + value: expressionResultGrip, + front: expressionResultFront, + }, + }; + + return ( + <li className="expression-container" key={input} title={expression.input}> + <div className="expression-content"> + <ObjectInspector + roots={[root]} + autoExpandDepth={0} + disableWrap={true} + openLink={openLink} + createElement={this.createElement} + onDoubleClick={(items, { depth }) => { + if (depth === 0) { + this.editExpression(expression, index); + } + }} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + shouldRenderTooltip={true} + mayUseCustomFormatter={true} + /> + <div className="expression-container__close-btn"> + <CloseButton + handleClick={e => this.deleteExpression(e, expression)} + tooltip={L10N.getStr("expressions.remove.tooltip")} + /> + </div> + </div> + </li> + ); + }; + + renderExpressions() { + const { expressions, showInput } = this.props; + + return ( + <> + <ul className="pane expressions-list"> + {expressions.map(this.renderExpression)} + </ul> + {showInput && this.renderNewExpressionInput()} + </> + ); + } + + renderAutoCompleteMatches() { + if (!features.autocompleteExpression) { + return null; + } + const { autocompleteMatches } = this.props; + if (autocompleteMatches) { + return ( + <datalist id="autocomplete-matches"> + {autocompleteMatches.map((match, index) => { + return <option key={index} value={match} />; + })} + </datalist> + ); + } + return <datalist id="autocomplete-matches" />; + } + + renderNewExpressionInput() { + const { expressionError } = this.props; + const { editing, inputValue, focused } = this.state; + const error = editing === false && expressionError === true; + const placeholder = error + ? L10N.getStr("expressions.errorMsg") + : L10N.getStr("expressions.placeholder"); + + return ( + <form + className={classnames( + "expression-input-container expression-input-form", + { focused, error } + )} + onSubmit={this.handleNewSubmit} + > + <input + className="input-expression" + type="text" + placeholder={placeholder} + onChange={this.handleChange} + onBlur={this.hideInput} + onKeyDown={this.handleKeyDown} + onFocus={this.onFocus} + value={!editing ? inputValue : ""} + ref={c => (this._input = c)} + {...(features.autocompleteExpression && { + list: "autocomplete-matches", + })} + /> + {this.renderAutoCompleteMatches()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + renderExpressionEditInput(expression) { + const { expressionError } = this.props; + const { inputValue, editing, focused } = this.state; + const error = editing === true && expressionError === true; + + return ( + <form + key={expression.input} + className={classnames( + "expression-input-container expression-input-form", + { focused, error } + )} + onSubmit={e => this.handleExistingSubmit(e, expression)} + > + <input + className={classnames("input-expression", { error })} + type="text" + onChange={this.handleChange} + onBlur={this.clear} + onKeyDown={this.handleKeyDown} + onFocus={this.onFocus} + value={editing ? inputValue : expression.input} + ref={c => (this._input = c)} + {...(features.autocompleteExpression && { + list: "autocomplete-matches", + })} + /> + {this.renderAutoCompleteMatches()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + render() { + const { expressions } = this.props; + + if (expressions.length === 0) { + return this.renderNewExpressionInput(); + } + + return this.renderExpressions(); + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + autocompleteMatches: getAutocompleteMatchset(state), + expressions: getExpressions(state), + expressionError: getExpressionError(state), +}); + +export default connect(mapStateToProps, { + autocomplete: actions.autocomplete, + clearAutocomplete: actions.clearAutocomplete, + addExpression: actions.addExpression, + clearExpressionError: actions.clearExpressionError, + updateExpression: actions.updateExpression, + deleteExpression: actions.deleteExpression, + openLink: actions.openLink, + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(Expressions); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js new file mode 100644 index 0000000000..4ea94df95d --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component, memo } from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "../../shared/AccessibleImage"; +import { formatDisplayName } from "../../../utils/pause/frames"; +import { getFilename, getFileURL } from "../../../utils/source"; +import FrameMenu from "./FrameMenu"; +import FrameIndent from "./FrameIndent"; +const classnames = require("devtools/client/shared/classnames.js"); + +function FrameTitle({ frame, options = {}, l10n }) { + const displayName = formatDisplayName(frame, options, l10n); + return <span className="title">{displayName}</span>; +} + +FrameTitle.propTypes = { + frame: PropTypes.object.isRequired, + options: PropTypes.object.isRequired, + l10n: PropTypes.object.isRequired, +}; + +const FrameLocation = memo(({ frame, displayFullUrl = false }) => { + if (!frame.source) { + return null; + } + + if (frame.library) { + return ( + <span className="location"> + {frame.library} + <AccessibleImage + className={`annotation-logo ${frame.library.toLowerCase()}`} + /> + </span> + ); + } + + const { location, source } = frame; + const filename = displayFullUrl + ? getFileURL(source, false) + : getFilename(source); + + return ( + <span className="location" title={source.url}> + <span className="filename">{filename}</span>: + <span className="line">{location.line}</span> + </span> + ); +}); + +FrameLocation.displayName = "FrameLocation"; + +FrameLocation.propTypes = { + frame: PropTypes.object.isRequired, + displayFullUrl: PropTypes.bool.isRequired, +}; + +export default class FrameComponent extends Component { + static defaultProps = { + hideLocation: false, + shouldMapDisplayName: true, + disableContextMenu: false, + }; + + static get propTypes() { + return { + copyStackTrace: PropTypes.func.isRequired, + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frame: PropTypes.object.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + hideLocation: PropTypes.bool.isRequired, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectedFrame: PropTypes.object, + shouldMapDisplayName: PropTypes.bool.isRequired, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func.isRequired, + }; + } + + get isSelectable() { + return this.props.panel == "webconsole"; + } + + get isDebugger() { + return this.props.panel == "debugger"; + } + + onContextMenu(event) { + const { + frame, + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + frameworkGroupingOn, + cx, + restart, + } = this.props; + FrameMenu( + frame, + frameworkGroupingOn, + { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, restart }, + event, + cx + ); + } + + onMouseDown(e, frame, selectedFrame) { + if (e.button !== 0) { + return; + } + + this.props.selectFrame(this.props.cx, frame); + } + + onKeyUp(event, frame, selectedFrame) { + if (event.key != "Enter") { + return; + } + + this.props.selectFrame(this.props.cx, frame); + } + + render() { + const { + frame, + selectedFrame, + hideLocation, + shouldMapDisplayName, + displayFullUrl, + getFrameTitle, + disableContextMenu, + } = this.props; + const { l10n } = this.context; + + const className = classnames("frame", { + selected: selectedFrame && selectedFrame.id === frame.id, + }); + + if (!frame.source) { + throw new Error("no frame source"); + } + + const title = getFrameTitle + ? getFrameTitle( + `${getFileURL(frame.source, false)}:${frame.location.line}` + ) + : undefined; + + return ( + <div + role="listitem" + key={frame.id} + className={className} + onMouseDown={e => this.onMouseDown(e, frame, selectedFrame)} + onKeyUp={e => this.onKeyUp(e, frame, selectedFrame)} + onContextMenu={disableContextMenu ? null : e => this.onContextMenu(e)} + tabIndex={0} + title={title} + > + {frame.asyncCause && ( + <span className="location-async-cause"> + {this.isSelectable && <FrameIndent />} + {this.isDebugger ? ( + <span className="async-label">{frame.asyncCause}</span> + ) : ( + l10n.getFormatStr("stacktrace.asyncStack", frame.asyncCause) + )} + {this.isSelectable && <br className="clipboard-only" />} + </span> + )} + {this.isSelectable && <FrameIndent />} + <FrameTitle + frame={frame} + options={{ shouldMapDisplayName }} + l10n={l10n} + /> + {!hideLocation && <span className="clipboard-only"> </span>} + {!hideLocation && ( + <FrameLocation frame={frame} displayFullUrl={displayFullUrl} /> + )} + {this.isSelectable && <br className="clipboard-only" />} + </div> + ); + } +} + +FrameComponent.displayName = "Frame"; +FrameComponent.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js new file mode 100644 index 0000000000..55eb5da08a --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; + +export default function FrameIndent() { + return ( + <span className="frame-indent clipboard-only"> + + </span> + ); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js new file mode 100644 index 0000000000..a92db936ba --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { showMenu } from "../../../context-menu/menu"; +import { copyToTheClipboard } from "../../../utils/clipboard"; + +const blackboxString = "ignoreContextItem.ignore"; +const unblackboxString = "ignoreContextItem.unignore"; + +function formatMenuElement(labelString, click, disabled = false) { + const label = L10N.getStr(labelString); + const accesskey = L10N.getStr(`${labelString}.accesskey`); + const id = `node-menu-${labelString}`; + return { + id, + label, + accesskey, + disabled, + click, + }; +} + +function copySourceElement(url) { + return formatMenuElement("copySourceUri2", () => copyToTheClipboard(url)); +} + +function copyStackTraceElement(copyStackTrace) { + return formatMenuElement("copyStackTrace", () => copyStackTrace()); +} + +function toggleFrameworkGroupingElement( + toggleFrameworkGrouping, + frameworkGroupingOn +) { + const actionType = frameworkGroupingOn + ? "framework.disableGrouping" + : "framework.enableGrouping"; + + return formatMenuElement(actionType, () => toggleFrameworkGrouping()); +} + +function blackBoxSource(cx, source, toggleBlackBox) { + const toggleBlackBoxString = source.isBlackBoxed + ? unblackboxString + : blackboxString; + + return formatMenuElement(toggleBlackBoxString, () => + toggleBlackBox(cx, source) + ); +} + +function restartFrame(cx, frame, restart) { + return formatMenuElement("restartFrame", () => restart(cx, frame)); +} + +function isValidRestartFrame(frame, callbacks) { + // Hides 'Restart Frame' item for call stack groups context menu, + // otherwise can be misleading for the user which frame gets restarted. + if (!callbacks.restart) { + return false; + } + + // Any frame state than 'on-stack' is either dismissed by the server + // or can potentially cause unexpected errors. + // Global frame has frame.callee equal to null and can't be restarted. + return frame.type === "call" && frame.state === "on-stack"; +} + +export default function FrameMenu( + frame, + frameworkGroupingOn, + callbacks, + event, + cx +) { + event.stopPropagation(); + event.preventDefault(); + + const menuOptions = []; + + if (isValidRestartFrame(frame, callbacks)) { + const restartFrameItem = restartFrame(cx, frame, callbacks.restart); + menuOptions.push(restartFrameItem); + } + + const toggleFrameworkElement = toggleFrameworkGroupingElement( + callbacks.toggleFrameworkGrouping, + frameworkGroupingOn + ); + menuOptions.push(toggleFrameworkElement); + + const { source } = frame; + if (source) { + const copySourceUri2 = copySourceElement(source.url); + menuOptions.push(copySourceUri2); + menuOptions.push(blackBoxSource(cx, source, callbacks.toggleBlackBox)); + } + + const copyStackTraceItem = copyStackTraceElement(callbacks.copyStackTrace); + + menuOptions.push(copyStackTraceItem); + + showMenu(event, menuOptions); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css new file mode 100644 index 0000000000..5f57f97e51 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.frames [role="list"] { + list-style: none; + margin: 0; + padding: 4px 0; +} + +.frames [role="list"] [role="listitem"] { + padding-bottom: 2px; + overflow: hidden; + display: flex; + justify-content: space-between; + column-gap: 0.5em; + flex-direction: row; + align-items: center; + margin: 0; + max-width: 100%; + flex-wrap: wrap; +} + +.frames [role="list"] [role="listitem"] * { + user-select: none; +} + +.frames .badge { + flex-shrink: 0; + margin-inline-end: 10px; +} + +.frames .location { + font-weight: normal; + margin: 0; + flex-grow: 1; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + /* Trick to get the ellipsis at the start of the string */ + text-overflow: ellipsis; + direction: rtl; +} + +.call-stack-pane:dir(ltr) .frames .location { + padding-right: 10px; + text-align: right; +} + +.call-stack-pane:dir(rtl) .frames .location { + padding-left: 10px; + text-align: left; +} + +.call-stack-pane .location-async-cause { + color: var(--theme-comment); +} + +.theme-light .frames .location { + color: var(--theme-comment); +} + +:root.theme-dark .frames .location { + color: var(--theme-body-color); + opacity: 0.6; +} + +.frames .title { + text-overflow: ellipsis; + overflow: hidden; + padding-inline-start: 10px; +} + +.frames-group .title { + padding-inline-start: 40px; +} + +.frames [role="list"] [role="listitem"]:hover, +.frames [role="list"] [role="listitem"]:focus { + background-color: var(--theme-toolbar-background-alt); +} + +.frames [role="list"] [role="listitem"]:hover .location-async-cause, +.frames [role="list"] [role="listitem"]:focus .location-async-cause, +.frames [role="list"] [role="listitem"]:hover .async-label, +.frames [role="list"] [role="listitem"]:focus .async-label { + background-color: var(--theme-body-background); +} + +.theme-dark .frames [role="list"] [role="listitem"]:focus, +.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label, +.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label { + background-color: var(--theme-tab-toolbar-background); +} + +.frames [role="list"] [role="listitem"].selected, +.frames [role="list"] [role="listitem"].selected .async-label { + background-color: var(--theme-selection-background); + color: white; +} + +.frames [role="list"] [role="listitem"].selected i.annotation-logo svg path { + fill: white; +} + +:root.theme-light .frames [role="list"] [role="listitem"].selected .location, +:root.theme-dark .frames [role="list"] [role="listitem"].selected .location { + color: white; +} + +.frames .show-more-container { + display: flex; + min-height: 24px; + padding: 4px 0; +} + +.frames .show-more { + text-align: center; + padding: 8px 0px; + margin: 7px 10px 7px 7px; + border: 1px solid var(--theme-splitter-color); + background-color: var(--theme-tab-toolbar-background); + width: 100%; + font-size: inherit; + color: inherit; +} + +.frames .show-more:hover { + background-color: var(--theme-toolbar-background-hover); +} + +.frames .img.annotation-logo { + margin-inline-end: 4px; + background-color: currentColor; +} + +/* + * We also show the library icon in locations, which are forced to RTL. + */ +.frames .location .img.annotation-logo { + margin-inline-start: 4px; +} + +/* Some elements are added to the DOM only to be printed into the clipboard + when the user copy some elements. We don't want those elements to mess with + the layout so we put them outside of the screen +*/ +.frames .clipboard-only { + position: absolute; + left: -9999px; +} + +.call-stack-pane [role="listitem"] .location-async-cause { + height: 20px; + line-height: 20px; + color: var(--theme-icon-dimmed-color); + display: block; + z-index: 4; + position: relative; + padding-inline-start: 17px; + width: 100%; + pointer-events: none; +} + +.frames-group .location-async-cause { + padding-inline-start: 47px; +} + +.call-stack-pane [role="listitem"] .location-async-cause::after { + content: " "; + position: absolute; + left: 0; + z-index: -1; + height: 30px; + top: 50%; + width: 100%; + border-top: 1px solid var(--theme-tab-toolbar-background);; +} + +.call-stack-pane .async-label { + z-index: 1; + background-color: var(--theme-sidebar-background); + padding: 0 3px; + display: inline-block; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css new file mode 100644 index 0000000000..14dbea9954 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.frames-group .group, +.frames-group .group .location { + font-weight: 500; + cursor: default; + /* + * direction:rtl is set in Frames.css to overflow the location text from the + * start. Here we need to reset it in order to display the framework icon + * after the framework name. + */ + direction: ltr; +} + +.frames-group.expanded .group, +.frames-group.expanded .group .location { + color: var(--theme-highlight-blue); +} + +.frames-group .frames-list { + border-top: 1px solid var(--theme-splitter-color); + border-bottom: 1px solid var(--theme-splitter-color); +} + +.frames-group.expanded .badge { + color: var(--theme-highlight-blue); +} + +.frames-group .img.arrow { + margin-inline-start: -1px; + margin-inline-end: 4px; +} + +.frames-group .group-description { + padding-inline-start: 6px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js new file mode 100644 index 0000000000..162c89a2a6 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { getLibraryFromUrl } from "../../../utils/pause/frames"; + +import FrameMenu from "./FrameMenu"; +import AccessibleImage from "../../shared/AccessibleImage"; +import FrameComponent from "./Frame"; + +import "./Group.css"; + +import Badge from "../../shared/Badge"; +import FrameIndent from "./FrameIndent"; + +const classnames = require("devtools/client/shared/classnames.js"); + +function FrameLocation({ frame, expanded }) { + const library = frame.library || getLibraryFromUrl(frame); + if (!library) { + return null; + } + + const arrowClassName = classnames("arrow", { expanded }); + return ( + <span className="group-description"> + <AccessibleImage className={arrowClassName} /> + <AccessibleImage className={`annotation-logo ${library.toLowerCase()}`} /> + <span className="group-description-name">{library}</span> + </span> + ); +} + +FrameLocation.propTypes = { + expanded: PropTypes.any.isRequired, + frame: PropTypes.object.isRequired, +}; + +FrameLocation.displayName = "FrameLocation"; + +export default class Group extends Component { + constructor(...args) { + super(...args); + this.state = { expanded: false }; + } + + static get propTypes() { + return { + copyStackTrace: PropTypes.func.isRequired, + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + group: PropTypes.array.isRequired, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectLocation: PropTypes.func, + selectedFrame: PropTypes.object, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func.isRequired, + }; + } + + get isSelectable() { + return this.props.panel == "webconsole"; + } + + onContextMenu(event) { + const { + group, + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + frameworkGroupingOn, + cx, + } = this.props; + const frame = group[0]; + FrameMenu( + frame, + frameworkGroupingOn, + { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox }, + event, + cx + ); + } + + toggleFrames = event => { + event.stopPropagation(); + this.setState(prevState => ({ expanded: !prevState.expanded })); + }; + + renderFrames() { + const { + cx, + group, + selectFrame, + selectLocation, + selectedFrame, + toggleFrameworkGrouping, + frameworkGroupingOn, + toggleBlackBox, + copyStackTrace, + displayFullUrl, + getFrameTitle, + disableContextMenu, + panel, + restart, + } = this.props; + + const { expanded } = this.state; + if (!expanded) { + return null; + } + + return ( + <div className="frames-list"> + {group.reduce((acc, frame, i) => { + if (this.isSelectable) { + acc.push(<FrameIndent key={`frame-indent-${i}`} />); + } + return acc.concat( + <FrameComponent + cx={cx} + copyStackTrace={copyStackTrace} + frame={frame} + frameworkGroupingOn={frameworkGroupingOn} + hideLocation={true} + key={frame.id} + selectedFrame={selectedFrame} + selectFrame={selectFrame} + selectLocation={selectLocation} + shouldMapDisplayName={false} + toggleBlackBox={toggleBlackBox} + toggleFrameworkGrouping={toggleFrameworkGrouping} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ); + }, [])} + </div> + ); + } + + renderDescription() { + const { l10n } = this.context; + const { group } = this.props; + const { expanded } = this.state; + + const frame = group[0]; + const l10NEntry = expanded + ? "callStack.group.collapseTooltip" + : "callStack.group.expandTooltip"; + const title = l10n.getFormatStr(l10NEntry, frame.library); + + return ( + <div + role="listitem" + key={frame.id} + className="group" + onClick={this.toggleFrames} + tabIndex={0} + title={title} + > + {this.isSelectable && <FrameIndent />} + <FrameLocation frame={frame} expanded={expanded} /> + {this.isSelectable && <span className="clipboard-only"> </span>} + <Badge>{this.props.group.length}</Badge> + {this.isSelectable && <br className="clipboard-only" />} + </div> + ); + } + + render() { + const { expanded } = this.state; + const { disableContextMenu } = this.props; + return ( + <div + className={classnames("frames-group", { expanded })} + onContextMenu={disableContextMenu ? null : e => this.onContextMenu(e)} + > + {this.renderDescription()} + {this.renderFrames()} + </div> + ); + } +} + +Group.displayName = "Group"; +Group.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js new file mode 100644 index 0000000000..5c48af8cb3 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import { connect } from "../../../utils/connect"; +import PropTypes from "prop-types"; + +import FrameComponent from "./Frame"; +import Group from "./Group"; + +import actions from "../../../actions"; +import { collapseFrames, formatCopyName } from "../../../utils/pause/frames"; +import { copyToTheClipboard } from "../../../utils/clipboard"; + +import { + getFrameworkGroupingState, + getSelectedFrame, + getCallStackFrames, + getCurrentThread, + getThreadContext, +} from "../../../selectors"; + +import "./Frames.css"; + +const NUM_FRAMES_SHOWN = 7; + +class Frames extends Component { + constructor(props) { + super(props); + + this.state = { + showAllFrames: !!props.disableFrameTruncate, + }; + } + + static get propTypes() { + return { + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + disableFrameTruncate: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frames: PropTypes.array.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectLocation: PropTypes.func, + selectedFrame: PropTypes.object, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + const { frames, selectedFrame, frameworkGroupingOn } = this.props; + const { showAllFrames } = this.state; + return ( + frames !== nextProps.frames || + selectedFrame !== nextProps.selectedFrame || + showAllFrames !== nextState.showAllFrames || + frameworkGroupingOn !== nextProps.frameworkGroupingOn + ); + } + + toggleFramesDisplay = () => { + this.setState(prevState => ({ + showAllFrames: !prevState.showAllFrames, + })); + }; + + collapseFrames(frames) { + const { frameworkGroupingOn } = this.props; + if (!frameworkGroupingOn) { + return frames; + } + + return collapseFrames(frames); + } + + truncateFrames(frames) { + const numFramesToShow = this.state.showAllFrames + ? frames.length + : NUM_FRAMES_SHOWN; + + return frames.slice(0, numFramesToShow); + } + + copyStackTrace = () => { + const { frames } = this.props; + const { l10n } = this.context; + const framesToCopy = frames.map(f => formatCopyName(f, l10n)).join("\n"); + copyToTheClipboard(framesToCopy); + }; + + toggleFrameworkGrouping = () => { + const { toggleFrameworkGrouping, frameworkGroupingOn } = this.props; + toggleFrameworkGrouping(!frameworkGroupingOn); + }; + + renderFrames(frames) { + const { + cx, + selectFrame, + selectLocation, + selectedFrame, + toggleBlackBox, + frameworkGroupingOn, + displayFullUrl, + getFrameTitle, + disableContextMenu, + panel, + restart, + } = this.props; + + const framesOrGroups = this.truncateFrames(this.collapseFrames(frames)); + + // We're not using a <ul> because it adds new lines before and after when + // the user copies the trace. Needed for the console which has several + // places where we don't want to have those new lines. + return ( + <div role="list"> + {framesOrGroups.map(frameOrGroup => + frameOrGroup.id ? ( + <FrameComponent + cx={cx} + frame={frameOrGroup} + toggleFrameworkGrouping={this.toggleFrameworkGrouping} + copyStackTrace={this.copyStackTrace} + frameworkGroupingOn={frameworkGroupingOn} + selectFrame={selectFrame} + selectLocation={selectLocation} + selectedFrame={selectedFrame} + toggleBlackBox={toggleBlackBox} + key={String(frameOrGroup.id)} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ) : ( + <Group + cx={cx} + group={frameOrGroup} + toggleFrameworkGrouping={this.toggleFrameworkGrouping} + copyStackTrace={this.copyStackTrace} + frameworkGroupingOn={frameworkGroupingOn} + selectFrame={selectFrame} + selectLocation={selectLocation} + selectedFrame={selectedFrame} + toggleBlackBox={toggleBlackBox} + key={frameOrGroup[0].id} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ) + )} + </div> + ); + } + + renderToggleButton(frames) { + const { l10n } = this.context; + const buttonMessage = this.state.showAllFrames + ? l10n.getStr("callStack.collapse") + : l10n.getStr("callStack.expand"); + + frames = this.collapseFrames(frames); + if (frames.length <= NUM_FRAMES_SHOWN) { + return null; + } + + return ( + <div className="show-more-container"> + <button className="show-more" onClick={this.toggleFramesDisplay}> + {buttonMessage} + </button> + </div> + ); + } + + render() { + const { frames, disableFrameTruncate } = this.props; + + if (!frames) { + return ( + <div className="pane frames"> + <div className="pane-info empty"> + {L10N.getStr("callStack.notPaused")} + </div> + </div> + ); + } + + return ( + <div className="pane frames"> + {this.renderFrames(frames)} + {disableFrameTruncate ? null : this.renderToggleButton(frames)} + </div> + ); + } +} + +Frames.contextTypes = { l10n: PropTypes.object }; + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + frames: getCallStackFrames(state), + frameworkGroupingOn: getFrameworkGroupingState(state), + selectedFrame: getSelectedFrame(state, getCurrentThread(state)), + disableFrameTruncate: false, + disableContextMenu: false, + displayFullUrl: false, +}); + +export default connect(mapStateToProps, { + selectFrame: actions.selectFrame, + selectLocation: actions.selectLocation, + toggleBlackBox: actions.toggleBlackBox, + toggleFrameworkGrouping: actions.toggleFrameworkGrouping, + restart: actions.restart, +})(Frames); + +// Export the non-connected component in order to use it outside of the debugger +// panel (e.g. console, netmonitor, …). +export { Frames }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build new file mode 100644 index 0000000000..f775363b14 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build @@ -0,0 +1,14 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "Frame.js", + "FrameIndent.js", + "FrameMenu.js", + "Group.js", + "index.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js new file mode 100644 index 0000000000..10ec961858 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow, mount } from "enzyme"; +import Frame from "../Frame.js"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +import FrameMenu from "../FrameMenu"; +jest.mock("../FrameMenu", () => jest.fn()); + +function frameProperties(frame, selectedFrame, overrides = {}) { + return { + cx: mockthreadcx, + frame, + selectedFrame, + copyStackTrace: jest.fn(), + contextTypes: {}, + selectFrame: jest.fn(), + selectLocation: jest.fn(), + toggleBlackBox: jest.fn(), + displayFullUrl: false, + frameworkGroupingOn: false, + panel: "webconsole", + toggleFrameworkGrouping: null, + restart: jest.fn(), + ...overrides, + }; +} + +function render(frameToSelect = {}, overrides = {}, propsOverrides = {}) { + const source = makeMockSource("foo-view.js"); + const defaultFrame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const frame = { ...defaultFrame, ...overrides }; + const selectedFrame = { ...frame, ...frameToSelect }; + + const props = frameProperties(frame, selectedFrame, propsOverrides); + const component = shallow(<Frame {...props} />); + return { component, props }; +} + +describe("Frame", () => { + it("user frame", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("user frame (not selected)", () => { + const { component } = render({ id: "2" }); + expect(component).toMatchSnapshot(); + }); + + it("library frame", () => { + const source = makeMockSource("backbone.js"); + const backboneFrame = { + ...makeMockFrame("3", source, undefined, 12, "updateEvents"), + library: "backbone", + }; + + const { component } = render({ id: "3" }, backboneFrame); + expect(component).toMatchSnapshot(); + }); + + it("filename only", () => { + const source = makeMockSource( + "https://firefox.com/assets/src/js/foo-view.js" + ); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null); + const component = mount(<Frame {...props} />); + expect(component.text()).toBe("    renderFoo foo-view.js:10"); + }); + + it("full URL", () => { + const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null, { displayFullUrl: true }); + const component = mount(<Frame {...props} />); + expect(component.text()).toBe(`    renderFoo ${url}:10`); + }); + + it("renders asyncCause", () => { + const url = `https://example.com/async.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "timeoutFn"); + frame.asyncCause = "setTimeout handler"; + + const props = frameProperties(frame); + const component = mount(<Frame {...props} />, { context: { l10n: L10N } }); + expect(component.find(".location-async-cause").text()).toBe( + `    (Async: setTimeout handler)` + ); + }); + + it("getFrameTitle", () => { + const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null, { + getFrameTitle: x => `Jump to ${x}`, + }); + const component = shallow(<Frame {...props} />); + expect(component.prop("title")).toBe(`Jump to ${url}:10`); + expect(component).toMatchSnapshot(); + }); + + describe("mouse events", () => { + it("does not call FrameMenu when disableContextMenu is true", () => { + const { component } = render(undefined, undefined, { + disableContextMenu: true, + }); + + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledTimes(0); + }); + + it("calls FrameMenu on right click", () => { + const { component, props } = render(); + const { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + cx, + restart, + } = props; + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledWith( + props.frame, + props.frameworkGroupingOn, + { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + restart, + }, + mockEvent, + cx + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js new file mode 100644 index 0000000000..dbaa98f5cf --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.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 FrameMenu from "../FrameMenu"; + +import { showMenu } from "../../../../context-menu/menu"; +import { copyToTheClipboard } from "../../../../utils/clipboard"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +jest.mock("../../../../context-menu/menu", () => ({ showMenu: jest.fn() })); +jest.mock("../../../../utils/clipboard", () => ({ + copyToTheClipboard: jest.fn(), +})); + +function generateMockId(labelString) { + return `node-menu-${labelString}`; +} + +describe("FrameMenu", () => { + let mockEvent; + let mockFrame; + let emptyFrame; + let callbacks; + let frameworkGroupingOn; + let toggleFrameworkGrouping; + + beforeEach(() => { + mockFrame = makeMockFrame(undefined, makeMockSource("isFake")); + mockEvent = { + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }; + callbacks = { + toggleFrameworkGrouping, + toggleBlackbox: jest.fn(), + copyToTheClipboard, + restart: jest.fn(), + }; + emptyFrame = {}; + }); + + afterEach(() => { + showMenu.mockClear(); + }); + + it("sends three element in menuOpts to showMenu if source is present", () => { + const restartFrameId = generateMockId("restartFrame"); + const sourceId = generateMockId("copySourceUri2"); + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGroupingId = generateMockId("framework.enableGrouping"); + const blackBoxId = generateMockId("ignoreContextItem.ignore"); + + FrameMenu( + mockFrame, + frameworkGroupingOn, + callbacks, + mockEvent, + mockthreadcx + ); + + const receivedArray = showMenu.mock.calls[0][1]; + expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray); + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([ + restartFrameId, + frameworkGroupingId, + sourceId, + blackBoxId, + stacktraceId, + ]); + }); + + it("sends one element in menuOpts without source", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.enableGrouping"); + + FrameMenu( + emptyFrame, + frameworkGroupingOn, + callbacks, + mockEvent, + mockthreadcx + ); + + const receivedArray = showMenu.mock.calls[0][1]; + expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray); + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); + + it("uses the disableGrouping text if frameworkGroupingOn is false", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.disableGrouping"); + + FrameMenu(emptyFrame, true, callbacks, mockEvent, mockthreadcx); + + const receivedArray = showMenu.mock.calls[0][1]; + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); + + it("uses the enableGrouping text if frameworkGroupingOn is true", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.enableGrouping"); + + FrameMenu(emptyFrame, false, callbacks, mockEvent, mockthreadcx); + + const receivedArray = showMenu.mock.calls[0][1]; + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js new file mode 100644 index 0000000000..da60418b07 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js @@ -0,0 +1,295 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { mount, shallow } from "enzyme"; +import Frames from "../index.js"; +// eslint-disable-next-line +import { formatCallStackFrames } from "../../../../selectors/getCallStackFrames"; +import { makeMockFrame, makeMockSource } from "../../../../utils/test-mockup"; + +function render(overrides = {}) { + const defaultProps = { + frames: null, + selectedFrame: null, + frameworkGroupingOn: false, + toggleFrameworkGrouping: jest.fn(), + contextTypes: {}, + selectFrame: jest.fn(), + toggleBlackBox: jest.fn(), + }; + + const props = { ...defaultProps, ...overrides }; + const component = shallow(<Frames.WrappedComponent {...props} />, { + context: { l10n: L10N }, + }); + + return component; +} + +describe("Frames", () => { + describe("Supports different number of frames", () => { + it("empty frames", () => { + const component = render(); + expect(component).toMatchSnapshot(); + expect(component.find(".show-more").exists()).toBeFalsy(); + }); + + it("one frame", () => { + const frames = [{ id: 1 }]; + const selectedFrame = frames[0]; + const component = render({ frames, selectedFrame }); + + expect(component.find(".show-more").exists()).toBeFalsy(); + expect(component).toMatchSnapshot(); + }); + + it("toggling the show more button", () => { + const frames = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 6 }, + { id: 7 }, + { id: 8 }, + { id: 9 }, + { id: 10 }, + ]; + + const selectedFrame = frames[0]; + const component = render({ selectedFrame, frames }); + + const getToggleBtn = () => component.find(".show-more"); + const getFrames = () => component.find("Frame"); + + expect(getToggleBtn().text()).toEqual("Expand rows"); + expect(getFrames()).toHaveLength(7); + + getToggleBtn().simulate("click"); + expect(getToggleBtn().text()).toEqual("Collapse rows"); + expect(getFrames()).toHaveLength(10); + expect(component).toMatchSnapshot(); + }); + + it("disable frame truncation", () => { + const framesNumber = 20; + const frames = Array.from({ length: framesNumber }, (_, i) => ({ + id: i + 1, + })); + + const component = render({ + frames, + disableFrameTruncate: true, + }); + + const getToggleBtn = () => component.find(".show-more"); + const getFrames = () => component.find("Frame"); + + expect(getToggleBtn().exists()).toBeFalsy(); + expect(getFrames()).toHaveLength(framesNumber); + + expect(component).toMatchSnapshot(); + }); + + it("shows the full URL", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + ]; + + const component = mount( + <Frames.WrappedComponent + frames={frames} + disableFrameTruncate={true} + displayFullUrl={true} + /> + ); + expect(component.text()).toBe( + "renderFoo http://myfile.com/mahscripts.js:55" + ); + }); + + it("passes the getFrameTitle prop to the Frame component", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + ]; + const getFrameTitle = () => {}; + const component = render({ frames, getFrameTitle }); + + expect(component.find("Frame").prop("getFrameTitle")).toBe(getFrameTitle); + expect(component).toMatchSnapshot(); + }); + + it("passes the getFrameTitle prop to the Group component", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + { + id: 2, + library: "back", + displayName: "a", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/back.js", + }, + }, + { + id: 3, + library: "back", + displayName: "b", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/back.js", + }, + }, + ]; + const getFrameTitle = () => {}; + const component = render({ + frames, + getFrameTitle, + frameworkGroupingOn: true, + }); + + expect(component.find("Group").prop("getFrameTitle")).toBe(getFrameTitle); + }); + }); + + describe("Blackboxed Frames", () => { + it("filters blackboxed frames", () => { + const source1 = makeMockSource("source1", "1"); + const source2 = makeMockSource("source2", "2"); + source2.isBlackBoxed = true; + + const frames = [ + makeMockFrame("1", source1), + makeMockFrame("2", source2), + makeMockFrame("3", source1), + makeMockFrame("8", source2), + ]; + + const blackboxedRanges = { + source2: [], + }; + + const processedFrames = formatCallStackFrames( + frames, + source1, + blackboxedRanges + ); + const selectedFrame = frames[0]; + + const component = render({ + frames: processedFrames, + frameworkGroupingOn: false, + selectedFrame, + }); + + expect(component.find("Frame")).toHaveLength(2); + expect(component).toMatchSnapshot(); + }); + }); + + describe("Library Frames", () => { + it("toggling framework frames", () => { + const frames = [ + { id: 1 }, + { id: 2, library: "back" }, + { id: 3, library: "back" }, + { id: 8 }, + ]; + + const selectedFrame = frames[0]; + const frameworkGroupingOn = false; + const component = render({ frames, frameworkGroupingOn, selectedFrame }); + + expect(component.find("Frame")).toHaveLength(4); + expect(component).toMatchSnapshot(); + + component.setProps({ frameworkGroupingOn: true }); + + expect(component.find("Frame")).toHaveLength(2); + expect(component).toMatchSnapshot(); + }); + + it("groups all the Webpack-related frames", () => { + const frames = [ + { id: "1-appFrame" }, + { + id: "2-webpackBootstrapFrame", + source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" }, + }, + { + id: "3-webpackBundleFrame", + source: { url: "https://foo.com/bundle.js" }, + }, + { + id: "4-webpackBootstrapFrame", + source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" }, + }, + { + id: "5-webpackBundleFrame", + source: { url: "https://foo.com/bundle.js" }, + }, + ]; + const selectedFrame = frames[0]; + const frameworkGroupingOn = true; + const component = render({ frames, frameworkGroupingOn, selectedFrame }); + + expect(component).toMatchSnapshot(); + }); + + it("selectable framework frames", () => { + const frames = [ + { id: 1 }, + { id: 2, library: "back" }, + { id: 3, library: "back" }, + { id: 8 }, + ]; + + const selectedFrame = frames[0]; + + const component = render({ + frames, + frameworkGroupingOn: false, + selectedFrame, + selectable: true, + }); + expect(component).toMatchSnapshot(); + + component.setProps({ frameworkGroupingOn: true }); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js new file mode 100644 index 0000000000..8ff1454d1a --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.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 React from "react"; +import { shallow } from "enzyme"; +import Group from "../Group.js"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +import FrameMenu from "../FrameMenu"; +jest.mock("../FrameMenu", () => jest.fn()); + +function render(overrides = {}) { + const frame = { ...makeMockFrame(), displayName: "foo", library: "Back" }; + const defaultProps = { + cx: mockthreadcx, + group: [frame], + selectedFrame: frame, + frameworkGroupingOn: true, + toggleFrameworkGrouping: jest.fn(), + selectFrame: jest.fn(), + selectLocation: jest.fn(), + copyStackTrace: jest.fn(), + toggleBlackBox: jest.fn(), + disableContextMenu: false, + displayFullUrl: false, + panel: "webconsole", + restart: jest.fn(), + }; + + const props = { ...defaultProps, ...overrides }; + const component = shallow(<Group {...props} />, { + context: { l10n: L10N }, + }); + return { component, props }; +} + +describe("Group", () => { + it("displays a group", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("passes the getFrameTitle prop to the Frame components", () => { + const mahscripts = makeMockSource("http://myfile.com/mahscripts.js"); + const back = makeMockSource("http://myfile.com/back.js"); + const group = [ + { + ...makeMockFrame("1", mahscripts, undefined, 55, "renderFoo"), + library: "Back", + }, + { + ...makeMockFrame("2", back, undefined, 55, "a"), + library: "Back", + }, + { + ...makeMockFrame("3", back, undefined, 55, "b"), + library: "Back", + }, + ]; + const getFrameTitle = () => {}; + const { component } = render({ group, getFrameTitle }); + + component.setState({ expanded: true }); + + const frameComponents = component.find("Frame"); + expect(frameComponents).toHaveLength(3); + frameComponents.forEach(node => { + expect(node.prop("getFrameTitle")).toBe(getFrameTitle); + }); + expect(component).toMatchSnapshot(); + }); + + it("renders group with anonymous functions", () => { + const mahscripts = makeMockSource("http://myfile.com/mahscripts.js"); + const back = makeMockSource("http://myfile.com/back.js"); + const group = [ + { + ...makeMockFrame("1", mahscripts, undefined, 55), + library: "Back", + }, + { + ...makeMockFrame("2", back, undefined, 55), + library: "Back", + }, + { + ...makeMockFrame("3", back, undefined, 55), + library: "Back", + }, + ]; + + const { component } = render({ group }); + expect(component).toMatchSnapshot(); + component.setState({ expanded: true }); + expect(component).toMatchSnapshot(); + }); + + describe("mouse events", () => { + it("does not call FrameMenu when disableContextMenu is true", () => { + const { component } = render({ + disableContextMenu: true, + }); + + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledTimes(0); + }); + + it("calls FrameMenu on right click", () => { + const { component, props } = render(); + const { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, cx } = + props; + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledWith( + props.group[0], + props.frameworkGroupingOn, + { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + }, + mockEvent, + cx + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap new file mode 100644 index 0000000000..2b1edaeef7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap @@ -0,0 +1,1196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Frame getFrameTitle 1`] = ` +<div + className="frame" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} + title="Jump to https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js:10" +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame library frame 1`] = ` +<div + className="frame selected" + key="3" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "updateEvents", + "generatedLocation": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "backbone", + "location": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "updateEvents", + "generatedLocation": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "backbone", + "location": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame user frame (not selected) 1`] = ` +<div + className="frame" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame user frame 1`] = ` +<div + className="frame selected" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap new file mode 100644 index 0000000000..9a9c2a379f --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap @@ -0,0 +1,1651 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Frames Blackboxed Frames filters blackboxed frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-3", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames groups all the Webpack-related frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": "1-appFrame", + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1-appFrame" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": "1-appFrame", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": "2-webpackBootstrapFrame", + "source": Object { + "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f", + }, + }, + Object { + "id": "3-webpackBundleFrame", + "source": Object { + "url": "https://foo.com/bundle.js", + }, + }, + Object { + "id": "4-webpackBootstrapFrame", + "source": Object { + "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f", + }, + }, + Object { + "id": "5-webpackBundleFrame", + "source": Object { + "url": "https://foo.com/bundle.js", + }, + }, + ] + } + key="2-webpackBootstrapFrame" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": "1-appFrame", + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames selectable framework frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames selectable framework frames 2`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": 2, + "library": "back", + }, + Object { + "id": 3, + "library": "back", + }, + ] + } + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames toggling framework frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames toggling framework frames 2`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": 2, + "library": "back", + }, + Object { + "id": 3, + "library": "back", + }, + ] + } + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames disable frame truncation 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 4, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="4" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 5, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="5" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 6, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="6" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 7, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="7" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 9, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="9" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 10, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="10" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 11, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="11" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 12, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="12" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 13, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="13" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 14, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="14" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 15, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="15" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 16, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="16" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 17, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="17" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 18, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="18" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 19, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="19" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 20, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="20" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames empty frames 1`] = ` +<div + className="pane frames" +> + <div + className="pane-info empty" + > + Not paused + </div> +</div> +`; + +exports[`Frames Supports different number of frames one frame 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames passes the getFrameTitle prop to the Frame component 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "displayName": "renderFoo", + "id": 1, + "location": Object { + "line": 55, + }, + "source": Object { + "url": "http://myfile.com/mahscripts.js", + }, + } + } + frameworkGroupingOn={false} + getFrameTitle={[Function]} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames toggling the show more button 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 4, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="4" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 5, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="5" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 6, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="6" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 7, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="7" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 9, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="9" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 10, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="10" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> + <div + className="show-more-container" + > + <button + className="show-more" + onClick={[Function]} + > + Collapse rows + </button> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap new file mode 100644 index 0000000000..d6542f7fd2 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap @@ -0,0 +1,2440 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Group displays a group 1`] = ` +<div + className="frames-group" + onContextMenu={[Function]} +> + <div + className="group" + key="frame" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Show Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={false} + frame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 1 + </Badge> + <br + className="clipboard-only" + /> + </div> +</div> +`; + +exports[`Group passes the getFrameTitle prop to the Frame components 1`] = ` +<div + className="frames-group expanded" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Collapse Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={true} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> + <div + className="frames-list" + > + <FrameIndent + key="frame-indent-0" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="1" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-1" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "a", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "2", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="2" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-2" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "b", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="3" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + </div> +</div> +`; + +exports[`Group renders group with anonymous functions 1`] = ` +<div + className="frames-group" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Show Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> +</div> +`; + +exports[`Group renders group with anonymous functions 2`] = ` +<div + className="frames-group expanded" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Collapse Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={true} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> + <div + className="frames-list" + > + <FrameIndent + key="frame-indent-0" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="1" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-1" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-2", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "2", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="2" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-2" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-3", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="3" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css new file mode 100644 index 0000000000..6f47c45d19 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.secondary-panes .map-scopes-header { + padding-inline-end: 3px; +} + +.secondary-panes .header-buttons .img.shortcuts { + width: 14px; + height: 14px; + /* Better vertical centering of the icon */ + margin-top: -2px; +} + +.scopes-content .node.object-node { + padding-inline-start: 7px; +} + +.scopes-content .pane.scopes-list { + font-family: var(--monospace-font-family); +} + +.scopes-content .toggle-map-scopes a.mdn { + padding-inline-start: 3px; +} + +.scopes-content .toggle-map-scopes .img.shortcuts { + background: var(--theme-comment); +} + +.object-node.default-property { + opacity: 0.6; +} + +.object-node { + padding-inline-start: 20px; +} + +html[dir="rtl"] .object-node { + padding-right: 4px; +} + +.object-label { + color: var(--theme-highlight-blue); +} + +.objectBox-object, +.objectBox-text, +.objectBox-table, +.objectLink-textNode, +.objectLink-event, +.objectLink-eventLog, +.objectLink-regexp, +.objectLink-object, +.objectLink-Date, +.theme-dark .objectBox-object, +.theme-light .objectBox-object { + white-space: nowrap; +} + +.scopes-pane ._content { + overflow: auto; +} + +.scopes-list { + padding: 4px 0px; +} + +.scopes-list .function-signature { + display: inline-block; +} + +.scopes-list .scope-type-toggle { + text-align: center; + padding-top: 10px; + padding-bottom: 10px; +} + +.scopes-list .scope-type-toggle button { + /* Override color so that the link doesn't turn purple */ + color: var(--theme-body-color); + font-size: inherit; + text-decoration: underline; + cursor: pointer; +} + +.scopes-list .scope-type-toggle button:hover { + background: transparent; +} + +.scopes-list .tree.object-inspector .node.object-node { + display: flex; + align-items: center; +} + +.scopes-list .tree.object-inspector .tree-node button.arrow, +.scopes-list button.invoke-getter { + margin-top: 2px; +} + +.scopes-list .tree { + line-height: 15px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js new file mode 100644 index 0000000000..2b6b5f94c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js @@ -0,0 +1,311 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; + +import { + getSelectedSource, + getSelectedFrame, + getGeneratedFrameScope, + getOriginalFrameScope, + getPauseReason, + isMapScopesEnabled, + getThreadContext, + getLastExpandedScopes, + getIsCurrentThreadPaused, +} from "../../selectors"; +import { getScopes } from "../../utils/pause/scopes"; +import { getScopeItemPath } from "../../utils/pause/scopes/utils"; +import { clientCommands } from "../../client/firefox"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +import "./Scopes.css"; + +const { ObjectInspector } = objectInspector; + +class Scopes extends PureComponent { + constructor(props) { + const { why, selectedFrame, originalFrameScopes, generatedFrameScopes } = + props; + + super(props); + + this.state = { + originalScopes: getScopes(why, selectedFrame, originalFrameScopes), + generatedScopes: getScopes(why, selectedFrame, generatedFrameScopes), + showOriginal: true, + }; + } + + static get propTypes() { + return { + addWatchpoint: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + expandedScopes: PropTypes.array.isRequired, + generatedFrameScopes: PropTypes.object, + highlightDomElement: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + mapScopesEnabled: PropTypes.bool.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + originalFrameScopes: PropTypes.object, + removeWatchpoint: PropTypes.func.isRequired, + selectedFrame: PropTypes.object.isRequired, + setExpandedScope: PropTypes.func.isRequired, + toggleMapScopes: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + why: PropTypes.object.isRequired, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { + selectedFrame, + originalFrameScopes, + generatedFrameScopes, + isPaused, + } = this.props; + const isPausedChanged = isPaused !== nextProps.isPaused; + const selectedFrameChanged = selectedFrame !== nextProps.selectedFrame; + const originalFrameScopesChanged = + originalFrameScopes !== nextProps.originalFrameScopes; + const generatedFrameScopesChanged = + generatedFrameScopes !== nextProps.generatedFrameScopes; + + if ( + isPausedChanged || + selectedFrameChanged || + originalFrameScopesChanged || + generatedFrameScopesChanged + ) { + this.setState({ + originalScopes: getScopes( + nextProps.why, + nextProps.selectedFrame, + nextProps.originalFrameScopes + ), + generatedScopes: getScopes( + nextProps.why, + nextProps.selectedFrame, + nextProps.generatedFrameScopes + ), + }); + } + } + + onToggleMapScopes = () => { + this.props.toggleMapScopes(); + }; + + onContextMenu = (event, item) => { + const { addWatchpoint, removeWatchpoint } = this.props; + + if (!item.parent || !item.contents.configurable) { + return; + } + + if (!item.contents || item.contents.watchpoint) { + const removeWatchpointLabel = L10N.getStr("watchpoints.removeWatchpoint"); + + const removeWatchpointItem = { + id: "node-menu-remove-watchpoint", + label: removeWatchpointLabel, + disabled: false, + click: () => removeWatchpoint(item), + }; + + const menuItems = [removeWatchpointItem]; + showMenu(event, menuItems); + return; + } + + const addSetWatchpointLabel = L10N.getStr("watchpoints.setWatchpoint"); + const addGetWatchpointLabel = L10N.getStr("watchpoints.getWatchpoint"); + const addGetOrSetWatchpointLabel = L10N.getStr( + "watchpoints.getOrSetWatchpoint" + ); + const watchpointsSubmenuLabel = L10N.getStr("watchpoints.submenu"); + + const addSetWatchpointItem = { + id: "node-menu-add-set-watchpoint", + label: addSetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "set"), + }; + + const addGetWatchpointItem = { + id: "node-menu-add-get-watchpoint", + label: addGetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "get"), + }; + + const addGetOrSetWatchpointItem = { + id: "node-menu-add-get-watchpoint", + label: addGetOrSetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "getorset"), + }; + + const watchpointsSubmenuItem = { + id: "node-menu-watchpoints", + label: watchpointsSubmenuLabel, + disabled: false, + click: () => addWatchpoint(item, "set"), + submenu: [ + addSetWatchpointItem, + addGetWatchpointItem, + addGetOrSetWatchpointItem, + ], + }; + + const menuItems = [watchpointsSubmenuItem]; + showMenu(event, menuItems); + }; + + renderWatchpointButton = item => { + const { removeWatchpoint } = this.props; + + if ( + !item || + !item.contents || + !item.contents.watchpoint || + typeof L10N === "undefined" + ) { + return null; + } + + const { watchpoint } = item.contents; + return ( + <button + className={`remove-watchpoint-${watchpoint}`} + title={L10N.getStr("watchpoints.removeWatchpointTooltip")} + onClick={e => { + e.stopPropagation(); + removeWatchpoint(item); + }} + /> + ); + }; + + renderScopesList() { + const { + cx, + isLoading, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + mapScopesEnabled, + selectedFrame, + setExpandedScope, + expandedScopes, + } = this.props; + const { originalScopes, generatedScopes, showOriginal } = this.state; + + const scopes = + (showOriginal && mapScopesEnabled && originalScopes) || generatedScopes; + + function initiallyExpanded(item) { + return expandedScopes.some(path => path == getScopeItemPath(item)); + } + + if (scopes && !!scopes.length && !isLoading) { + return ( + <div className="pane scopes-list"> + <ObjectInspector + roots={scopes} + autoExpandAll={false} + autoExpandDepth={1} + client={clientCommands} + createElement={tagName => document.createElement(tagName)} + disableWrap={true} + dimTopLevelWindow={true} + frame={selectedFrame} + mayUseCustomFormatter={true} + openLink={openLink} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + onContextMenu={this.onContextMenu} + setExpanded={(path, expand) => setExpandedScope(cx, path, expand)} + initiallyExpanded={initiallyExpanded} + renderItemActions={this.renderWatchpointButton} + shouldRenderTooltip={true} + /> + </div> + ); + } + + let stateText = L10N.getStr("scopes.notPaused"); + if (this.props.isPaused) { + if (isLoading) { + stateText = L10N.getStr("loadingText"); + } else { + stateText = L10N.getStr("scopes.notAvailable"); + } + } + + return ( + <div className="pane scopes-list"> + <div className="pane-info">{stateText}</div> + </div> + ); + } + + render() { + return <div className="scopes-content">{this.renderScopesList()}</div>; + } +} + +const mapStateToProps = state => { + const cx = getThreadContext(state); + const selectedFrame = getSelectedFrame(state, cx.thread); + const selectedSource = getSelectedSource(state); + + const { scope: originalFrameScopes, pending: originalPending } = + getOriginalFrameScope( + state, + cx.thread, + selectedSource?.id, + selectedFrame?.id + ) || { scope: null, pending: false }; + + const { scope: generatedFrameScopes, pending: generatedPending } = + getGeneratedFrameScope(state, cx.thread, selectedFrame?.id) || { + scope: null, + pending: false, + }; + + return { + cx, + selectedFrame, + mapScopesEnabled: isMapScopesEnabled(state), + isLoading: generatedPending || originalPending, + why: getPauseReason(state, cx.thread), + originalFrameScopes, + generatedFrameScopes, + expandedScopes: getLastExpandedScopes(state, cx.thread), + isPaused: getIsCurrentThreadPaused(state), + }; +}; + +export default connect(mapStateToProps, { + openLink: actions.openLink, + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, + toggleMapScopes: actions.toggleMapScopes, + setExpandedScope: actions.setExpandedScope, + addWatchpoint: actions.addWatchpoint, + removeWatchpoint: actions.removeWatchpoint, +})(Scopes); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css new file mode 100644 index 0000000000..dec84252f8 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.secondary-panes { + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-direction: column; + flex: 1; + white-space: nowrap; + background-color: var(--theme-sidebar-background); + --breakpoint-expression-right-clear-space: 36px; +} + +.secondary-panes .controlled > div { + max-width: 100%; +} + +/* + We apply overflow to the container with the commandbar. + This allows the commandbar to remain fixed when scrolling + until the content completely ends. Not just the height of + the wrapper. + Ref: https://github.com/firefox-devtools/debugger/issues/3426 +*/ + +.secondary-panes-wrapper { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; +} + +.secondary-panes .accordion { + flex: 1 0 auto; + margin-bottom: 0; +} + +.secondary-panes-wrapper .accordion li:last-child ._content { + border-bottom: 0; +} + +.pane { + color: var(--theme-body-color); +} + +.pane .pane-info { + font-style: italic; + text-align: center; + padding: 0.5em; + user-select: none; + cursor: default; +} + +.secondary-panes .breakpoints-buttons { + display: flex; +} + +.dropdown { + width: 20em; + overflow: auto; +} + +.secondary-panes input[type="checkbox"] { + margin: 0; + margin-inline-end: 4px; + vertical-align: middle; +} + +.secondary-panes-wrapper .command-bar.bottom { + background-color: var(--theme-body-background); +} + +/** + * Skip Pausing style + * Add a gray background and lower content opacity + */ +.skip-pausing .xhr-breakpoints-pane ._content, +.skip-pausing .breakpoints-pane ._content, +.skip-pausing .event-listeners-pane ._content, +.skip-pausing .dom-mutations-pane ._content { + background-color: var(--skip-pausing-background-color); + opacity: var(--skip-pausing-opacity); + color: var(--skip-pausing-color); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Thread.js b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js new file mode 100644 index 0000000000..c9db8a25ef --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import actions from "../../actions"; +import { getCurrentThread, getIsPaused, getContext } from "../../selectors"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +export class Thread extends Component { + static get propTypes() { + return { + currentThread: PropTypes.string.isRequired, + cx: PropTypes.object.isRequired, + isPaused: PropTypes.bool.isRequired, + selectThread: PropTypes.func.isRequired, + thread: PropTypes.object.isRequired, + }; + } + + onSelectThread = () => { + const { thread } = this.props; + this.props.selectThread(this.props.cx, thread.actor); + }; + + render() { + const { currentThread, isPaused, thread } = this.props; + + const isWorker = thread.targetType.includes("worker"); + let label = thread.name; + if (thread.serviceWorkerStatus) { + label += ` (${thread.serviceWorkerStatus})`; + } + + return ( + <div + className={classnames("thread", { + selected: thread.actor == currentThread, + })} + key={thread.actor} + onClick={this.onSelectThread} + > + <div className="icon"> + <AccessibleImage className={isWorker ? "worker" : "window"} /> + </div> + <div className="label">{label}</div> + {isPaused ? ( + <div className="pause-badge"> + <AccessibleImage className="pause" /> + </div> + ) : null} + </div> + ); + } +} + +const mapStateToProps = (state, props) => ({ + cx: getContext(state), + currentThread: getCurrentThread(state), + isPaused: getIsPaused(state, props.thread.actor), +}); + +export default connect(mapStateToProps, { + selectThread: actions.selectThread, +})(Thread); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.css b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css new file mode 100644 index 0000000000..49e150dd44 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.threads-list { + padding: 4px 0; +} + +.threads-list * { + user-select: none; +} + +.threads-list > .thread { + font-size: inherit; + color: var(--theme-text-color-strong); + padding: 2px 6px; + padding-inline-start: 20px; + line-height: 16px; + position: relative; + cursor: pointer; + display: flex; + align-items: center; +} + +.threads-list > .thread:hover { + background-color: var(--search-overlays-semitransparent); +} + +.threads-list > .thread.selected { + background-color: var(--tab-line-selected-color); +} + +.threads-list .icon { + flex: none; + margin-inline-end: 4px; +} + +.threads-list .img { + display: block; +} + +.threads-list .label { + display: inline-block; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.threads-list .pause-badge { + flex: none; + margin-inline-start: 4px; +} + +.threads-list > .thread.selected { + background: var(--theme-selection-background); + color: var(--theme-selection-color); +} + +.threads-list > .thread.selected .img { + background-color: currentColor; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.js b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js new file mode 100644 index 0000000000..4dbf0ff081 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { getAllThreads } from "../../selectors"; +import Thread from "./Thread"; + +import "./Threads.css"; + +export class Threads extends Component { + static get propTypes() { + return { + threads: PropTypes.array.isRequired, + }; + } + + render() { + const { threads } = this.props; + + return ( + <div className="pane threads-list"> + {threads.map(thread => ( + <Thread thread={thread} key={thread.actor} /> + ))} + </div> + ); + } +} + +const mapStateToProps = state => ({ + threads: getAllThreads(state), +}); + +export default connect(mapStateToProps)(Threads); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css new file mode 100644 index 0000000000..cbe2ebf4c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.why-paused { + display: flex; + flex-direction: column; + justify-content: center; + border-bottom: 1px solid var(--theme-splitter-color); + background-color: hsl(54, 100%, 92%); + color: var(--theme-body-color); + font-size: 12px; + cursor: default; + min-height: 44px; + padding: 6px; + white-space: normal; + font-weight: bold; +} + +.why-paused > div { + display: flex; + flex-direction: row; + align-items: center; +} + +.why-paused .info.icon { + align-self: center; + padding-right: 4px; + margin-inline-start: 14px; + margin-inline-end: 3px; +} + +.why-paused .pause.reason { + display: flex; + flex-direction: column; + padding-right: 4px; +} + +.theme-dark .secondary-panes .why-paused { + background-color: hsl(42, 37%, 19%); + color: hsl(43, 94%, 81%); +} + +.why-paused .message { + font-style: italic; + font-weight: 100; +} + +.why-paused .mutationNode { + font-weight: normal; +} + +.why-paused .message.warning { + color: var(--theme-graphs-full-red); + font-family: var(--monospace-font-family); + font-size: 10px; + font-style: normal; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js new file mode 100644 index 0000000000..5123649f37 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js @@ -0,0 +1,183 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const { + LocalizationProvider, + Localized, +} = require("devtools/client/shared/vendor/fluent-react"); + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import AccessibleImage from "../shared/AccessibleImage"; +import actions from "../../actions"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, +} = Reps; + +import { getPauseReason } from "../../utils/pause"; +import { + getCurrentThread, + getPaneCollapse, + getPauseReason as getWhy, +} from "../../selectors"; + +import "./WhyPaused.css"; + +class WhyPaused extends PureComponent { + constructor(props) { + super(props); + this.state = { hideWhyPaused: "" }; + } + + static get propTypes() { + return { + delay: PropTypes.number.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + why: PropTypes.object, + }; + } + + componentDidUpdate() { + const { delay } = this.props; + + if (delay) { + setTimeout(() => { + this.setState({ hideWhyPaused: "" }); + }, delay); + } else { + this.setState({ hideWhyPaused: "pane why-paused" }); + } + } + + renderExceptionSummary(exception) { + if (typeof exception === "string") { + return exception; + } + + const { preview } = exception; + if (!preview || !preview.name || !preview.message) { + return null; + } + + return `${preview.name}: ${preview.message}`; + } + + renderMessage(why) { + const { type, exception, message } = why; + + if (type == "exception" && exception) { + // Our types for 'Why' are too general because 'type' can be 'string'. + // $FlowFixMe - We should have a proper discriminating union of reasons. + const summary = this.renderExceptionSummary(exception); + return <div className="message warning">{summary}</div>; + } + + if (type === "mutationBreakpoint" && why.nodeGrip) { + const { nodeGrip, ancestorGrip, action } = why; + const { + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const targetRep = Rep({ + object: nodeGrip, + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(nodeGrip), + onInspectIconClick: () => openElementInInspector(nodeGrip), + onDOMNodeMouseOver: () => highlightDomElement(nodeGrip), + onDOMNodeMouseOut: () => unHighlightDomElement(), + }); + + const ancestorRep = ancestorGrip + ? Rep({ + object: ancestorGrip, + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(ancestorGrip), + onInspectIconClick: () => openElementInInspector(ancestorGrip), + onDOMNodeMouseOver: () => highlightDomElement(ancestorGrip), + onDOMNodeMouseOut: () => unHighlightDomElement(), + }) + : null; + + return ( + <div> + <div className="message">{why.message}</div> + <div className="mutationNode"> + {ancestorRep} + {ancestorGrip ? ( + <span className="why-paused-ancestor"> + <Localized + id={ + action === "remove" + ? "whypaused-mutation-breakpoint-removed" + : "whypaused-mutation-breakpoint-added" + } + ></Localized> + {targetRep} + </span> + ) : ( + targetRep + )} + </div> + </div> + ); + } + + if (typeof message == "string") { + return <div className="message">{message}</div>; + } + + return null; + } + + render() { + const { endPanelCollapsed, why } = this.props; + const { fluentBundles } = this.context; + const reason = getPauseReason(why); + + if (!why || !reason || endPanelCollapsed) { + return <div className={this.state.hideWhyPaused} />; + } + return ( + // We're rendering the LocalizationProvider component from here and not in an upper + // component because it does set a new context, overriding the context that we set + // in the first place in <App>, which breaks some components. + // This should be fixed in Bug 1743155. + <LocalizationProvider bundles={fluentBundles || []}> + <div className="pane why-paused"> + <div> + <div className="info icon"> + <AccessibleImage className="info" /> + </div> + <div className="pause reason"> + <Localized id={reason}></Localized> + {this.renderMessage(why)} + </div> + </div> + </div> + </LocalizationProvider> + ); + } +} + +WhyPaused.contextTypes = { fluentBundles: PropTypes.array }; + +const mapStateToProps = state => ({ + endPanelCollapsed: getPaneCollapse(state, "end"), + why: getWhy(state, getCurrentThread(state)), +}); + +export default connect(mapStateToProps, { + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(WhyPaused); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css new file mode 100644 index 0000000000..5f0352a93c --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.xhr-breakpoints-pane ._content { + overflow-x: auto; +} + +.xhr-input-container { + display: flex; + border: 1px solid transparent; +} + +.xhr-input-container.focused { + border: 1px solid var(--theme-highlight-blue); +} + +:root.theme-dark .xhr-input-container.focused { + border: 1px solid var(--blue-50); +} + +.xhr-input-container.error { + border: 1px solid red; +} + +.xhr-input-form { + display: inline-flex; + width: 100%; + padding-inline-start: 20px; + padding-inline-end: 12px; + /* Stop select height from increasing as input height increases */ + align-items: center; +} + +.xhr-checkbox { + margin-inline-start: 0; + margin-inline-end: 4px; +} + +.xhr-input-url { + border: 1px; + flex-grow: 1; + background-color: var(--theme-sidebar-background); + font-size: inherit; + height: 24px; + color: var(--theme-body-color); +} + +.xhr-input-url::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.xhr-input-url:focus { + cursor: text; + outline: none; +} + +.expressions-list .xhr-input-container { + height: var(--expression-item-height); +} + +.expressions-list .xhr-input-url { + /* Prevent vertical bounce when editing an existing XHR Breakpoint */ + height: 100%; +} + +.xhr-container { + border-left: 4px solid transparent; + width: 100%; + color: var(--theme-body-color); + padding-inline-start: 16px; + padding-inline-end: 12px; + display: flex; + align-items: center; + position: relative; + height: var(--expression-item-height); +} + +:root.theme-light .xhr-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +:root.theme-dark .xhr-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +.xhr-label-method { + line-height: 14px; + display: inline-block; + margin-inline-end: 2px; +} + +.xhr-input-method { + display: none; + /* Vertically center select in form */ + margin-top: 2px; +} + +.expressions-list .xhr-input-method { + margin-top: 0px; +} + +.xhr-input-container.focused .xhr-input-method { + display: block; +} + +.xhr-label-url { + max-width: calc(100% - var(--breakpoint-expression-right-clear-space)); + color: var(--theme-comment); + display: inline-block; + cursor: text; + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + padding: 0px 2px 0px 2px; + line-height: 14px; +} + +.xhr-container label { + flex-grow: 1; + display: flex; + align-items: center; + overflow-x: hidden; +} + +.xhr-container__close-btn { + display: flex; + padding-top: 2px; + padding-bottom: 2px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js new file mode 100644 index 0000000000..721b132a3b --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js @@ -0,0 +1,361 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; + +import { CloseButton } from "../shared/Button"; + +import "./XHRBreakpoints.css"; +import { getXHRBreakpoints, shouldPauseOnAnyXHR } from "../../selectors"; +import ExceptionOption from "./Breakpoints/ExceptionOption"; + +const classnames = require("devtools/client/shared/classnames.js"); + +// At present, the "Pause on any URL" checkbox creates an xhrBreakpoint +// of "ANY" with no path, so we can remove that before creating the list +function getExplicitXHRBreakpoints(xhrBreakpoints) { + return xhrBreakpoints.filter(bp => bp.path !== ""); +} + +const xhrMethods = [ + "ANY", + "GET", + "POST", + "PUT", + "HEAD", + "DELETE", + "PATCH", + "OPTIONS", +]; + +class XHRBreakpoints extends Component { + constructor(props) { + super(props); + + this.state = { + editing: false, + inputValue: "", + inputMethod: "ANY", + focused: false, + editIndex: -1, + clickedOnFormElement: false, + }; + } + + static get propTypes() { + return { + disableXHRBreakpoint: PropTypes.func.isRequired, + enableXHRBreakpoint: PropTypes.func.isRequired, + onXHRAdded: PropTypes.func.isRequired, + removeXHRBreakpoint: PropTypes.func.isRequired, + setXHRBreakpoint: PropTypes.func.isRequired, + shouldPauseOnAny: PropTypes.bool.isRequired, + showInput: PropTypes.bool.isRequired, + togglePauseOnAny: PropTypes.func.isRequired, + updateXHRBreakpoint: PropTypes.func.isRequired, + xhrBreakpoints: PropTypes.array.isRequired, + }; + } + + componentDidMount() { + const { showInput } = this.props; + + // Ensures that the input is focused when the "+" + // is clicked while the panel is collapsed + if (this._input && showInput) { + this._input.focus(); + } + } + + componentDidUpdate(prevProps, prevState) { + const input = this._input; + + if (!input) { + return; + } + + if (!prevState.editing && this.state.editing) { + input.setSelectionRange(0, input.value.length); + input.focus(); + } else if (this.props.showInput && !this.state.focused) { + input.focus(); + } + } + + handleNewSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + + const setXHRBreakpoint = function () { + this.props.setXHRBreakpoint( + this.state.inputValue, + this.state.inputMethod + ); + this.hideInput(); + }; + + // force update inputMethod in state for mochitest purposes + // before setting XHR breakpoint + this.setState( + { inputMethod: e.target.children[1].value }, + setXHRBreakpoint + ); + }; + + handleExistingSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + + const { editIndex, inputValue, inputMethod } = this.state; + const { xhrBreakpoints } = this.props; + const { path, method } = xhrBreakpoints[editIndex]; + + if (path !== inputValue || method != inputMethod) { + this.props.updateXHRBreakpoint(editIndex, inputValue, inputMethod); + } + + this.hideInput(); + }; + + handleChange = e => { + this.setState({ inputValue: e.target.value }); + }; + + handleMethodChange = e => { + this.setState({ + focused: true, + editing: true, + inputMethod: e.target.value, + }); + }; + + hideInput = () => { + if (this.state.clickedOnFormElement) { + this.setState({ + focused: true, + clickedOnFormElement: false, + }); + } else { + this.setState({ + focused: false, + editing: false, + editIndex: -1, + inputValue: "", + inputMethod: "ANY", + }); + this.props.onXHRAdded(); + } + }; + + onFocus = () => { + this.setState({ focused: true, editing: true }); + }; + + onMouseDown = e => { + this.setState({ editing: false, clickedOnFormElement: true }); + }; + + handleTab = e => { + if (e.key !== "Tab") { + return; + } + + if (e.currentTarget.nodeName === "INPUT") { + this.setState({ + clickedOnFormElement: true, + editing: false, + }); + } else if (e.currentTarget.nodeName === "SELECT" && !e.shiftKey) { + // The user has tabbed off the select and we should + // cancel the edit + this.hideInput(); + } + }; + + editExpression = index => { + const { xhrBreakpoints } = this.props; + const { path, method } = xhrBreakpoints[index]; + this.setState({ + inputValue: path, + inputMethod: method, + editing: true, + editIndex: index, + }); + }; + + renderXHRInput(onSubmit) { + const { focused, inputValue } = this.state; + const placeholder = L10N.getStr("xhrBreakpoints.placeholder"); + + return ( + <form + key="xhr-input-container" + className={classnames("xhr-input-container xhr-input-form", { + focused, + })} + onSubmit={onSubmit} + > + <input + className="xhr-input-url" + type="text" + placeholder={placeholder} + onChange={this.handleChange} + onBlur={this.hideInput} + onFocus={this.onFocus} + value={inputValue} + onKeyDown={this.handleTab} + ref={c => (this._input = c)} + /> + {this.renderMethodSelectElement()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + handleCheckbox = index => { + const { xhrBreakpoints, enableXHRBreakpoint, disableXHRBreakpoint } = + this.props; + const breakpoint = xhrBreakpoints[index]; + if (breakpoint.disabled) { + enableXHRBreakpoint(index); + } else { + disableXHRBreakpoint(index); + } + }; + + renderBreakpoint = breakpoint => { + const { path, disabled, method } = breakpoint; + const { editIndex } = this.state; + const { removeXHRBreakpoint, xhrBreakpoints } = this.props; + + // The "pause on any" checkbox + if (!path) { + return null; + } + + // Finds the xhrbreakpoint so as to not make assumptions about position + const index = xhrBreakpoints.findIndex( + bp => bp.path === path && bp.method === method + ); + + if (index === editIndex) { + return this.renderXHRInput(this.handleExistingSubmit); + } + + return ( + <li + className="xhr-container" + key={`${path}-${method}`} + title={path} + onDoubleClick={(items, options) => this.editExpression(index)} + > + <label> + <input + type="checkbox" + className="xhr-checkbox" + checked={!disabled} + onChange={() => this.handleCheckbox(index)} + onClick={ev => ev.stopPropagation()} + /> + <div className="xhr-label-method">{method}</div> + <div className="xhr-label-url">{path}</div> + <div className="xhr-container__close-btn"> + <CloseButton handleClick={e => removeXHRBreakpoint(index)} /> + </div> + </label> + </li> + ); + }; + + renderBreakpoints = explicitXhrBreakpoints => { + const { showInput } = this.props; + + return ( + <> + <ul className="pane expressions-list"> + {explicitXhrBreakpoints.map(this.renderBreakpoint)} + </ul> + {showInput && this.renderXHRInput(this.handleNewSubmit)} + </> + ); + }; + + renderCheckbox = explicitXhrBreakpoints => { + const { shouldPauseOnAny, togglePauseOnAny } = this.props; + + return ( + <div + className={classnames("breakpoints-exceptions-options", { + empty: explicitXhrBreakpoints.length === 0, + })} + > + <ExceptionOption + className="breakpoints-exceptions" + label={L10N.getStr("pauseOnAnyXHR")} + isChecked={shouldPauseOnAny} + onChange={() => togglePauseOnAny()} + /> + </div> + ); + }; + + renderMethodOption = method => { + return ( + <option + key={method} + value={method} + // e.stopPropagation() required here since otherwise Firefox triggers 2x + // onMouseDown events on <select> upon clicking on an <option> + onMouseDown={e => e.stopPropagation()} + > + {method} + </option> + ); + }; + + renderMethodSelectElement = () => { + return ( + <select + value={this.state.inputMethod} + className="xhr-input-method" + onChange={this.handleMethodChange} + onMouseDown={this.onMouseDown} + onKeyDown={this.handleTab} + > + {xhrMethods.map(this.renderMethodOption)} + </select> + ); + }; + + render() { + const { xhrBreakpoints } = this.props; + const explicitXhrBreakpoints = getExplicitXHRBreakpoints(xhrBreakpoints); + + return ( + <> + {this.renderCheckbox(explicitXhrBreakpoints)} + {explicitXhrBreakpoints.length === 0 + ? this.renderXHRInput(this.handleNewSubmit) + : this.renderBreakpoints(explicitXhrBreakpoints)} + </> + ); + } +} + +const mapStateToProps = state => ({ + xhrBreakpoints: getXHRBreakpoints(state), + shouldPauseOnAny: shouldPauseOnAnyXHR(state), +}); + +export default connect(mapStateToProps, { + setXHRBreakpoint: actions.setXHRBreakpoint, + removeXHRBreakpoint: actions.removeXHRBreakpoint, + enableXHRBreakpoint: actions.enableXHRBreakpoint, + disableXHRBreakpoint: actions.disableXHRBreakpoint, + updateXHRBreakpoint: actions.updateXHRBreakpoint, + togglePauseOnAny: actions.togglePauseOnAny, +})(XHRBreakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/index.js b/devtools/client/debugger/src/components/SecondaryPanes/index.js new file mode 100644 index 0000000000..9b1e2dca60 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/index.js @@ -0,0 +1,537 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const SplitBox = require("devtools/client/shared/components/splitter/SplitBox"); + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { connect } from "../../utils/connect"; + +import actions from "../../actions"; +import { + getTopFrame, + getExpressions, + getPauseCommand, + isMapScopesEnabled, + getSelectedFrame, + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, + getThreads, + getCurrentThread, + getThreadContext, + getPauseReason, + getShouldBreakpointsPaneOpenOnPause, + getSkipPausing, + shouldLogEventBreakpoints, +} from "../../selectors"; + +import AccessibleImage from "../shared/AccessibleImage"; +import { prefs } from "../../utils/prefs"; + +import Breakpoints from "./Breakpoints"; +import Expressions from "./Expressions"; +import Frames from "./Frames"; +import Threads from "./Threads"; +import Accordion from "../shared/Accordion"; +import CommandBar from "./CommandBar"; +import XHRBreakpoints from "./XHRBreakpoints"; +import EventListeners from "./EventListeners"; +import DOMMutationBreakpoints from "./DOMMutationBreakpoints"; +import WhyPaused from "./WhyPaused"; + +import Scopes from "./Scopes"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./SecondaryPanes.css"; + +function debugBtn(onClick, type, className, tooltip) { + return ( + <button + onClick={onClick} + className={`${type} ${className}`} + key={type} + title={tooltip} + > + <AccessibleImage className={type} title={tooltip} aria-label={tooltip} /> + </button> + ); +} + +const mdnLink = + "https://firefox-source-docs.mozilla.org/devtools-user/debugger/using_the_debugger_map_scopes_feature/"; + +class SecondaryPanes extends Component { + constructor(props) { + super(props); + + this.state = { + showExpressionsInput: false, + showXHRInput: false, + }; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + evaluateExpressions: PropTypes.func.isRequired, + expressions: PropTypes.array.isRequired, + hasFrames: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + logEventBreakpoints: PropTypes.bool.isRequired, + mapScopesEnabled: PropTypes.bool.isRequired, + pauseOnExceptions: PropTypes.func.isRequired, + pauseReason: PropTypes.string.isRequired, + shouldBreakpointsPaneOpenOnPause: PropTypes.bool.isRequired, + thread: PropTypes.string.isRequired, + renderWhyPauseDelay: PropTypes.number.isRequired, + selectedFrame: PropTypes.object, + shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired, + shouldPauseOnExceptions: PropTypes.bool.isRequired, + skipPausing: PropTypes.bool.isRequired, + source: PropTypes.object, + toggleEventLogging: PropTypes.func.isRequired, + resetBreakpointsPaneState: PropTypes.func.isRequired, + toggleMapScopes: PropTypes.func.isRequired, + threads: PropTypes.array.isRequired, + removeAllBreakpoints: PropTypes.func.isRequired, + removeAllXHRBreakpoints: PropTypes.func.isRequired, + }; + } + + onExpressionAdded = () => { + this.setState({ showExpressionsInput: false }); + }; + + onXHRAdded = () => { + this.setState({ showXHRInput: false }); + }; + + watchExpressionHeaderButtons() { + const { expressions } = this.props; + const buttons = []; + + if (expressions.length) { + buttons.push( + debugBtn( + evt => { + evt.stopPropagation(); + this.props.evaluateExpressions(this.props.cx); + }, + "refresh", + "active", + L10N.getStr("watchExpressions.refreshButton") + ) + ); + } + buttons.push( + debugBtn( + evt => { + if (prefs.expressionsVisible) { + evt.stopPropagation(); + } + this.setState({ showExpressionsInput: true }); + }, + "plus", + "active", + L10N.getStr("expressions.placeholder") + ) + ); + return buttons; + } + + xhrBreakpointsHeaderButtons() { + return [ + debugBtn( + evt => { + if (prefs.xhrBreakpointsVisible) { + evt.stopPropagation(); + } + this.setState({ showXHRInput: true }); + }, + "plus", + "active", + L10N.getStr("xhrBreakpoints.label") + ), + + debugBtn( + evt => { + evt.stopPropagation(); + this.props.removeAllXHRBreakpoints(); + }, + "removeAll", + "active", + L10N.getStr("xhrBreakpoints.removeAll.tooltip") + ), + ]; + } + + breakpointsHeaderButtons() { + return [ + debugBtn( + evt => { + evt.stopPropagation(); + this.props.removeAllBreakpoints(this.props.cx); + }, + "removeAll", + "active", + L10N.getStr("breakpointMenuItem.deleteAll") + ), + ]; + } + + getScopeItem() { + return { + header: L10N.getStr("scopes.header"), + className: "scopes-pane", + component: <Scopes />, + opened: prefs.scopesVisible, + buttons: this.getScopesButtons(), + onToggle: opened => { + prefs.scopesVisible = opened; + }, + }; + } + + getScopesButtons() { + const { selectedFrame, mapScopesEnabled, source } = this.props; + + if ( + !selectedFrame || + isGeneratedId(selectedFrame.location.sourceId) || + source?.isPrettyPrinted + ) { + return null; + } + + return [ + <div key="scopes-buttons"> + <label + className="map-scopes-header" + title={L10N.getStr("scopes.mapping.label")} + onClick={e => e.stopPropagation()} + > + <input + type="checkbox" + checked={mapScopesEnabled ? "checked" : ""} + onChange={e => this.props.toggleMapScopes()} + /> + {L10N.getStr("scopes.map.label")} + </label> + <a + className="mdn" + target="_blank" + href={mdnLink} + onClick={e => e.stopPropagation()} + title={L10N.getStr("scopes.helpTooltip.label")} + > + <AccessibleImage className="shortcuts" /> + </a> + </div>, + ]; + } + + getEventButtons() { + const { logEventBreakpoints } = this.props; + return [ + <div key="events-buttons"> + <label + className="events-header" + title={L10N.getStr("eventlisteners.log.label")} + onClick={e => e.stopPropagation()} + > + <input + type="checkbox" + checked={logEventBreakpoints ? "checked" : ""} + onChange={e => this.props.toggleEventLogging()} + onKeyDown={e => e.stopPropagation()} + /> + {L10N.getStr("eventlisteners.log")} + </label> + </div>, + ]; + } + + getWatchItem() { + return { + header: L10N.getStr("watchExpressions.header"), + className: "watch-expressions-pane", + buttons: this.watchExpressionHeaderButtons(), + component: ( + <Expressions + showInput={this.state.showExpressionsInput} + onExpressionAdded={this.onExpressionAdded} + /> + ), + opened: prefs.expressionsVisible, + onToggle: opened => { + prefs.expressionsVisible = opened; + }, + }; + } + + getXHRItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("xhrBreakpoints.header"), + className: "xhr-breakpoints-pane", + buttons: this.xhrBreakpointsHeaderButtons(), + component: ( + <XHRBreakpoints + showInput={this.state.showXHRInput} + onXHRAdded={this.onXHRAdded} + /> + ), + opened: prefs.xhrBreakpointsVisible || pauseReason === "XHR", + onToggle: opened => { + prefs.xhrBreakpointsVisible = opened; + }, + }; + } + + getCallStackItem() { + return { + header: L10N.getStr("callStack.header"), + className: "call-stack-pane", + component: <Frames panel="debugger" />, + opened: prefs.callStackVisible, + onToggle: opened => { + prefs.callStackVisible = opened; + }, + }; + } + + getThreadsItem() { + return { + header: L10N.getStr("threadsHeader"), + className: "threads-pane", + component: <Threads />, + opened: prefs.threadsVisible, + onToggle: opened => { + prefs.threadsVisible = opened; + }, + }; + } + + getBreakpointsItem() { + const { + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + pauseOnExceptions, + pauseReason, + shouldBreakpointsPaneOpenOnPause, + thread, + } = this.props; + + return { + header: L10N.getStr("breakpoints.header"), + className: "breakpoints-pane", + buttons: this.breakpointsHeaderButtons(), + component: ( + <Breakpoints + shouldPauseOnExceptions={shouldPauseOnExceptions} + shouldPauseOnCaughtExceptions={shouldPauseOnCaughtExceptions} + pauseOnExceptions={pauseOnExceptions} + /> + ), + opened: + prefs.breakpointsVisible || + (pauseReason === "breakpoint" && shouldBreakpointsPaneOpenOnPause), + onToggle: opened => { + prefs.breakpointsVisible = opened; + // one-shot flag used to force open the Breakpoints Pane only + // when hitting a breakpoint, but not when selecting frames etc... + if (shouldBreakpointsPaneOpenOnPause) { + this.props.resetBreakpointsPaneState(thread); + } + }, + }; + } + + getEventListenersItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("eventListenersHeader1"), + className: "event-listeners-pane", + buttons: this.getEventButtons(), + component: <EventListeners />, + opened: prefs.eventListenersVisible || pauseReason === "eventBreakpoint", + onToggle: opened => { + prefs.eventListenersVisible = opened; + }, + }; + } + + getDOMMutationsItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("domMutationHeader"), + className: "dom-mutations-pane", + buttons: [], + component: <DOMMutationBreakpoints />, + opened: + prefs.domMutationBreakpointsVisible || + pauseReason === "mutationBreakpoint", + onToggle: opened => { + prefs.domMutationBreakpointsVisible = opened; + }, + }; + } + + getStartItems() { + const items = []; + const { horizontal, hasFrames } = this.props; + + if (horizontal) { + if (this.props.threads.length) { + items.push(this.getThreadsItem()); + } + + items.push(this.getWatchItem()); + } + + items.push(this.getBreakpointsItem()); + + if (hasFrames) { + items.push(this.getCallStackItem()); + if (horizontal) { + items.push(this.getScopeItem()); + } + } + + items.push(this.getXHRItem()); + + items.push(this.getEventListenersItem()); + + items.push(this.getDOMMutationsItem()); + + return items; + } + + getEndItems() { + if (this.props.horizontal) { + return []; + } + + const items = []; + if (this.props.threads.length) { + items.push(this.getThreadsItem()); + } + + items.push(this.getWatchItem()); + + if (this.props.hasFrames) { + items.push(this.getScopeItem()); + } + + return items; + } + + getItems() { + return [...this.getStartItems(), ...this.getEndItems()]; + } + + renderHorizontalLayout() { + const { renderWhyPauseDelay } = this.props; + + return ( + <div> + <WhyPaused delay={renderWhyPauseDelay} /> + <Accordion items={this.getItems()} /> + </div> + ); + } + + renderVerticalLayout() { + return ( + <SplitBox + initialSize="300px" + minSize={10} + maxSize="50%" + splitterSize={1} + startPanel={ + <div style={{ width: "inherit" }}> + <WhyPaused delay={this.props.renderWhyPauseDelay} /> + <Accordion items={this.getStartItems()} /> + </div> + } + endPanel={<Accordion items={this.getEndItems()} />} + /> + ); + } + + render() { + const { skipPausing } = this.props; + return ( + <div className="secondary-panes-wrapper"> + <CommandBar horizontal={this.props.horizontal} /> + <div + className={classnames( + "secondary-panes", + skipPausing && "skip-pausing" + )} + > + {this.props.horizontal + ? this.renderHorizontalLayout() + : this.renderVerticalLayout()} + </div> + </div> + ); + } +} + +// Checks if user is in debugging mode and adds a delay preventing +// excessive vertical 'jumpiness' +function getRenderWhyPauseDelay(state, thread) { + const inPauseCommand = !!getPauseCommand(state, thread); + + if (!inPauseCommand) { + return 100; + } + + return 0; +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + const selectedFrame = getSelectedFrame(state, thread); + const pauseReason = getPauseReason(state, thread); + const shouldBreakpointsPaneOpenOnPause = getShouldBreakpointsPaneOpenOnPause( + state, + thread + ); + + return { + cx: getThreadContext(state), + expressions: getExpressions(state), + hasFrames: !!getTopFrame(state, thread), + renderWhyPauseDelay: getRenderWhyPauseDelay(state, thread), + selectedFrame, + mapScopesEnabled: isMapScopesEnabled(state), + shouldPauseOnExceptions: getShouldPauseOnExceptions(state), + shouldPauseOnCaughtExceptions: getShouldPauseOnCaughtExceptions(state), + threads: getThreads(state), + skipPausing: getSkipPausing(state), + logEventBreakpoints: shouldLogEventBreakpoints(state), + source: selectedFrame && selectedFrame.location.source, + pauseReason: pauseReason?.type ?? "", + shouldBreakpointsPaneOpenOnPause, + thread, + }; +}; + +export default connect(mapStateToProps, { + evaluateExpressions: actions.evaluateExpressions, + pauseOnExceptions: actions.pauseOnExceptions, + toggleMapScopes: actions.toggleMapScopes, + breakOnNext: actions.breakOnNext, + toggleEventLogging: actions.toggleEventLogging, + removeAllBreakpoints: actions.removeAllBreakpoints, + removeAllXHRBreakpoints: actions.removeAllXHRBreakpoints, + resetBreakpointsPaneState: actions.resetBreakpointsPaneState, +})(SecondaryPanes); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/moz.build new file mode 100644 index 0000000000..33cfa2e316 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/moz.build @@ -0,0 +1,22 @@ +# 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 += [ + "Breakpoints", + "Frames", +] + +CompiledModules( + "CommandBar.js", + "DOMMutationBreakpoints.js", + "EventListeners.js", + "Expressions.js", + "index.js", + "Scopes.js", + "Thread.js", + "Threads.js", + "WhyPaused.js", + "XHRBreakpoints.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js new file mode 100644 index 0000000000..69dd75a187 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import CommandBar from "../CommandBar"; +import { mockthreadcx } from "../../../utils/test-mockup"; + +describe("CommandBar", () => { + it("f8 key command calls props.breakOnNext when not in paused state", () => { + const props = { + cx: mockthreadcx, + breakOnNext: jest.fn(), + resume: jest.fn(), + isPaused: false, + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + // The "on" spy will see all the keyboard listeners being registered by + // the shortcuts.on function + const context = { shortcuts: { on: jest.fn() } }; + + shallow(<CommandBar.WrappedComponent {...props} />, { context }); + + // get the keyboard event listeners recorded from the "on" spy. + // this will be an array where each item is itself a two item array + // containing the key code and the corresponding handler for that key code + const keyEventHandlers = context.shortcuts.on.mock.calls; + + // simulate pressing the F8 key by calling the F8 handlers + keyEventHandlers + .filter(i => i[0] === "F8") + .forEach(([_, handler]) => { + handler(mockEvent); + }); + + expect(props.breakOnNext).toHaveBeenCalled(); + expect(props.resume).not.toHaveBeenCalled(); + }); + + it("f8 key command calls props.resume when in paused state", () => { + const props = { + cx: { ...mockthreadcx, isPaused: true }, + breakOnNext: jest.fn(), + resume: jest.fn(), + isPaused: true, + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + // The "on" spy will see all the keyboard listeners being registered by + // the shortcuts.on function + const context = { shortcuts: { on: jest.fn() } }; + + shallow(<CommandBar.WrappedComponent {...props} />, { context }); + + // get the keyboard event listeners recorded from the "on" spy. + // this will be an array where each item is itself a two item array + // containing the key code and the corresponding handler for that key code + const keyEventHandlers = context.shortcuts.on.mock.calls; + + // simulate pressing the F8 key by calling the F8 handlers + keyEventHandlers + .filter(i => i[0] === "F8") + .forEach(([_, handler]) => { + handler(mockEvent); + }); + expect(props.resume).toHaveBeenCalled(); + expect(props.breakOnNext).not.toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js new file mode 100644 index 0000000000..f82b2093c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.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 React from "react"; +import { shallow } from "enzyme"; +import EventListeners from "../EventListeners"; + +function getCategories() { + return [ + { + name: "Category 1", + events: [ + { name: "Subcategory 1", id: "category1.subcategory1" }, + { name: "Subcategory 2", id: "category1.subcategory2" }, + ], + }, + { + name: "Category 2", + events: [ + { name: "Subcategory 3", id: "category2.subcategory1" }, + { name: "Subcategory 4", id: "category2.subcategory2" }, + ], + }, + ]; +} + +function generateDefaults(overrides = {}) { + const defaults = { + activeEventListeners: [], + expandedCategories: [], + categories: [], + }; + + return { ...defaults, ...overrides }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<EventListeners.WrappedComponent {...props} />); + return { component, props }; +} + +describe("EventListeners", () => { + it("should render", async () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("should render categories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should render expanded categories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + expandedCategories: ["Category 2"], + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should render checked subcategories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + activeEventListeners: ["category1.subcategory2"], + expandedCategories: ["Category 1"], + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should filter the event listeners based on the event name", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Subcategory 3" to display just one event which + // will be the Subcategory 3 event + searchInput.simulate("change", { + currentTarget: { value: "Subcategory 3" }, + }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(1); + }); + + it("should filter the event listeners based on the category name", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Category 1" to display two events which will be + // the Subcategory 1 event and the Subcategory 2 event + searchInput.simulate("change", { currentTarget: { value: "Category 1" } }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(2); + }); + + it("should be case insensitive when filtering events and categories", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Subcategory 3" to display just one event which + // will be the Subcategory 3 event + searchInput.simulate("change", { + currentTarget: { value: "sUbCaTeGoRy 3" }, + }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(1); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js new file mode 100644 index 0000000000..ad14190276 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import Expressions from "../Expressions"; + +function generateDefaults(overrides) { + return { + evaluateExpressions: async () => {}, + expressions: [ + { + input: "expression1", + value: { + result: { + value: "foo", + class: "", + }, + }, + }, + { + input: "expression2", + value: { + result: { + value: "bar", + class: "", + }, + }, + }, + ], + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<Expressions.WrappedComponent {...props} />); + return { component, props }; +} + +describe("Expressions", () => { + it("should render", async () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("should always have unique keys", async () => { + const overrides = { + expressions: [ + { + input: "expression1", + value: { + result: { + value: undefined, + class: "", + }, + }, + }, + { + input: "expression2", + value: { + result: { + value: undefined, + class: "", + }, + }, + }, + ], + }; + + const { component } = render(overrides); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js new file mode 100644 index 0000000000..e269e89ac5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js @@ -0,0 +1,345 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { mount } from "enzyme"; +import XHRBreakpoints from "../XHRBreakpoints"; + +const xhrMethods = [ + "ANY", + "GET", + "POST", + "PUT", + "HEAD", + "DELETE", + "PATCH", + "OPTIONS", +]; + +// default state includes xhrBreakpoints[0] which is the checkbox that +// enables breaking on any url during an XMLHTTPRequest +function generateDefaultState(propsOverride) { + return { + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + ], + enableXHRBreakpoint: () => {}, + disableXHRBreakpoint: () => {}, + updateXHRBreakpoint: () => {}, + removeXHRBreakpoint: () => {}, + setXHRBreakpoint: () => {}, + togglePauseOnAny: () => {}, + showInput: false, + shouldPauseOnAny: false, + onXHRAdded: () => {}, + ...propsOverride, + }; +} + +function renderXHRBreakpointsComponent(propsOverride) { + const props = generateDefaultState(propsOverride); + const xhrBreakpointsComponent = mount( + <XHRBreakpoints.WrappedComponent {...props} /> + ); + return xhrBreakpointsComponent; +} + +describe("XHR Breakpoints", function () { + it("should render with 0 expressions passed from props", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + expect(xhrBreakpointsComponent).toMatchSnapshot(); + }); + + it("should render with 8 expressions passed from props", function () { + const allXHRBreakpointMethods = { + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + { + path: "this is any", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains "this is any"', + }, + { + path: "this is get", + method: "GET", + disabled: false, + loading: false, + text: 'URL contains "this is get"', + }, + { + path: "this is post", + method: "POST", + disabled: false, + loading: false, + text: 'URL contains "this is post"', + }, + { + path: "this is put", + method: "PUT", + disabled: false, + loading: false, + text: 'URL contains "this is put"', + }, + { + path: "this is head", + method: "HEAD", + disabled: false, + loading: false, + text: 'URL contains "this is head"', + }, + { + path: "this is delete", + method: "DELETE", + disabled: false, + loading: false, + text: 'URL contains "this is delete"', + }, + { + path: "this is patch", + method: "PATCH", + disabled: false, + loading: false, + text: 'URL contains "this is patch"', + }, + { + path: "this is options", + method: "OPTIONS", + disabled: false, + loading: false, + text: 'URL contains "this is options"', + }, + ], + }; + + const xhrBreakpointsComponent = renderXHRBreakpointsComponent( + allXHRBreakpointMethods + ); + expect(xhrBreakpointsComponent).toMatchSnapshot(); + }); + + it("should display xhr-input-method on click", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + const xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + }); + + it("should have focused and editing default to false", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + expect(xhrBreakpointsComponent.state("focused")).toBe(false); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + }); + + it("should have state {..focused: true, editing: true} on focus", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + }); + + // shifting focus from .xhr-input to any other element apart from + // .xhr-input-method should unrender .xhr-input-method + it("shifting focus should unrender XHR methods", function () { + const propsOverride = { + onXHRAdded: jest.fn, + togglePauseOnAny: jest.fn, + }; + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + let xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + + xhrBreakpointsComponent + .find(".breakpoints-exceptions-options") + .simulate("mousedown"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur"); + expect(xhrBreakpointsComponent.state("focused")).toBe(false); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent + .find(".breakpoints-exceptions-options") + .simulate("click"); + + xhrInputContainer = xhrBreakpointsComponent.find(".xhr-input-container"); + expect(xhrInputContainer.hasClass("focused")).not.toBeTruthy(); + }); + + // shifting focus from .xhr-input to .xhr-input-method + // should not unrender .xhr-input-method + it("shifting focus to XHR methods should not unrender", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(true); + + xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent.find(".xhr-input-method").simulate("click"); + const xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + }); + + it("should have all 8 methods available as options", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + expect(xhrInputMethod.children()).toHaveLength(8); + + const actualXHRMethods = []; + const expectedXHRMethods = xhrMethods; + + // fill the actualXHRMethods array with actual methods displayed in DOM + for (let i = 0; i < xhrInputMethod.children().length; i++) { + actualXHRMethods.push(xhrInputMethod.childAt(i).key()); + } + + // check each expected XHR Method to see if they match the actual methods + expectedXHRMethods.forEach((expectedMethod, i) => { + function compareMethods(actualMethod) { + return expectedMethod === actualMethod; + } + expect(actualXHRMethods.find(compareMethods)).toBeTruthy(); + }); + }); + + it("should return focus to input box after selecting a method", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + + // focus starts off at .xhr-input + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + // click on method options and select GET + const methodEvent = { target: { value: "GET" } }; + xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown"); + expect(xhrBreakpointsComponent.state("inputMethod")).toBe("ANY"); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + xhrBreakpointsComponent + .find(".xhr-input-method") + .simulate("change", methodEvent); + + // if state.editing changes from false to true, infer that + // this._input.focus() is called, which shifts focus back to input box + expect(xhrBreakpointsComponent.state("inputMethod")).toBe("GET"); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + }); + + it("should submit the URL and method when adding a breakpoint", function () { + const setXHRBreakpointCallback = jest.fn(); + const propsOverride = { + setXHRBreakpoint: setXHRBreakpointCallback, + onXHRAdded: jest.fn(), + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const availableXHRMethods = xhrMethods; + expect(!!availableXHRMethods.length).toBeTruthy(); + + // check each of the available methods to see whether + // adding them as a method to a new breakpoint works as expected + availableXHRMethods.forEach(function (method) { + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + const urlValue = `${method.toLowerCase()}URLValue`; + + // simulate DOM event adding urlValue to .xhr-input + const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url"); + xhrInput.simulate("change", { target: { value: urlValue } }); + + // simulate DOM event adding the input method to .xhr-input-method + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + xhrInputMethod.simulate("change", { target: { value: method } }); + + xhrBreakpointsComponent.find("form").simulate("submit", mockEvent); + expect(setXHRBreakpointCallback).toHaveBeenCalledWith(urlValue, method); + }); + }); + + it("should submit the URL and method when editing a breakpoint", function () { + const setXHRBreakpointCallback = jest.fn(); + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const propsOverride = { + updateXHRBreakpoint: setXHRBreakpointCallback, + onXHRAdded: jest.fn(), + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + { + path: "this is GET", + method: "GET", + disabled: false, + loading: false, + text: 'URL contains "this is get"', + }, + ], + }; + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + + // load xhrBreakpoints pane with one existing xhrBreakpoint + const existingXHRbreakpoint = + xhrBreakpointsComponent.find(".xhr-container"); + expect(existingXHRbreakpoint).toHaveLength(1); + + // double click on existing breakpoint + existingXHRbreakpoint.simulate("doubleclick"); + const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url"); + xhrInput.simulate("focus"); + + // change inputs and submit form + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + xhrInput.simulate("change", { target: { value: "POSTURLValue" } }); + xhrInputMethod.simulate("change", { target: { value: "POST" } }); + xhrBreakpointsComponent.find("form").simulate("submit", mockEvent); + expect(setXHRBreakpointCallback).toHaveBeenCalledWith( + 1, + "POSTURLValue", + "POST" + ); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap new file mode 100644 index 0000000000..cc2ddf09f6 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap @@ -0,0 +1,408 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EventListeners should render 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + /> + </div> +</div> +`; + +exports[`EventListeners should render categories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + </li> + </ul> + </div> +</div> +`; + +exports[`EventListeners should render checked subcategories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow expanded" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + <ul> + <li + className="event-listener-event" + key="category1.subcategory1" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category1.subcategory1" + /> + <span + className="event-listener-name" + > + Subcategory 1 + </span> + </label> + </li> + <li + className="event-listener-event" + key="category1.subcategory2" + > + <label + className="event-listener-label" + > + <input + checked={true} + onChange={[Function]} + type="checkbox" + value="category1.subcategory2" + /> + <span + className="event-listener-name" + > + Subcategory 2 + </span> + </label> + </li> + </ul> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + </li> + </ul> + </div> +</div> +`; + +exports[`EventListeners should render expanded categories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow expanded" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + <ul> + <li + className="event-listener-event" + key="category2.subcategory1" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category2.subcategory1" + /> + <span + className="event-listener-name" + > + Subcategory 3 + </span> + </label> + </li> + <li + className="event-listener-event" + key="category2.subcategory2" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category2.subcategory2" + /> + <span + className="event-listener-name" + > + Subcategory 4 + </span> + </label> + </li> + </ul> + </li> + </ul> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap new file mode 100644 index 0000000000..4869b15a73 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap @@ -0,0 +1,199 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Expressions should always have unique keys 1`] = ` +<Fragment> + <ul + className="pane expressions-list" + > + <li + className="expression-container" + key="expression1" + title="expression1" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": undefined, + }, + }, + "name": "expression1", + "path": "expression1", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + <li + className="expression-container" + key="expression2" + title="expression2" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": undefined, + }, + }, + "name": "expression2", + "path": "expression2", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + </ul> +</Fragment> +`; + +exports[`Expressions should render 1`] = ` +<Fragment> + <ul + className="pane expressions-list" + > + <li + className="expression-container" + key="expression1" + title="expression1" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": "foo", + }, + }, + "name": "expression1", + "path": "expression1", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + <li + className="expression-container" + key="expression2" + title="expression2" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": "bar", + }, + }, + "name": "expression2", + "path": "expression2", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + </ul> +</Fragment> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap new file mode 100644 index 0000000000..5611f6ceef --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap @@ -0,0 +1,621 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`XHR Breakpoints should render with 0 expressions passed from props 1`] = ` +<XHRBreakpoints + disableXHRBreakpoint={[Function]} + enableXHRBreakpoint={[Function]} + onXHRAdded={[Function]} + removeXHRBreakpoint={[Function]} + setXHRBreakpoint={[Function]} + shouldPauseOnAny={false} + showInput={false} + togglePauseOnAny={[Function]} + updateXHRBreakpoint={[Function]} + xhrBreakpoints={ + Array [ + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "", + "text": "URL contains \\"\\"", + }, + ] + } +> + <div + className="breakpoints-exceptions-options empty" + > + <ExceptionOption + className="breakpoints-exceptions" + isChecked={false} + label="Pause on any URL" + onChange={[Function]} + > + <div + className="breakpoints-exceptions" + onClick={[Function]} + > + <input + checked="" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + Pause on any URL + </div> + </div> + </ExceptionOption> + </div> + <form + className="xhr-input-container xhr-input-form" + key="xhr-input-container" + onSubmit={[Function]} + > + <input + className="xhr-input-url" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Break when URL contains" + type="text" + value="" + /> + <select + className="xhr-input-method" + onChange={[Function]} + onKeyDown={[Function]} + onMouseDown={[Function]} + value="ANY" + > + <option + key="ANY" + onMouseDown={[Function]} + value="ANY" + > + ANY + </option> + <option + key="GET" + onMouseDown={[Function]} + value="GET" + > + GET + </option> + <option + key="POST" + onMouseDown={[Function]} + value="POST" + > + POST + </option> + <option + key="PUT" + onMouseDown={[Function]} + value="PUT" + > + PUT + </option> + <option + key="HEAD" + onMouseDown={[Function]} + value="HEAD" + > + HEAD + </option> + <option + key="DELETE" + onMouseDown={[Function]} + value="DELETE" + > + DELETE + </option> + <option + key="PATCH" + onMouseDown={[Function]} + value="PATCH" + > + PATCH + </option> + <option + key="OPTIONS" + onMouseDown={[Function]} + value="OPTIONS" + > + OPTIONS + </option> + </select> + <input + style={ + Object { + "display": "none", + } + } + type="submit" + /> + </form> +</XHRBreakpoints> +`; + +exports[`XHR Breakpoints should render with 8 expressions passed from props 1`] = ` +<XHRBreakpoints + disableXHRBreakpoint={[Function]} + enableXHRBreakpoint={[Function]} + onXHRAdded={[Function]} + removeXHRBreakpoint={[Function]} + setXHRBreakpoint={[Function]} + shouldPauseOnAny={false} + showInput={false} + togglePauseOnAny={[Function]} + updateXHRBreakpoint={[Function]} + xhrBreakpoints={ + Array [ + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "", + "text": "URL contains \\"\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "this is any", + "text": "URL contains \\"this is any\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "GET", + "path": "this is get", + "text": "URL contains \\"this is get\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "POST", + "path": "this is post", + "text": "URL contains \\"this is post\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "PUT", + "path": "this is put", + "text": "URL contains \\"this is put\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "HEAD", + "path": "this is head", + "text": "URL contains \\"this is head\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "DELETE", + "path": "this is delete", + "text": "URL contains \\"this is delete\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "PATCH", + "path": "this is patch", + "text": "URL contains \\"this is patch\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "OPTIONS", + "path": "this is options", + "text": "URL contains \\"this is options\\"", + }, + ] + } +> + <div + className="breakpoints-exceptions-options" + > + <ExceptionOption + className="breakpoints-exceptions" + isChecked={false} + label="Pause on any URL" + onChange={[Function]} + > + <div + className="breakpoints-exceptions" + onClick={[Function]} + > + <input + checked="" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + Pause on any URL + </div> + </div> + </ExceptionOption> + </div> + <ul + className="pane expressions-list" + > + <li + className="xhr-container" + key="this is any-ANY" + onDoubleClick={[Function]} + title="this is any" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + ANY + </div> + <div + className="xhr-label-url" + > + this is any + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is get-GET" + onDoubleClick={[Function]} + title="this is get" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + GET + </div> + <div + className="xhr-label-url" + > + this is get + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is post-POST" + onDoubleClick={[Function]} + title="this is post" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + POST + </div> + <div + className="xhr-label-url" + > + this is post + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is put-PUT" + onDoubleClick={[Function]} + title="this is put" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + PUT + </div> + <div + className="xhr-label-url" + > + this is put + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is head-HEAD" + onDoubleClick={[Function]} + title="this is head" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + HEAD + </div> + <div + className="xhr-label-url" + > + this is head + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is delete-DELETE" + onDoubleClick={[Function]} + title="this is delete" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + DELETE + </div> + <div + className="xhr-label-url" + > + this is delete + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is patch-PATCH" + onDoubleClick={[Function]} + title="this is patch" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + PATCH + </div> + <div + className="xhr-label-url" + > + this is patch + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is options-OPTIONS" + onDoubleClick={[Function]} + title="this is options" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + OPTIONS + </div> + <div + className="xhr-label-url" + > + this is options + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + </ul> +</XHRBreakpoints> +`; diff --git a/devtools/client/debugger/src/components/ShortcutsModal.css b/devtools/client/debugger/src/components/ShortcutsModal.css new file mode 100644 index 0000000000..84024f9677 --- /dev/null +++ b/devtools/client/debugger/src/components/ShortcutsModal.css @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.shortcuts-content { + padding: 15px; + column-width: 250px; + cursor: default; + user-select: none; +} + +.shortcuts-content h2 { + margin-top: 2px; + margin-bottom: 2px; + color: var(--theme-text-color-strong); +} + +.shortcuts-section { + display: inline-block; + margin: 5px; + margin-bottom: 15px; + width: 250px; +} + +.shortcuts-list { + list-style: none; + margin: 0px; + padding: 0px; + overflow: auto; + width: calc(100% - 1px); /* 1px fixes the hidden right border */ +} + +.shortcuts-list li { + font-size: 12px; + color: var(--theme-body-color); + padding-top: 5px; + display: flex; + justify-content: space-between; + border: 1px solid transparent; + white-space: pre; +} + +@media (max-width: 640px) { + .shortcuts-section { + width: 100%; + } +} diff --git a/devtools/client/debugger/src/components/ShortcutsModal.js b/devtools/client/debugger/src/components/ShortcutsModal.js new file mode 100644 index 0000000000..fd9696e93f --- /dev/null +++ b/devtools/client/debugger/src/components/ShortcutsModal.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import Modal from "./shared/Modal"; +import { formatKeyShortcut } from "../utils/text"; +const classnames = require("devtools/client/shared/classnames.js"); + +import "./ShortcutsModal.css"; + +const isMacOS = Services.appinfo.OS === "Darwin"; + +export class ShortcutsModal extends Component { + static get propTypes() { + return { + enabled: PropTypes.bool.isRequired, + handleClose: PropTypes.func.isRequired, + }; + } + + renderPrettyCombos(combo) { + return combo + .split(" ") + .map(c => ( + <span key={c} className="keystroke"> + {c} + </span> + )) + .reduce((prev, curr) => [prev, " + ", curr]); + } + + renderShorcutItem(title, combo) { + return ( + <li> + <span>{title}</span> + <span>{this.renderPrettyCombos(combo)}</span> + </li> + ); + } + + renderEditorShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleBreakpoint"), + formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleCondPanel.breakpoint"), + formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleCondPanel.logPoint"), + formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")) + )} + </ul> + ); + } + + renderSteppingShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem(L10N.getStr("shortcuts.pauseOrResume"), "F8")} + {this.renderShorcutItem(L10N.getStr("shortcuts.stepOver"), "F10")} + {this.renderShorcutItem(L10N.getStr("shortcuts.stepIn"), "F11")} + {this.renderShorcutItem( + L10N.getStr("shortcuts.stepOut"), + formatKeyShortcut(L10N.getStr("stepOut.key")) + )} + </ul> + ); + } + + renderSearchShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem( + L10N.getStr("shortcuts.fileSearch2"), + formatKeyShortcut(L10N.getStr("sources.search.key2")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.projectSearch2"), + formatKeyShortcut(L10N.getStr("projectTextSearch.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.functionSearch2"), + formatKeyShortcut(L10N.getStr("functionSearch.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.gotoLine"), + formatKeyShortcut(L10N.getStr("gotoLineModal.key3")) + )} + </ul> + ); + } + + renderShortcutsContent() { + return ( + <div className={classnames("shortcuts-content", isMacOS ? "mac" : "")}> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.editor")}</h2> + {this.renderEditorShortcuts()} + </div> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.stepping")}</h2> + {this.renderSteppingShortcuts()} + </div> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.search")}</h2> + {this.renderSearchShortcuts()} + </div> + </div> + ); + } + + render() { + const { enabled } = this.props; + + if (!enabled) { + return null; + } + + return ( + <Modal + in={enabled} + additionalClass="shortcuts-modal" + handleClose={this.props.handleClose} + > + {this.renderShortcutsContent()} + </Modal> + ); + } +} diff --git a/devtools/client/debugger/src/components/WelcomeBox.css b/devtools/client/debugger/src/components/WelcomeBox.css new file mode 100644 index 0000000000..a0932625ae --- /dev/null +++ b/devtools/client/debugger/src/components/WelcomeBox.css @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.welcomebox { + position: absolute; + top: var(--editor-header-height); + left: 0; + bottom: var(--editor-footer-height); + width: calc(100% - 1px); + padding: 10vh 0; + background-color: var(--theme-toolbar-background); + overflow: hidden; + font-weight: 300; + z-index: 10; + user-select: none; +} + +.theme-dark .welcomebox { + background-color: var(--theme-body-background); +} + +.alignlabel { + display: flex; + white-space: nowrap; + font-size: 1.25em; +} + +.shortcutKey, +.shortcutLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.welcomebox__searchSources:hover, +.welcomebox__searchProject:hover, +.welcomebox__allShortcuts:hover { + color: var(--theme-body-color); +} + +.shortcutKey { + direction: ltr; + text-align: right; + padding-right: 10px; + font-family: var(--monospace-font-family); + font-size: 14px; + line-height: 18px; + color: var(--theme-body-color); +} + +.shortcutKey:dir(rtl) { + text-align: left; +} + +:root[platform="mac"] .welcomebox .shortcutKey { + font-family: system-ui, -apple-system, sans-serif; + font-weight: 500; +} + +.shortcutLabel { + text-align: start; + padding-left: 10px; + font-size: 14px; + line-height: 18px; +} + +.shortcutFunction { + margin: 0 auto; + color: var(--theme-comment); + display: table; +} + +.shortcutFunction p { + display: table-row; +} + +.shortcutFunction .shortcutKey, +.shortcutFunction .shortcutLabel { + padding: 10px 5px; + display: table-cell; +} diff --git a/devtools/client/debugger/src/components/WelcomeBox.js b/devtools/client/debugger/src/components/WelcomeBox.js new file mode 100644 index 0000000000..18567d31f7 --- /dev/null +++ b/devtools/client/debugger/src/components/WelcomeBox.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../utils/connect"; +import { primaryPaneTabs } from "../constants"; + +import actions from "../actions"; +import { getPaneCollapse } from "../selectors"; +import { formatKeyShortcut } from "../utils/text"; + +import "./WelcomeBox.css"; + +export class WelcomeBox extends Component { + static get propTypes() { + return { + openQuickOpen: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + toggleShortcutsModal: PropTypes.func.isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + }; + } + + render() { + const searchSourcesShortcut = formatKeyShortcut( + L10N.getStr("sources.search.key2") + ); + + const searchProjectShortcut = formatKeyShortcut( + L10N.getStr("projectTextSearch.key") + ); + + const allShortcutsShortcut = formatKeyShortcut( + L10N.getStr("allShortcut.key") + ); + + const allShortcutsLabel = L10N.getStr("welcome.allShortcuts"); + const searchSourcesLabel = L10N.getStr("welcome.search2").substring(2); + const searchProjectLabel = L10N.getStr("welcome.findInFiles2").substring(2); + + return ( + <div className="welcomebox"> + <div className="alignlabel"> + <div className="shortcutFunction"> + <p + className="welcomebox__searchSources" + role="button" + tabIndex="0" + onClick={() => this.props.openQuickOpen()} + > + <span className="shortcutKey">{searchSourcesShortcut}</span> + <span className="shortcutLabel">{searchSourcesLabel}</span> + </p> + <p + className="welcomebox__searchProject" + role="button" + tabIndex="0" + onClick={() => { + this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH); + this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH); + }} + > + <span className="shortcutKey">{searchProjectShortcut}</span> + <span className="shortcutLabel">{searchProjectLabel}</span> + </p> + <p + className="welcomebox__allShortcuts" + role="button" + tabIndex="0" + onClick={() => this.props.toggleShortcutsModal()} + > + <span className="shortcutKey">{allShortcutsShortcut}</span> + <span className="shortcutLabel">{allShortcutsLabel}</span> + </p> + </div> + </div> + </div> + ); + } +} + +const mapStateToProps = state => ({ + endPanelCollapsed: getPaneCollapse(state, "end"), +}); + +export default connect(mapStateToProps, { + togglePaneCollapse: actions.togglePaneCollapse, + setActiveSearch: actions.setActiveSearch, + openQuickOpen: actions.openQuickOpen, + setPrimaryPaneTab: actions.setPrimaryPaneTab, +})(WelcomeBox); diff --git a/devtools/client/debugger/src/components/moz.build b/devtools/client/debugger/src/components/moz.build new file mode 100644 index 0000000000..41ced8d474 --- /dev/null +++ b/devtools/client/debugger/src/components/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 += [ + "Editor", + "PrimaryPanes", + "SecondaryPanes", + "shared", +] + +CompiledModules( + "A11yIntention.js", + "App.js", + "QuickOpenModal.js", + "ShortcutsModal.js", + "WelcomeBox.js", +) diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.css b/devtools/client/debugger/src/components/shared/AccessibleImage.css new file mode 100644 index 0000000000..06b8149325 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/AccessibleImage.css @@ -0,0 +1,194 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.img { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + /* use background-color for the icon color, and mask-image for its shape */ + background-color: var(--theme-icon-color); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + /* multicolor icons use background-image */ + background-position: center; + background-repeat: no-repeat; + background-size: contain; + /* do not let images shrink when used as flex children */ + flex-shrink: 0; +} + +/* Expand arrow icon */ +.img.arrow { + width: 10px; + height: 10px; + mask-image: url(chrome://devtools/content/debugger/images/arrow.svg); + /* we may override the width/height in specific contexts to make the + clickable area bigger, but we should always keep the mask size 10x10 */ + mask-size: 10px 10px; + background-color: var(--theme-icon-dimmed-color); + transform: rotate(-90deg); + transition: transform 180ms var(--animation-curve); +} + +.img.arrow:dir(rtl) { + transform: rotate(90deg); +} + +.img.arrow.expanded { + /* icon should always point to the bottom (default) when expanded, + regardless of the text direction */ + transform: none !important; +} + +.img.arrow-down { + mask-image: url(chrome://devtools/content/debugger/images/arrow-down.svg); +} + +.img.arrow-up { + mask-image: url(chrome://devtools/content/debugger/images/arrow-up.svg); +} + +.img.blackBox { + mask-image: url(chrome://devtools/content/debugger/images/blackBox.svg); +} + +.img.breadcrumb { + mask-image: url(chrome://devtools/content/debugger/images/breadcrumbs-divider.svg); +} + +.img.close { + mask-image: url(chrome://devtools/skin/images/close.svg); +} + +.img.disable-pausing { + mask-image: url(chrome://devtools/content/debugger/images/disable-pausing.svg); +} + +.img.enable-pausing { + mask-image: url(chrome://devtools/content/debugger/images/enable-pausing.svg); + background-color: var(--theme-icon-checked-color); +} + +.img.globe { + mask-image: url(chrome://devtools/content/debugger/images/globe.svg); +} + +.img.globe-small { + mask-image: url(chrome://devtools/content/debugger/images/globe-small.svg); + mask-size: 12px 12px; +} + +.img.window { + mask-image: url(chrome://devtools/content/debugger/images/window.svg); +} + +.img.file { + mask-image: url(chrome://devtools/content/debugger/images/file-small.svg); + mask-size: 12px 12px; +} + +.img.folder { + mask-image: url(chrome://devtools/content/debugger/images/folder.svg); +} + +.img.home { + mask-image: url(chrome://devtools/content/debugger/images/home.svg); +} + +.img.info { + mask-image: url(chrome://devtools/skin/images/info.svg); +} + +.img.loader { + background-image: url(chrome://devtools/content/debugger/images/loader.svg); + -moz-context-properties: fill; + fill: var(--theme-icon-color); + background-color: unset; +} + +.img.more-tabs { + mask-image: url(chrome://devtools/content/debugger/images/command-chevron.svg); +} + +html[dir="rtl"] .img.more-tabs { + transform: scaleX(-1); +} + +.img.next { + mask-image: url(chrome://devtools/content/debugger/images/next.svg); +} + +.img.next-circle { + mask-image: url(chrome://devtools/content/debugger/images/next-circle.svg); +} + +.img.pane-collapse { + mask-image: url(chrome://devtools/content/debugger/images/pane-collapse.svg); +} + +.img.pane-expand { + mask-image: url(chrome://devtools/content/debugger/images/pane-expand.svg); +} + +.img.pause { + mask-image: url(chrome://devtools/content/debugger/images/pause.svg); +} + +.img.plus { + mask-image: url(chrome://devtools/skin/images/add.svg); +} + +.img.prettyPrint { + background-image: url(chrome://devtools/content/debugger/images/prettyPrint.svg); + background-size: 14px 14px; + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.removeAll { + mask-image: url(chrome://devtools/skin/images/clear.svg) +} + +.img.refresh { + mask-image: url(chrome://devtools/skin/images/reload.svg); +} + +.img.resume { + mask-image: url(chrome://devtools/content/shared/images/resume.svg); +} + +.img.search { + mask-image: url(chrome://devtools/content/debugger/images/search.svg); +} + +.img.shortcuts { + mask-image: url(chrome://devtools/content/debugger/images/help.svg); +} + +.img.spin { + animation: spin 0.5s linear infinite; +} + +.img.stepIn { + mask-image: url(chrome://devtools/content/debugger/images/stepIn.svg); +} + +.img.stepOut { + mask-image: url(chrome://devtools/content/debugger/images/stepOut.svg); +} + +.img.stepOver { + mask-image: url(chrome://devtools/content/shared/images/stepOver.svg); +} + +.img.tab { + mask-image: url(chrome://devtools/content/debugger/images/tab.svg); +} + +.img.worker { + mask-image: url(chrome://devtools/content/debugger/images/worker.svg); +} diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.js b/devtools/client/debugger/src/components/shared/AccessibleImage.js new file mode 100644 index 0000000000..1ac3510c36 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/AccessibleImage.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 React from "react"; +import PropTypes from "prop-types"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./AccessibleImage.css"; + +const AccessibleImage = props => { + props = { + ...props, + className: classnames("img", props.className), + }; + return <span {...props} />; +}; + +AccessibleImage.propTypes = { + className: PropTypes.string.isRequired, +}; + +export default AccessibleImage; diff --git a/devtools/client/debugger/src/components/shared/Accordion.css b/devtools/client/debugger/src/components/shared/Accordion.css new file mode 100644 index 0000000000..e87fa41a6f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Accordion.css @@ -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/>. */ + +.accordion { + background-color: var(--theme-sidebar-background); + width: 100%; + list-style-type: none; + padding: 0px; + margin-top: 0px; +} + +.accordion ._header { + background-color: var(--theme-accordion-header-background); + border-bottom: 1px solid var(--theme-splitter-color); + display: flex; + font-size: 12px; + line-height: calc(16 / 12); + padding: 4px 6px; + width: 100%; + align-items: center; + margin: 0px; + font-weight: normal; + cursor: default; + user-select: none; +} + +.accordion ._header:hover { + background-color: var(--theme-accordion-header-hover); +} + +.accordion ._header .arrow { + margin-inline-end: 4px; +} + +.accordion ._header .header-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--theme-toolbar-color); +} + +.accordion ._header .header-buttons { + display: flex; + margin-inline-start: auto; +} + +.accordion ._header .header-buttons button { + color: var(--theme-body-color); + border: none; + background: none; + padding: 0; + margin: 0 2px; + width: 16px; + height: 16px; +} + +.accordion ._header .header-buttons button::-moz-focus-inner { + border: none; +} + +.accordion ._header .header-buttons button .img { + display: block; +} + +.accordion ._content { + border-bottom: 1px solid var(--theme-splitter-color); + font-size: var(--theme-body-font-size); +} + +.accordion div:last-child ._content { + border-bottom: none; +} diff --git a/devtools/client/debugger/src/components/shared/Accordion.js b/devtools/client/debugger/src/components/shared/Accordion.js new file mode 100644 index 0000000000..fba307abaf --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Accordion.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { cloneElement, Component } from "react"; +import PropTypes from "prop-types"; +import AccessibleImage from "./AccessibleImage"; + +import "./Accordion.css"; + +class Accordion extends Component { + static get propTypes() { + return { + items: PropTypes.array.isRequired, + }; + } + + handleHeaderClick(i) { + const item = this.props.items[i]; + const opened = !item.opened; + item.opened = opened; + + if (item.onToggle) { + item.onToggle(opened); + } + + // We force an update because otherwise the accordion + // would not re-render + this.forceUpdate(); + } + + onHandleHeaderKeyDown(e, i) { + if (e && (e.key === " " || e.key === "Enter")) { + this.handleHeaderClick(i); + } + } + + renderContainer = (item, i) => { + const { opened } = item; + + return ( + <li className={item.className} key={i}> + <h2 + className="_header" + tabIndex="0" + onKeyDown={e => this.onHandleHeaderKeyDown(e, i)} + onClick={() => this.handleHeaderClick(i)} + > + <AccessibleImage className={`arrow ${opened ? "expanded" : ""}`} /> + <span className="header-label">{item.header}</span> + {item.buttons ? ( + <div className="header-buttons" tabIndex="-1"> + {item.buttons} + </div> + ) : null} + </h2> + {opened && ( + <div className="_content"> + {cloneElement(item.component, item.componentProps || {})} + </div> + )} + </li> + ); + }; + render() { + return ( + <ul className="accordion"> + {this.props.items.map(this.renderContainer)} + </ul> + ); + } +} + +export default Accordion; diff --git a/devtools/client/debugger/src/components/shared/Badge.css b/devtools/client/debugger/src/components/shared/Badge.css new file mode 100644 index 0000000000..f52d32edf4 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Badge.css @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.badge { + --size: 17px; + --radius: calc(var(--size) / 2); + height: var(--size); + min-width: var(--size); + line-height: var(--size); + background: var(--theme-toolbar-background-hover); + color: var(--theme-body-color); + border-radius: var(--radius); + padding: 0 4px; + font-size: 0.9em; +} diff --git a/devtools/client/debugger/src/components/shared/Badge.js b/devtools/client/debugger/src/components/shared/Badge.js new file mode 100644 index 0000000000..58519e0246 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Badge.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; +import "./Badge.css"; + +const Badge = ({ children }) => ( + <span className="badge text-white text-center">{children}</span> +); + +Badge.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default Badge; diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.css b/devtools/client/debugger/src/components/shared/BracketArrow.css new file mode 100644 index 0000000000..afca888371 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/BracketArrow.css @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.bracket-arrow { + position: absolute; + pointer-events: none; +} + +.bracket-arrow::before, +.bracket-arrow::after { + content: ""; + height: 0; + width: 0; + position: absolute; + border: 7px solid transparent; +} + +.bracket-arrow.up::before { + border-bottom-color: var(--theme-splitter-color); + top: -1px; +} + +.theme-dark .bracket-arrow.up::before { + border-bottom-color: var(--theme-body-color); +} + +.bracket-arrow.up::after { + border-bottom-color: var(--theme-body-background); + top: 0px; +} + +.bracket-arrow.down::before { + border-bottom-color: transparent; + border-top-color: var(--theme-splitter-color); + top: 0px; +} + +.theme-dark .bracket-arrow.down::before { + border-top-color: var(--theme-body-color); +} + +.bracket-arrow.down::after { + border-bottom-color: transparent; + border-top-color: var(--theme-body-background); + top: -1px; +} + +.bracket-arrow.left::before { + border-left-color: transparent; + border-right-color: var(--theme-splitter-color); + top: 0px; +} + +.theme-dark .bracket-arrow.left::before { + border-right-color: var(--theme-body-color); +} + +.bracket-arrow.left::after { + border-left-color: transparent; + border-right-color: var(--theme-body-background); + top: 0px; + left: 1px; +} diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.js b/devtools/client/debugger/src/components/shared/BracketArrow.js new file mode 100644 index 0000000000..2e0c3fbf0e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/BracketArrow.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./BracketArrow.css"; + +const BracketArrow = ({ orientation, left, top, bottom }) => { + return ( + <div + className={classnames("bracket-arrow", orientation || "up")} + style={{ left, top, bottom }} + /> + ); +}; + +BracketArrow.propTypes = { + bottom: PropTypes.number, + left: PropTypes.number, + orientation: PropTypes.string.isRequired, + top: PropTypes.number, +}; + +export default BracketArrow; diff --git a/devtools/client/debugger/src/components/shared/Button/CloseButton.js b/devtools/client/debugger/src/components/shared/Button/CloseButton.js new file mode 100644 index 0000000000..2450b4aae2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/CloseButton.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "../AccessibleImage"; + +import "./styles/CloseButton.css"; + +function CloseButton({ handleClick, buttonClass, tooltip }) { + return ( + <button + className={buttonClass ? `close-btn ${buttonClass}` : "close-btn"} + onClick={handleClick} + title={tooltip} + > + <AccessibleImage className="close" /> + </button> + ); +} + +CloseButton.propTypes = { + buttonClass: PropTypes.string, + handleClick: PropTypes.func.isRequired, + tooltip: PropTypes.string, +}; + +export default CloseButton; diff --git a/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js new file mode 100644 index 0000000000..f1579b6f7a --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "../AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./styles/CommandBarButton.css"; + +export function debugBtn( + onClick, + type, + className, + tooltip, + disabled = false, + ariaPressed = false +) { + return ( + <CommandBarButton + className={classnames(type, className)} + disabled={disabled} + key={type} + onClick={onClick} + pressed={ariaPressed} + title={tooltip} + > + <AccessibleImage className={type} /> + </CommandBarButton> + ); +} + +const CommandBarButton = props => { + const { children, className, pressed = false, ...rest } = props; + + return ( + <button + aria-pressed={pressed} + className={classnames("command-bar-button", className)} + {...rest} + > + {children} + </button> + ); +}; + +CommandBarButton.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string.isRequired, + pressed: PropTypes.bool, +}; + +export default CommandBarButton; diff --git a/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js new file mode 100644 index 0000000000..ba2f20e882 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import AccessibleImage from "../AccessibleImage"; +import { CommandBarButton } from "./"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./styles/PaneToggleButton.css"; + +class PaneToggleButton extends PureComponent { + static defaultProps = { + horizontal: false, + position: "start", + }; + + static get propTypes() { + return { + collapsed: PropTypes.bool.isRequired, + handleClick: PropTypes.func.isRequired, + horizontal: PropTypes.bool.isRequired, + position: PropTypes.oneOf(["start", "end"]).isRequired, + }; + } + + label(position, collapsed) { + switch (position) { + case "start": + return L10N.getStr(collapsed ? "expandSources" : "collapseSources"); + case "end": + return L10N.getStr( + collapsed ? "expandBreakpoints" : "collapseBreakpoints" + ); + } + return null; + } + + render() { + const { position, collapsed, horizontal, handleClick } = this.props; + + return ( + <CommandBarButton + className={classnames("toggle-button", position, { + collapsed, + vertical: !horizontal, + })} + onClick={() => handleClick(position, !collapsed)} + title={this.label(position, collapsed)} + > + <AccessibleImage + className={collapsed ? "pane-expand" : "pane-collapse"} + /> + </CommandBarButton> + ); + } +} + +export default PaneToggleButton; diff --git a/devtools/client/debugger/src/components/shared/Button/index.js b/devtools/client/debugger/src/components/shared/Button/index.js new file mode 100644 index 0000000000..df7976ba90 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/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/>. */ + +import CloseButton from "./CloseButton"; +import CommandBarButton, { debugBtn } from "./CommandBarButton"; +import PaneToggleButton from "./PaneToggleButton"; + +export { CloseButton, CommandBarButton, debugBtn, PaneToggleButton }; diff --git a/devtools/client/debugger/src/components/shared/Button/moz.build b/devtools/client/debugger/src/components/shared/Button/moz.build new file mode 100644 index 0000000000..c6e652d5dc --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/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 += [ + "styles", +] + +CompiledModules( + "CloseButton.js", + "CommandBarButton.js", + "index.js", + "PaneToggleButton.js", +) diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css new file mode 100644 index 0000000000..b0093ff4de --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.close-btn { + width: 16px; + height: 16px; + border: 1px solid transparent; + border-radius: 2px; + padding: 1px; + color: var(--theme-icon-color); +} + +.close-btn:hover, +.close-btn:focus { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +.close-btn .img { + display: block; + width: 12px; + height: 12px; + /* inherit the button's text color for the icon's color */ + background-color: currentColor; +} + +.close-btn.big { + width: 20px; + height: 20px; +} + +.close-btn.big .img { + width: 16px; + height: 16px; +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css new file mode 100644 index 0000000000..5b03bca8ec --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css @@ -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/>. */ + +.command-bar-button { + appearance: none; + background: transparent; + border: none; + display: inline-block; + text-align: center; + position: relative; + padding: 0px 5px; + fill: currentColor; + min-width: 30px; +} + +.command-bar-button:disabled { + opacity: 0.6; + cursor: default; +} + +.command-bar-button:not(.disabled):hover, +.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover { + background: var(--theme-toolbar-background-hover); +} + +.theme-dark .command-bar-button:not(.disabled):hover, +.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover { + background: var(--theme-toolbar-hover); +} + +:root.theme-dark .command-bar-button { + color: var(--theme-body-color); +} + +.command-bar-button > * { + width: 16px; + height: 16px; + display: inline-block; + vertical-align: middle; +} + +/** + * Settings icon and menu + */ +.devtools-button.debugger-settings-menu-button { + border-radius: 0; + margin: 0; + padding: 0; +} + +.devtools-button.debugger-settings-menu-button::before { + background-image: url("chrome://devtools/skin/images/settings.svg"); +} + +.devtools-button.debugger-trace-menu-button::before { + background-image: url(chrome://devtools/content/debugger/images/trace.svg); +} +.devtools-button.debugger-trace-menu-button.active::before { + fill: var(--theme-icon-checked-color); +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css new file mode 100644 index 0000000000..d8a2495408 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.toggle-button { + padding: 4px 6px; +} + +.toggle-button .img { + vertical-align: middle; +} + +.toggle-button.end { + margin-inline-end: 0px; + margin-inline-start: auto; +} + +.toggle-button.start { + margin-inline-start: 0px; +} + +html[dir="rtl"] .toggle-button.start .img, +html[dir="ltr"] .toggle-button.end:not(.vertical) .img { + transform: scaleX(-1); +} + +.toggle-button.end.vertical .img { + transform: rotate(-90deg); +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/moz.build b/devtools/client/debugger/src/components/shared/Button/styles/moz.build new file mode 100644 index 0000000000..7d80140dbe --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/moz.build @@ -0,0 +1,8 @@ +# 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() diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js new file mode 100644 index 0000000000..cb426ddada --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import { CloseButton } from "../"; + +describe("CloseButton", () => { + it("renders with tooltip", () => { + const tooltip = "testTooltip"; + const wrapper = shallow( + <CloseButton tooltip={tooltip} handleClick={() => {}} /> + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles click event", () => { + const handleClickSpy = jest.fn(); + const wrapper = shallow(<CloseButton handleClick={handleClickSpy} />); + wrapper.simulate("click"); + expect(handleClickSpy).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js new file mode 100644 index 0000000000..1da7dc9fed --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import { CommandBarButton, debugBtn } from "../"; + +describe("CommandBarButton", () => { + it("renders", () => { + const wrapper = shallow(<CommandBarButton children={[]} className={""} />); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders children", () => { + const children = [1, 2, 3, 4]; + const wrapper = shallow( + <CommandBarButton children={children} className={""} /> + ); + expect(wrapper.find("button").children()).toHaveLength(4); + }); +}); + +describe("debugBtn", () => { + it("renders", () => { + const wrapper = shallow(debugBtn()); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles onClick", () => { + const onClickSpy = jest.fn(); + const wrapper = shallow(debugBtn(onClickSpy)); + wrapper.simulate("click"); + expect(onClickSpy).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js new file mode 100644 index 0000000000..59fbe11fc6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import { PaneToggleButton } from "../"; + +describe("PaneToggleButton", () => { + const handleClickSpy = jest.fn(); + const wrapper = shallow( + <PaneToggleButton + handleClick={handleClickSpy} + collapsed={false} + position="start" + /> + ); + + it("renders default", () => { + expect(wrapper.hasClass("vertical")).toBe(true); + expect(wrapper).toMatchSnapshot(); + }); + + it("toggles horizontal class", () => { + wrapper.setProps({ horizontal: true }); + expect(wrapper.hasClass("vertical")).toBe(false); + }); + + it("toggles collapsed class", () => { + wrapper.setProps({ collapsed: true }); + expect(wrapper.hasClass("collapsed")).toBe(true); + }); + + it("toggles start position", () => { + wrapper.setProps({ position: "start" }); + expect(wrapper.hasClass("start")).toBe(true); + }); + + it("toggles end position ", () => { + wrapper.setProps({ position: "end" }); + expect(wrapper.hasClass("end")).toBe(true); + }); + + it("handleClick is called", () => { + const position = "end"; + const collapsed = false; + wrapper.setProps({ position, collapsed }); + wrapper.simulate("click"); + expect(handleClickSpy).toHaveBeenCalledWith(position, true); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap new file mode 100644 index 0000000000..d0a0cb9967 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CloseButton renders with tooltip 1`] = ` +<button + className="close-btn" + onClick={[Function]} + title="testTooltip" +> + <AccessibleImage + className="close" + /> +</button> +`; diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap new file mode 100644 index 0000000000..cebcb5892c --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommandBarButton renders 1`] = ` +<button + aria-pressed={false} + className="command-bar-button" +/> +`; + +exports[`debugBtn renders 1`] = ` +<button + aria-pressed={false} + className="command-bar-button" + disabled={false} +> + <AccessibleImage /> +</button> +`; diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap new file mode 100644 index 0000000000..86067066a6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaneToggleButton renders default 1`] = ` +<CommandBarButton + className="toggle-button start vertical" + onClick={[Function]} + title="Collapse Sources and Outline panes" +> + <AccessibleImage + className="pane-collapse" + /> +</CommandBarButton> +`; diff --git a/devtools/client/debugger/src/components/shared/Dropdown.css b/devtools/client/debugger/src/components/shared/Dropdown.css new file mode 100644 index 0000000000..bae5656c8f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Dropdown.css @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.dropdown { + background: var(--theme-body-background); + border: 1px solid var(--theme-splitter-color); + border-radius: 4px; + box-shadow: 0 4px 4px 0 var(--search-overlays-semitransparent); + max-height: 300px; + position: absolute; + top: 24px; + width: 150px; + z-index: 1000; + overflow: auto; +} + +[dir="ltr"] .dropdown { + right: 2px; +} + +[dir="rtl"] .dropdown { + left: 2px; +} + +.dropdown-block { + position: relative; + align-self: center; + height: 100%; +} + +/* cover the reserved space at the end of .source-tabs */ +.source-tabs + .dropdown-block { + margin-inline-start: -28px; +} + +.dropdown-button { + color: var(--theme-comment); + background: none; + border: none; + padding: 4px 6px; + font-weight: 100; + font-size: 14px; + height: 100%; + width: 28px; +} + +.dropdown-button .img { + display: block; +} + +.dropdown ul { + margin: 0; + padding: 4px 0; + list-style: none; +} + +.dropdown li { + display: flex; + align-items: center; + padding: 6px 8px; + font-size: 12px; + line-height: calc(16 / 12); + transition: all 0.25s ease; +} + +.dropdown li:hover { + background-color: var(--search-overlays-semitransparent); +} + +.dropdown-icon { + margin-inline-end: 4px; + mask-size: 13px 13px; +} + +.dropdown-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-icon.prettyPrint, +.dropdown-icon.blackBox { + background-color: var(--theme-highlight-blue); +} + +.dropdown-mask { + position: fixed; + width: 100%; + height: 100%; + background: transparent; + z-index: 999; + left: 0; + top: 0; +} diff --git a/devtools/client/debugger/src/components/shared/Dropdown.js b/devtools/client/debugger/src/components/shared/Dropdown.js new file mode 100644 index 0000000000..7051cec9c5 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Dropdown.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import "./Dropdown.css"; + +export class Dropdown extends Component { + constructor(props) { + super(props); + this.state = { + dropdownShown: false, + }; + } + + static get propTypes() { + return { + icon: PropTypes.node.isRequired, + panel: PropTypes.node.isRequired, + }; + } + + toggleDropdown = e => { + this.setState(prevState => ({ + dropdownShown: !prevState.dropdownShown, + })); + }; + + renderPanel() { + return ( + <div + className="dropdown" + onClick={this.toggleDropdown} + style={{ display: this.state.dropdownShown ? "block" : "none" }} + > + {this.props.panel} + </div> + ); + } + + renderButton() { + return ( + <button className="dropdown-button" onClick={this.toggleDropdown}> + {this.props.icon} + </button> + ); + } + + renderMask() { + return ( + <div + className="dropdown-mask" + onClick={this.toggleDropdown} + style={{ display: this.state.dropdownShown ? "block" : "none" }} + /> + ); + } + + render() { + return ( + <div className="dropdown-block"> + {this.renderPanel()} + {this.renderButton()} + {this.renderMask()} + </div> + ); + } +} + +export default Dropdown; diff --git a/devtools/client/debugger/src/components/shared/Modal.css b/devtools/client/debugger/src/components/shared/Modal.css new file mode 100644 index 0000000000..072390b001 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Modal.css @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.modal-wrapper { + position: fixed; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100%; + top: 0; + left: 0; + transition: z-index 200ms; + z-index: 100; +} + +.modal { + display: flex; + width: 80%; + max-height: 80vh; + overflow-y: auto; + background-color: var(--theme-toolbar-background); + transition: transform 150ms cubic-bezier(0.07, 0.95, 0, 1); + box-shadow: 1px 1px 6px 1px var(--popup-shadow-color); +} + +.modal.entering, +.modal.exited { + transform: translateY(-101%); +} + +.modal.entered, +.modal.exiting { + transform: translateY(5px); + flex-direction: column; +} + +/* This rule is active when the screen is not narrow */ +@media (min-width: 580px) { + .modal { + width: 50%; + } +} + +@media (min-height: 340px) { + .modal.entered, + .modal.exiting { + transform: translateY(30px); + } +} diff --git a/devtools/client/debugger/src/components/shared/Modal.js b/devtools/client/debugger/src/components/shared/Modal.js new file mode 100644 index 0000000000..dec65e627b --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Modal.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 PropTypes from "prop-types"; +import React from "react"; +import Transition from "react-transition-group/Transition"; +const classnames = require("devtools/client/shared/classnames.js"); +import "./Modal.css"; + +export const transitionTimeout = 50; + +export class Modal extends React.Component { + static get propTypes() { + return { + additionalClass: PropTypes.string, + children: PropTypes.node.isRequired, + handleClose: PropTypes.func.isRequired, + status: PropTypes.string.isRequired, + }; + } + + onClick = e => { + e.stopPropagation(); + }; + + render() { + const { additionalClass, children, handleClose, status } = this.props; + + return ( + <div className="modal-wrapper" onClick={handleClose}> + <div + className={classnames("modal", additionalClass, status)} + onClick={this.onClick} + > + {children} + </div> + </div> + ); + } +} + +Modal.contextTypes = { + shortcuts: PropTypes.object, +}; + +export default function Slide({ + in: inProp, + children, + additionalClass, + handleClose, +}) { + return ( + <Transition in={inProp} timeout={transitionTimeout} appear> + {status => ( + <Modal + status={status} + additionalClass={additionalClass} + handleClose={handleClose} + > + {children} + </Modal> + )} + </Transition> + ); +} + +Slide.propTypes = { + additionalClass: PropTypes.string, + children: PropTypes.node.isRequired, + handleClose: PropTypes.func.isRequired, + in: PropTypes.bool.isRequired, +}; diff --git a/devtools/client/debugger/src/components/shared/Popover.css b/devtools/client/debugger/src/components/shared/Popover.css new file mode 100644 index 0000000000..5da8ea4b63 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Popover.css @@ -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/>. */ + +.popover { + position: fixed; + z-index: 100; + --gap-size: 10px; + --left-offset: -55px; +} + +.popover.orientation-right { + display: flex; + flex-direction: row; +} + +.popover.orientation-right .gap { + width: var(--gap-size); +} + +.popover:not(.orientation-right) .gap { + height: var(--gap-size); + margin-left: var(--left-offset); +} + +.popover:not(.orientation-right) .preview-popup { + margin-left: var(--left-offset); +} + +.popover .add-to-expression-bar { + margin-left: var(--left-offset); +} diff --git a/devtools/client/debugger/src/components/shared/Popover.js b/devtools/client/debugger/src/components/shared/Popover.js new file mode 100644 index 0000000000..fde7d40a21 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Popover.js @@ -0,0 +1,299 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import BracketArrow from "./BracketArrow"; +import SmartGap from "./SmartGap"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Popover.css"; + +class Popover extends Component { + state = { + coords: { + left: 0, + top: 0, + orientation: "down", + targetMid: { x: 0, y: 0 }, + }, + }; + firstRender = true; + + static defaultProps = { + type: "popover", + }; + + static get propTypes() { + return { + children: PropTypes.node.isRequired, + editorRef: PropTypes.object.isRequired, + mouseout: PropTypes.func.isRequired, + target: PropTypes.object.isRequired, + targetPosition: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + }; + } + + componentDidMount() { + const { type } = this.props; + this.gapHeight = this.$gap.getBoundingClientRect().height; + const coords = + type == "popover" ? this.getPopoverCoords() : this.getTooltipCoords(); + + if (coords) { + this.setState({ coords }); + } + + this.firstRender = false; + this.startTimer(); + } + + componentWillUnmount() { + if (this.timerId) { + clearTimeout(this.timerId); + } + } + + startTimer() { + this.timerId = setTimeout(this.onTimeout, 0); + } + + onTimeout = () => { + const isHoveredOnGap = this.$gap && this.$gap.matches(":hover"); + const isHoveredOnPopover = this.$popover && this.$popover.matches(":hover"); + const isHoveredOnTooltip = this.$tooltip && this.$tooltip.matches(":hover"); + const isHoveredOnTarget = this.props.target.matches(":hover"); + + if (isHoveredOnGap) { + if (!this.wasOnGap) { + this.wasOnGap = true; + this.timerId = setTimeout(this.onTimeout, 200); + return; + } + this.props.mouseout(); + return; + } + + // Don't clear the current preview if mouse is hovered on + // the current preview's token (target) or the popup element + if (isHoveredOnPopover || isHoveredOnTooltip || isHoveredOnTarget) { + this.wasOnGap = false; + this.timerId = setTimeout(this.onTimeout, 0); + return; + } + + this.props.mouseout(); + }; + + calculateLeft(target, editor, popover, orientation) { + const estimatedLeft = target.left; + const estimatedRight = estimatedLeft + popover.width; + const isOverflowingRight = estimatedRight > editor.right; + if (orientation === "right") { + return target.left + target.width; + } + if (isOverflowingRight) { + const adjustedLeft = editor.right - popover.width - 8; + return adjustedLeft; + } + return estimatedLeft; + } + + calculateTopForRightOrientation = (target, editor, popover) => { + if (popover.height <= editor.height) { + const rightOrientationTop = target.top - popover.height / 2; + if (rightOrientationTop < editor.top) { + return editor.top - target.height; + } + const rightOrientationBottom = rightOrientationTop + popover.height; + if (rightOrientationBottom > editor.bottom) { + return editor.bottom + target.height - popover.height + this.gapHeight; + } + return rightOrientationTop; + } + return editor.top - target.height; + }; + + calculateOrientation(target, editor, popover) { + const estimatedBottom = target.bottom + popover.height; + if (editor.bottom > estimatedBottom) { + return "down"; + } + const upOrientationTop = target.top - popover.height; + if (upOrientationTop > editor.top) { + return "up"; + } + + return "right"; + } + + calculateTop = (target, editor, popover, orientation) => { + if (orientation === "down") { + return target.bottom; + } + if (orientation === "up") { + return target.top - popover.height; + } + + return this.calculateTopForRightOrientation(target, editor, popover); + }; + + getPopoverCoords() { + if (!this.$popover || !this.props.editorRef) { + return null; + } + + const popover = this.$popover; + const editor = this.props.editorRef; + const popoverRect = popover.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const targetRect = this.props.targetPosition; + const orientation = this.calculateOrientation( + targetRect, + editorRect, + popoverRect + ); + const top = this.calculateTop( + targetRect, + editorRect, + popoverRect, + orientation + ); + const popoverLeft = this.calculateLeft( + targetRect, + editorRect, + popoverRect, + orientation + ); + let targetMid; + if (orientation === "right") { + targetMid = { + x: -14, + y: targetRect.top - top - 2, + }; + } else { + targetMid = { + x: targetRect.left - popoverLeft + targetRect.width / 2 - 8, + y: 0, + }; + } + + return { + left: popoverLeft, + top, + orientation, + targetMid, + }; + } + + getTooltipCoords() { + if (!this.$tooltip || !this.props.editorRef) { + return null; + } + const tooltip = this.$tooltip; + const editor = this.props.editorRef; + const tooltipRect = tooltip.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const targetRect = this.props.targetPosition; + const left = this.calculateLeft(targetRect, editorRect, tooltipRect); + const enoughRoomForTooltipAbove = + targetRect.top - editorRect.top > tooltipRect.height; + const top = enoughRoomForTooltipAbove + ? targetRect.top - tooltipRect.height + : targetRect.bottom; + + return { + left, + top, + orientation: enoughRoomForTooltipAbove ? "up" : "down", + targetMid: { x: 0, y: 0 }, + }; + } + + getChildren() { + const { children } = this.props; + const { coords } = this.state; + const gap = this.getGap(); + + return coords.orientation === "up" ? [children, gap] : [gap, children]; + } + + getGap() { + if (this.firstRender) { + return <div className="gap" key="gap" ref={a => (this.$gap = a)} />; + } + + return ( + <div className="gap" key="gap" ref={a => (this.$gap = a)}> + <SmartGap + token={this.props.target} + preview={this.$tooltip || this.$popover} + type={this.props.type} + gapHeight={this.gapHeight} + coords={this.state.coords} + offset={this.$gap.getBoundingClientRect().left} + /> + </div> + ); + } + + getPopoverArrow(orientation, left, top) { + let arrowProps = {}; + + if (orientation === "up") { + arrowProps = { orientation: "down", bottom: 10, left }; + } else if (orientation === "down") { + arrowProps = { orientation: "up", top: -2, left }; + } else { + arrowProps = { orientation: "left", top, left: -4 }; + } + + return <BracketArrow {...arrowProps} />; + } + + renderPopover() { + const { top, left, orientation, targetMid } = this.state.coords; + const arrow = this.getPopoverArrow(orientation, targetMid.x, targetMid.y); + + return ( + <div + className={classnames("popover", `orientation-${orientation}`, { + up: orientation === "up", + })} + style={{ top, left }} + ref={c => (this.$popover = c)} + > + {arrow} + {this.getChildren()} + </div> + ); + } + + renderTooltip() { + const { top, left, orientation } = this.state.coords; + return ( + <div + className={`tooltip orientation-${orientation}`} + style={{ top, left }} + ref={c => (this.$tooltip = c)} + > + {this.getChildren()} + </div> + ); + } + + render() { + const { type } = this.props; + + if (type === "tooltip") { + return this.renderTooltip(); + } + + return this.renderPopover(); + } +} + +export default Popover; diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.css b/devtools/client/debugger/src/components/shared/PreviewFunction.css new file mode 100644 index 0000000000..bff9ce25a2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/PreviewFunction.css @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.function-signature { + align-self: center; +} + +.function-signature .function-name { + color: var(--theme-highlight-blue); +} + +.function-signature .param { + color: var(--theme-highlight-red); +} + +.function-signature .paren { + color: var(--object-color); +} + +.function-signature .comma { + color: var(--object-color); +} diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.js b/devtools/client/debugger/src/components/shared/PreviewFunction.js new file mode 100644 index 0000000000..760a45db5d --- /dev/null +++ b/devtools/client/debugger/src/components/shared/PreviewFunction.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { formatDisplayName } from "../../utils/pause/frames"; + +import "./PreviewFunction.css"; + +const IGNORED_SOURCE_URLS = ["debugger eval code"]; + +export default class PreviewFunction extends Component { + static get propTypes() { + return { + func: PropTypes.object.isRequired, + }; + } + + renderFunctionName(func) { + const { l10n } = this.context; + const name = formatDisplayName(func, undefined, l10n); + return <span className="function-name">{name}</span>; + } + + renderParams(func) { + const { parameterNames = [] } = func; + + return parameterNames + .filter(Boolean) + .map((param, i, arr) => { + const elements = [ + <span className="param" key={param}> + {param} + </span>, + ]; + // if this isn't the last param, add a comma + if (i !== arr.length - 1) { + elements.push( + <span className="delimiter" key={i}> + {", "} + </span> + ); + } + return elements; + }) + .flat(); + } + + jumpToDefinitionButton(func) { + const { location } = func; + + if (!location?.url || IGNORED_SOURCE_URLS.includes(location.url)) { + return null; + } + + const lastIndex = location.url.lastIndexOf("/"); + return ( + <button + className="jump-definition" + draggable="false" + title={`${location.url.slice(lastIndex + 1)}:${location.line}`} + /> + ); + } + + render() { + const { func } = this.props; + return ( + <span className="function-signature"> + {this.renderFunctionName(func)} + <span className="paren">(</span> + {this.renderParams(func)} + <span className="paren">)</span> + {this.jumpToDefinitionButton(func)} + </span> + ); + } +} + +PreviewFunction.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/shared/ResultList.css b/devtools/client/debugger/src/components/shared/ResultList.css new file mode 100644 index 0000000000..037c3497d3 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/ResultList.css @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.result-list { + list-style: none; + margin: 0px; + padding: 0px; + overflow: auto; + width: 100%; + background: var(--theme-body-background); +} + +.result-list * { + user-select: none; +} + +.result-list li { + color: var(--theme-body-color); + padding: 4px 8px; + display: flex; +} + +.result-list.big li { + flex-direction: row; + align-items: center; + padding: 6px 8px; + font-size: 12px; + line-height: 16px; +} + +.result-list.small li { + justify-content: space-between; +} + +.result-list li:hover { + background: var(--theme-tab-toolbar-background); +} + +.theme-dark .result-list li:hover { + background: var(--grey-70); +} + +.result-list li.selected { + background: var(--theme-accordion-header-background); +} + +.result-list.small li.selected { + background-color: var(--theme-selection-background); + color: white; +} + +.result-list li .result-item-icon { + background-color: var(--theme-icon-dimmed-color); +} + +.result-list li .icon { + align-self: center; + margin-inline-end: 14px; + margin-inline-start: 4px; +} + +.result-list .result-item-icon { + display: block; +} + +.result-list .selected .result-item-icon { + background-color: var(--theme-selection-color); +} + +.result-list li .title { + word-break: break-all; + text-overflow: ellipsis; + white-space: nowrap; + + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-90); +} + +.theme-dark .result-list li .title { + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-30); +} + +.result-list li.selected .title { + color: white; +} + +.result-list.big li.selected { + background-color: var(--theme-selection-background); + color: white; +} + +.result-list.big li.selected .subtitle { + color: white; +} + +.result-list.big li.selected .subtitle .highlight { + color: white; + font-weight: bold; +} + +.result-list.big li .subtitle { + word-break: break-all; + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-40); + margin-left: 15px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.theme-dark .result-list.big li.selected .subtitle { + color: white; +} + +.theme-dark .result-list.big li .subtitle { + color: var(--theme-text-color-inactive); +} + +.search-bar .result-list li.selected .subtitle { + color: white; +} + +.search-bar .result-list { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.theme-dark .result-list { + background-color: var(--theme-body-background); +} diff --git a/devtools/client/debugger/src/components/shared/ResultList.js b/devtools/client/debugger/src/components/shared/ResultList.js new file mode 100644 index 0000000000..bb915b8f24 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/ResultList.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "./AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./ResultList.css"; + +export default class ResultList extends Component { + static defaultProps = { + size: "small", + role: "listbox", + }; + + static get propTypes() { + return { + items: PropTypes.array.isRequired, + role: PropTypes.oneOf(["listbox"]), + selectItem: PropTypes.func.isRequired, + selected: PropTypes.number.isRequired, + size: PropTypes.oneOf(["big", "small"]), + }; + } + + renderListItem = (item, index) => { + if (item.value === "/" && item.title === "") { + item.title = "(index)"; + } + + const { selectItem, selected } = this.props; + const props = { + onClick: event => selectItem(event, item, index), + key: `${item.id}${item.value}${index}`, + ref: String(index), + title: item.value, + "aria-labelledby": `${item.id}-title`, + "aria-describedby": `${item.id}-subtitle`, + role: "option", + className: classnames("result-item", { + selected: index === selected, + }), + }; + + return ( + <li {...props}> + {item.icon && ( + <div className="icon"> + <AccessibleImage className={item.icon} /> + </div> + )} + <div id={`${item.id}-title`} className="title"> + {item.title} + </div> + {item.subtitle != item.title ? ( + <div id={`${item.id}-subtitle`} className="subtitle"> + {item.subtitle} + </div> + ) : null} + </li> + ); + }; + + render() { + const { size, items, role } = this.props; + + return ( + <ul + className={classnames("result-list", size)} + id="result-list" + role={role} + aria-live="polite" + > + {items.map(this.renderListItem)} + </ul> + ); + } +} diff --git a/devtools/client/debugger/src/components/shared/SearchInput.css b/devtools/client/debugger/src/components/shared/SearchInput.css new file mode 100644 index 0000000000..33d217321a --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SearchInput.css @@ -0,0 +1,225 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.search-outline { + border: 1px solid var(--theme-toolbar-background); + border-bottom: 1px solid var(--theme-splitter-color); + transition: border-color 200ms ease-in-out; + display: flex; + flex-direction: column; +} + +.search-field { + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 24px; + width: 100%; + background-color: var(--theme-toolbar-background); +} + +.search-field .img.search { + --icon-mask-size: 12px; + --icon-inset-inline-start: 6px; + position: absolute; + z-index: 1; + top: calc(50% - 8px); + mask-size: var(--icon-mask-size); + background-color: var(--theme-icon-dimmed-color); + pointer-events: none; +} + +.search-field.big .img.search { + --icon-mask-size: 16px; + --icon-inset-inline-start: 12px; +} + +[dir="ltr"] .search-field .img.search { + left: var(--icon-inset-inline-start); +} + +[dir="rtl"] .search-field .img.search { + right: var(--icon-inset-inline-start); +} + +.search-field .img.loader { + width: 24px; + height: 24px; + margin-inline-end: 4px; +} + +.search-field input { + align-self: stretch; + flex-grow: 1; + height: 24px; + width: 40px; + border: none; + outline: none; + padding: 4px; + padding-inline-start: 28px; + line-height: 16px; + font-family: inherit; + font-size: inherit; + color: var(--theme-body-color); + background-color: transparent; +} + +.exclude-patterns-field { + position: relative; + display: flex; + align-items: flex-start; + flex-direction: column; + flex-shrink: 0; + min-height: 24px; + width: 100%; + background-color: var(--theme-toolbar-background); + border-top: 1px solid var(--theme-splitter-color); + margin-top: 1px; +} + +.exclude-patterns-field input:focus { + outline: 1px solid var(--blue-50); +} + +.exclude-patterns-field label { + padding-inline-start: 8px; + padding-top: 5px; + padding-bottom: 3px; + align-self: stretch; + background-color: var(--theme-body-background); + font-size: 12px; +} + +.exclude-patterns-field input { + align-self: stretch; + height: 24px; + border: none; + padding-top: 14px; + padding-bottom: 14px; + padding-inline-start: 10px; + line-height: 16px; + font-family: inherit; + font-size: inherit; + color: var(--theme-body-color); + background-color: transparent; + border-top: 1px solid var(--theme-splitter-color); + min-height: 24px; +} + +.exclude-patterns-field input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.search-field.big input { + height: 40px; + padding-top: 10px; + padding-bottom: 10px; + padding-inline-start: 40px; + font-size: 14px; + line-height: 20px; +} + +.search-field:focus-within { + outline: 1px solid var(--blue-50); +} + +.search-field input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.search-field-summary { + align-self: center; + padding: 2px 4px; + white-space: nowrap; + text-align: center; + user-select: none; + color: var(--theme-text-color-alt); + /* Avoid layout jumps when we increment the result count quickly. With tabular + numbers, layout will only jump between 9 and 10, 99 and 100, etc. */ + font-variant-numeric: tabular-nums; +} + +.search-field.big .search-field-summary { + margin-inline-end: 4px; +} + +.search-field .search-nav-buttons { + display: flex; + user-select: none; +} + +.search-field .search-nav-buttons .nav-btn { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 4px; + background: transparent; +} + +.search-field .search-nav-buttons .nav-btn:hover { + background-color: var(--theme-toolbar-background-hover); +} + +.search-field .close-btn { + margin-inline-end: 4px; +} + +.search-field.big .close-btn { + margin-inline-end: 8px; +} + +.search-field .close-btn::-moz-focus-inner { + border: none; +} + +.search-buttons-bar .pipe-divider { + flex: none; + align-self: stretch; + width: 1px; + vertical-align: middle; + margin: 4px; + background-color: var(--theme-splitter-color); +} + +.search-buttons-bar * { + user-select: none; +} + +.search-buttons-bar { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + background-color: var(--theme-toolbar-background); + padding: 0; +} + +.search-buttons-bar .search-type-toggles { + display: flex; + align-items: center; + max-width: 68%; +} + +.search-buttons-bar .search-type-name { + margin: 0 4px; + border: none; + background: transparent; + color: var(--theme-comment); +} + +.search-buttons-bar .search-type-toggles .search-type-btn.active { + color: var(--theme-selection-background); +} + +.theme-dark .search-buttons-bar .search-type-toggles .search-type-btn.active { + color: white; +} + +.search-buttons-bar .close-btn { + margin-inline-end: 3px; +} diff --git a/devtools/client/debugger/src/components/shared/SearchInput.js b/devtools/client/debugger/src/components/shared/SearchInput.js new file mode 100644 index 0000000000..c07d7c86c7 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SearchInput.js @@ -0,0 +1,339 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { CloseButton } from "./Button"; + +import AccessibleImage from "./AccessibleImage"; +import actions from "../../actions"; +import "./SearchInput.css"; +import { getSearchOptions } from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); +const SearchModifiers = require("devtools/client/shared/components/SearchModifiers"); + +const arrowBtn = (onClick, type, className, tooltip) => { + const props = { + className, + key: type, + onClick, + title: tooltip, + type, + }; + + return ( + <button {...props}> + <AccessibleImage className={type} /> + </button> + ); +}; + +export class SearchInput extends Component { + static defaultProps = { + expanded: false, + hasPrefix: false, + selectedItemId: "", + size: "", + showClose: true, + }; + + constructor(props) { + super(props); + this.state = { + history: [], + excludePatterns: props.searchOptions.excludePatterns, + }; + } + + static get propTypes() { + return { + count: PropTypes.number.isRequired, + expanded: PropTypes.bool.isRequired, + handleClose: PropTypes.func, + handleNext: PropTypes.func, + handlePrev: PropTypes.func, + hasPrefix: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onHistoryScroll: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyUp: PropTypes.func, + placeholder: PropTypes.string, + query: PropTypes.string, + selectedItemId: PropTypes.string, + shouldFocus: PropTypes.bool, + showClose: PropTypes.bool.isRequired, + showExcludePatterns: PropTypes.bool.isRequired, + excludePatternsLabel: PropTypes.string, + excludePatternsPlaceholder: PropTypes.string, + showErrorEmoji: PropTypes.bool.isRequired, + size: PropTypes.string, + summaryMsg: PropTypes.string, + searchKey: PropTypes.string.isRequired, + searchOptions: PropTypes.object, + setSearchOptions: PropTypes.func, + showSearchModifiers: PropTypes.bool.isRequired, + onToggleSearchModifier: PropTypes.func, + }; + } + + componentDidMount() { + this.setFocus(); + } + + componentDidUpdate(prevProps) { + if (this.props.shouldFocus && !prevProps.shouldFocus) { + this.setFocus(); + } + } + + setFocus() { + if (this.$input) { + const input = this.$input; + input.focus(); + + if (!input.value) { + return; + } + + // omit prefix @:# from being selected + const selectStartPos = this.props.hasPrefix ? 1 : 0; + input.setSelectionRange(selectStartPos, input.value.length + 1); + } + } + + renderArrowButtons() { + const { handleNext, handlePrev } = this.props; + + return [ + arrowBtn( + handlePrev, + "arrow-up", + classnames("nav-btn", "prev"), + L10N.getFormatStr("editor.searchResults.prevResult") + ), + arrowBtn( + handleNext, + "arrow-down", + classnames("nav-btn", "next"), + L10N.getFormatStr("editor.searchResults.nextResult") + ), + ]; + } + + onFocus = e => { + const { onFocus } = this.props; + + if (onFocus) { + onFocus(e); + } + }; + + onBlur = e => { + const { onBlur } = this.props; + + if (onBlur) { + onBlur(e); + } + }; + + onKeyDown = e => { + const { onHistoryScroll, onKeyDown } = this.props; + if (!onHistoryScroll) { + onKeyDown(e); + return; + } + + const inputValue = e.target.value; + const { history } = this.state; + const currentHistoryIndex = history.indexOf(inputValue); + + if (e.key === "Enter") { + this.saveEnteredTerm(inputValue); + onKeyDown(e); + return; + } + + if (e.key === "ArrowUp") { + const previous = + currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1; + const previousInHistory = history[previous]; + if (previousInHistory) { + e.preventDefault(); + onHistoryScroll(previousInHistory); + } + return; + } + + if (e.key === "ArrowDown") { + const next = currentHistoryIndex + 1; + const nextInHistory = history[next]; + if (nextInHistory) { + onHistoryScroll(nextInHistory); + } + } + }; + + onExcludeKeyDown = e => { + if (e.key === "Enter") { + this.props.setSearchOptions(this.props.searchKey, { + excludePatterns: this.state.excludePatterns, + }); + this.props.onKeyDown(e); + } + }; + + saveEnteredTerm(query) { + const { history } = this.state; + const previousIndex = history.indexOf(query); + if (previousIndex !== -1) { + history.splice(previousIndex, 1); + } + history.push(query); + this.setState({ history }); + } + + renderSummaryMsg() { + const { summaryMsg } = this.props; + + if (!summaryMsg) { + return null; + } + + return <div className="search-field-summary">{summaryMsg}</div>; + } + + renderSpinner() { + const { isLoading } = this.props; + if (!isLoading) { + return null; + } + return <AccessibleImage className="loader spin" />; + } + + renderNav() { + const { count, handleNext, handlePrev } = this.props; + if ((!handleNext && !handlePrev) || !count || count == 1) { + return null; + } + + return ( + <div className="search-nav-buttons">{this.renderArrowButtons()}</div> + ); + } + + renderSearchModifiers() { + if (!this.props.showSearchModifiers) { + return null; + } + return ( + <SearchModifiers + modifiers={this.props.searchOptions} + onToggleSearchModifier={updatedOptions => { + this.props.setSearchOptions(this.props.searchKey, updatedOptions); + this.props.onToggleSearchModifier(); + }} + /> + ); + } + + renderExcludePatterns() { + if (!this.props.showExcludePatterns) { + return null; + } + + return ( + <div className={classnames("exclude-patterns-field", this.props.size)}> + <label>{this.props.excludePatternsLabel}</label> + <input + placeholder={this.props.excludePatternsPlaceholder} + value={this.state.excludePatterns} + onKeyDown={this.onExcludeKeyDown} + onChange={e => this.setState({ excludePatterns: e.target.value })} + /> + </div> + ); + } + + renderClose() { + if (!this.props.showClose) { + return null; + } + return ( + <React.Fragment> + <span className="pipe-divider" /> + <CloseButton + handleClick={this.props.handleClose} + buttonClass={this.props.size} + /> + </React.Fragment> + ); + } + + render() { + const { + expanded, + onChange, + onKeyUp, + placeholder, + query, + selectedItemId, + showErrorEmoji, + size, + } = this.props; + + const inputProps = { + className: classnames({ + empty: showErrorEmoji, + }), + onChange, + onKeyDown: e => this.onKeyDown(e), + onKeyUp, + onFocus: e => this.onFocus(e), + onBlur: e => this.onBlur(e), + "aria-autocomplete": "list", + "aria-controls": "result-list", + "aria-activedescendant": + expanded && selectedItemId ? `${selectedItemId}-title` : "", + placeholder, + value: query, + spellCheck: false, + ref: c => (this.$input = c), + }; + + return ( + <div className="search-outline"> + <div + className={classnames("search-field", size)} + role="combobox" + aria-haspopup="listbox" + aria-owns="result-list" + aria-expanded={expanded} + > + <AccessibleImage className="search" /> + <input {...inputProps} /> + {this.renderSpinner()} + {this.renderSummaryMsg()} + {this.renderNav()} + <div className="search-buttons-bar"> + {this.renderSearchModifiers()} + {this.renderClose()} + </div> + </div> + {this.renderExcludePatterns()} + </div> + ); + } +} +const mapStateToProps = (state, props) => ({ + searchOptions: getSearchOptions(state, props.searchKey), +}); + +export default connect(mapStateToProps, { + setSearchOptions: actions.setSearchOptions, +})(SearchInput); diff --git a/devtools/client/debugger/src/components/shared/SmartGap.js b/devtools/client/debugger/src/components/shared/SmartGap.js new file mode 100644 index 0000000000..785d7496fb --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SmartGap.js @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +function shorten(coordinates) { + // In cases where the token is wider than the preview, the smartGap + // gets distorted. This shortens the coordinate array so that the smartGap + // is only touching 2 corners of the token (instead of all 4 corners) + coordinates.splice(0, 2); + coordinates.splice(4, 2); + return coordinates; +} + +function getSmartGapCoordinates( + preview, + token, + offset, + orientation, + gapHeight, + coords +) { + if (orientation === "up") { + const coordinates = [ + token.left - coords.left + offset, + token.top + token.height - (coords.top + preview.height) + gapHeight, + 0, + 0, + preview.width + offset, + 0, + token.left + token.width - coords.left + offset, + token.top + token.height - (coords.top + preview.height) + gapHeight, + token.left + token.width - coords.left + offset, + token.top - (coords.top + preview.height) + gapHeight, + token.left - coords.left + offset, + token.top - (coords.top + preview.height) + gapHeight, + ]; + return preview.width > token.width ? coordinates : shorten(coordinates); + } + if (orientation === "down") { + const coordinates = [ + token.left + token.width - (coords.left + preview.top) + offset, + 0, + preview.width + offset, + coords.top - token.top + gapHeight, + 0, + coords.top - token.top + gapHeight, + token.left - (coords.left + preview.top) + offset, + 0, + token.left - (coords.left + preview.top) + offset, + token.height, + token.left + token.width - (coords.left + preview.top) + offset, + token.height, + ]; + return preview.width > token.width ? coordinates : shorten(coordinates); + } + return [ + 0, + token.top - coords.top, + gapHeight + token.width, + 0, + gapHeight + token.width, + preview.height - gapHeight, + 0, + token.top + token.height - coords.top, + token.width, + token.top + token.height - coords.top, + token.width, + token.top - coords.top, + ]; +} + +function getSmartGapDimensions( + previewRect, + tokenRect, + offset, + orientation, + gapHeight, + coords +) { + if (orientation === "up") { + return { + height: + tokenRect.top + + tokenRect.height - + coords.top - + previewRect.height + + gapHeight, + width: Math.max(previewRect.width, tokenRect.width) + offset, + }; + } + if (orientation === "down") { + return { + height: coords.top - tokenRect.top + gapHeight, + width: Math.max(previewRect.width, tokenRect.width) + offset, + }; + } + return { + height: previewRect.height - gapHeight, + width: coords.left - tokenRect.left + gapHeight, + }; +} + +export default function SmartGap({ + token, + preview, + type, + gapHeight, + coords, + offset, +}) { + const tokenRect = token.getBoundingClientRect(); + const previewRect = preview.getBoundingClientRect(); + const { orientation } = coords; + let optionalMarginLeft, optionalMarginTop; + + if (orientation === "down") { + optionalMarginTop = -tokenRect.height; + } else if (orientation === "right") { + optionalMarginLeft = -tokenRect.width; + } + + const { height, width } = getSmartGapDimensions( + previewRect, + tokenRect, + -offset, + orientation, + gapHeight, + coords + ); + const coordinates = getSmartGapCoordinates( + previewRect, + tokenRect, + -offset, + orientation, + gapHeight, + coords + ); + + return ( + <svg + version="1.1" + xmlns="http://www.w3.org/2000/svg" + style={{ + height, + width, + position: "absolute", + marginLeft: optionalMarginLeft, + marginTop: optionalMarginTop, + }} + > + <polygon points={coordinates} fill="transparent" /> + </svg> + ); +} + +SmartGap.propTypes = { + coords: PropTypes.object.isRequired, + gapHeight: PropTypes.number.isRequired, + offset: PropTypes.number.isRequired, + preview: PropTypes.object.isRequired, + token: PropTypes.object.isRequired, + type: PropTypes.oneOf(["popover", "tooltip"]).isRequired, +}; diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.css b/devtools/client/debugger/src/components/shared/SourceIcon.css new file mode 100644 index 0000000000..0b9bf3e79e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SourceIcon.css @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Variant of AccessibleImage used in sources list and tabs. + * Define the different source type / framework / library icons here. + */ + +.source-icon { + margin-inline-end: 4px; +} + +/* Icons for frameworks and libs */ + +.img.aframe { + background-image: url(chrome://devtools/content/debugger/images/sources/aframe.svg); + background-color: transparent !important; +} + +.img.angular { + background-image: url(chrome://devtools/content/debugger/images/sources/angular.svg); + background-color: transparent !important; +} + +.img.babel { + mask-image: url(chrome://devtools/content/debugger/images/sources/babel.svg); +} + +.img.backbone { + mask-image: url(chrome://devtools/content/debugger/images/sources/backbone.svg); +} + +.img.choo { + background-image: url(chrome://devtools/content/debugger/images/sources/choo.svg); + background-color: transparent !important; +} + +.img.coffeescript { + background-image: url(chrome://devtools/content/debugger/images/sources/coffeescript.svg); + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.dojo { + background-image: url(chrome://devtools/content/debugger/images/sources/dojo.svg); + background-color: transparent !important; +} + +.img.ember { + background-image: url(chrome://devtools/content/debugger/images/sources/ember.svg); + background-color: transparent !important; +} + +.img.express { + mask-image: url(chrome://devtools/content/debugger/images/sources/express.svg); +} + +.img.extension { + mask-image: url(chrome://devtools/content/debugger/images/sources/extension.svg); +} + +.img.immutable { + mask-image: url(chrome://devtools/content/debugger/images/sources/immutable.svg); +} + +.img.javascript { + background-image: url(chrome://devtools/content/debugger/images/sources/javascript.svg); + background-size: 14px 14px; + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.override::after { + content: ""; + display: block; + height: 5px; + width: 5px; + background-color: var(--purple-30); + border-radius: 100%; + outline: 1px solid var(--theme-sidebar-background); + translate: 12px 10px; +} + +.node.focused .img.override::after { + outline-color: var(--theme-selection-background); +} + +.img.jquery { + mask-image: url(chrome://devtools/content/debugger/images/sources/jquery.svg); +} + +.img.lodash { + mask-image: url(chrome://devtools/content/debugger/images/sources/lodash.svg); +} + +.img.marko { + background-image: url(chrome://devtools/content/debugger/images/sources/marko.svg); + background-color: transparent !important; +} + +.img.mobx { + background-image: url(chrome://devtools/content/debugger/images/sources/mobx.svg); + background-color: transparent !important; +} + +.img.nextjs { + background-image: url(chrome://devtools/content/debugger/images/sources/nextjs.svg); + background-color: transparent !important; +} + +.img.node { + background-image: url(chrome://devtools/content/debugger/images/sources/node.svg); + background-color: transparent !important; +} + +.img.nuxtjs { + background-image: url(chrome://devtools/content/debugger/images/sources/nuxtjs.svg); + background-color: transparent !important; +} + +.img.preact { + background-image: url(chrome://devtools/content/debugger/images/sources/preact.svg); + background-color: transparent !important; +} + +.img.pug { + background-image: url(chrome://devtools/content/debugger/images/sources/pug.svg); + background-color: transparent !important; +} + +.img.react { + background-image: url(chrome://devtools/content/debugger/images/sources/react.svg); + background-color: transparent !important; + fill: var(--theme-highlight-bluegrey); + -moz-context-properties: fill; +} + +.img.redux { + mask-image: url(chrome://devtools/content/debugger/images/sources/redux.svg); +} + +.img.rxjs { + background-image: url(chrome://devtools/content/debugger/images/sources/rxjs.svg); + background-color: transparent !important; +} + +.img.sencha-extjs { + background-image: url(chrome://devtools/content/debugger/images/sources/sencha-extjs.svg); + background-color: transparent !important; +} + +.img.typescript { + background-image: url(chrome://devtools/content/debugger/images/sources/typescript.svg); + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.underscore { + mask-image: url(chrome://devtools/content/debugger/images/sources/underscore.svg); +} + +/* We use both 'Vue' and 'VueJS' when identifying frameworks */ +.img.vue, +.img.vuejs { + background-image: url(chrome://devtools/content/debugger/images/sources/vuejs.svg); + background-color: transparent !important; +} + +.img.webpack { + background-image: url(chrome://devtools/content/debugger/images/sources/webpack.svg); + background-color: transparent !important; +} diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.js b/devtools/client/debugger/src/components/shared/SourceIcon.js new file mode 100644 index 0000000000..fed2e01f57 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SourceIcon.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; + +import AccessibleImage from "./AccessibleImage"; + +import { getSourceClassnames } from "../../utils/source"; +import { getSymbols, isSourceBlackBoxed, hasPrettyTab } from "../../selectors"; + +import "./SourceIcon.css"; + +class SourceIcon extends PureComponent { + static get propTypes() { + return { + modifier: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + iconClass: PropTypes.string, + forTab: PropTypes.bool, + }; + } + + render() { + const { modifier } = this.props; + let { iconClass } = this.props; + + if (modifier) { + const modified = modifier(iconClass); + if (!modified) { + return null; + } + iconClass = modified; + } + + return <AccessibleImage className={`source-icon ${iconClass}`} />; + } +} + +export default connect((state, props) => { + const { forTab, location } = props; + // BreakpointHeading sometimes spawn locations without source actor for generated sources + // which disallows fetching symbols. In such race condition return the default icon. + // (this reproduces when running browser_dbg-breakpoints-popup.js) + if (!location.source.isOriginal && !location.sourceActor) { + return "file"; + } + const symbols = getSymbols(state, location); + const isBlackBoxed = isSourceBlackBoxed(state, location.source); + // For the tab icon, we don't want to show the pretty icon for the non-pretty tab + const hasMatchingPrettyTab = + !forTab && hasPrettyTab(state, location.source.url); + + // This is the key function that will compute the icon type, + // In addition to the "modifier" implemented by each callsite. + const iconClass = getSourceClassnames( + location.source, + symbols, + isBlackBoxed, + hasMatchingPrettyTab + ); + + return { + iconClass, + }; +})(SourceIcon); diff --git a/devtools/client/debugger/src/components/shared/menu.css b/devtools/client/debugger/src/components/shared/menu.css new file mode 100644 index 0000000000..37dfbc2e8f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/menu.css @@ -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/>. */ + +menupopup { + position: fixed; + z-index: 10000; + border: 1px solid #cccccc; + padding: 5px 0; + background: #f2f2f2; + border-radius: 5px; + color: #585858; + box-shadow: 0 0 4px 0 rgba(190, 190, 190, 0.8); + min-width: 130px; +} + +menuitem { + display: block; + padding: 0 20px; + line-height: 20px; + font-weight: 500; + font-size: 13px; + user-select: none; +} + +menuitem:hover { + background: #3780fb; + color: white; +} + +menuitem[disabled="true"] { + color: #cccccc; +} + +menuitem[disabled="true"]:hover { + background-color: transparent; + cursor: default; +} + +menuseparator { + border-bottom: 1px solid #cacdd3; + width: 100%; + height: 5px; + display: block; + margin-bottom: 5px; +} + +#contextmenu-mask.show { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; +} diff --git a/devtools/client/debugger/src/components/shared/moz.build b/devtools/client/debugger/src/components/shared/moz.build new file mode 100644 index 0000000000..b30ea0ab4f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/moz.build @@ -0,0 +1,23 @@ +# 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 += [ + "Button", +] + +CompiledModules( + "AccessibleImage.js", + "Accordion.js", + "Badge.js", + "BracketArrow.js", + "Dropdown.js", + "Modal.js", + "Popover.js", + "PreviewFunction.js", + "ResultList.js", + "SearchInput.js", + "SourceIcon.js", + "SmartGap.js", +) diff --git a/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js new file mode 100644 index 0000000000..c15dbb827c --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import Accordion from "../Accordion"; + +describe("Accordion", () => { + const testItems = [ + { + header: "Test Accordion Item 1", + className: "accordion-item-1", + component: <div />, + opened: false, + onToggle: jest.fn(), + }, + { + header: "Test Accordion Item 2", + className: "accordion-item-2", + component: <div />, + buttons: <button />, + opened: false, + onToggle: jest.fn(), + }, + { + header: "Test Accordion Item 3", + className: "accordion-item-3", + component: <div />, + opened: true, + onToggle: jest.fn(), + }, + ]; + const wrapper = shallow(<Accordion items={testItems} />); + it("basic render", () => expect(wrapper).toMatchSnapshot()); + wrapper.find(".accordion-item-1 ._header").simulate("click"); + it("handleClick and onToggle", () => + expect(testItems[0].onToggle).toHaveBeenCalledWith(true)); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Badge.spec.js b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js new file mode 100644 index 0000000000..6a10b7f9e4 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Badge.spec.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 React from "react"; +import { shallow } from "enzyme"; + +import Badge from "../Badge"; + +describe("Badge", () => { + it("render", () => expect(shallow(<Badge>{3}</Badge>)).toMatchSnapshot()); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js new file mode 100644 index 0000000000..37f58fbfdc --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import BracketArrow from "../BracketArrow"; + +describe("BracketArrow", () => { + const wrapper = shallow( + <BracketArrow orientation="down" left={10} top={20} bottom={50} /> + ); + it("render", () => expect(wrapper).toMatchSnapshot()); + it("render up", () => { + wrapper.setProps({ orientation: null }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js new file mode 100644 index 0000000000..b01f6fa059 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import Dropdown from "../Dropdown"; + +describe("Dropdown", () => { + const wrapper = shallow(<Dropdown panel={<div />} icon="✅" />); + it("render", () => expect(wrapper).toMatchSnapshot()); + wrapper.find(".dropdown").simulate("click"); + it("handle toggleDropdown", () => + expect(wrapper.state().dropdownShown).toEqual(true)); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Modal.spec.js b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js new file mode 100644 index 0000000000..d609d3fda0 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import { Modal } from "../Modal"; + +describe("Modal", () => { + it("renders", () => { + const wrapper = shallow(<Modal handleClose={() => {}} status="entering" />); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles close modal click", () => { + const handleCloseSpy = jest.fn(); + const wrapper = shallow( + <Modal handleClose={handleCloseSpy} status="entering" /> + ); + wrapper.find(".modal-wrapper").simulate("click"); + expect(handleCloseSpy).toHaveBeenCalled(); + }); + + it("renders children", () => { + const children = <div className="aChild" />; + const wrapper = shallow( + <Modal children={children} handleClose={() => {}} status="entering" /> + ); + expect(wrapper.find(".aChild")).toHaveLength(1); + }); + + it("passes additionalClass to child div class", () => { + const additionalClass = "testAddon"; + const wrapper = shallow( + <Modal + additionalClass={additionalClass} + handleClose={() => {}} + status="entering" + /> + ); + expect(wrapper.find(`.modal-wrapper .${additionalClass}`)).toHaveLength(1); + }); + + it("passes status to child div class", () => { + const status = "testStatus"; + const wrapper = shallow(<Modal status={status} handleClose={() => {}} />); + expect(wrapper.find(`.modal-wrapper .${status}`)).toHaveLength(1); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Popover.spec.js b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js new file mode 100644 index 0000000000..fb44f16597 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js @@ -0,0 +1,200 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { mount } from "enzyme"; + +import Popover from "../Popover"; + +describe("Popover", () => { + const onMouseLeave = jest.fn(); + const onKeyDown = jest.fn(); + const editorRef = { + getBoundingClientRect() { + return { + x: 0, + y: 0, + width: 100, + height: 100, + top: 250, + right: 0, + bottom: 0, + left: 20, + }; + }, + }; + + const targetRef = { + getBoundingClientRect() { + return { + x: 0, + y: 0, + width: 100, + height: 100, + top: 250, + right: 0, + bottom: 0, + left: 20, + }; + }, + }; + const targetPosition = { + x: 100, + y: 200, + width: 300, + height: 300, + top: 50, + right: 0, + bottom: 0, + left: 200, + }; + const popover = mount( + <Popover + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Poppy!</h1> + </Popover> + ); + + const tooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + beforeEach(() => { + onMouseLeave.mockClear(); + onKeyDown.mockClear(); + }); + + it("render", () => expect(popover).toMatchSnapshot()); + + it("render (tooltip)", () => expect(tooltip).toMatchSnapshot()); + + it("mount popover", () => { + const mountedPopover = mount( + <Popover + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Poppy!</h1> + </Popover> + ); + expect(mountedPopover).toMatchSnapshot(); + }); + + it("mount tooltip", () => { + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + expect(mountedTooltip).toMatchSnapshot(); + }); + + it("tooltip normally displays above the target", () => { + const editor = { + getBoundingClientRect() { + return { + width: 500, + height: 500, + top: 0, + bottom: 500, + left: 0, + right: 500, + }; + }, + }; + const target = { + width: 30, + height: 10, + top: 100, + bottom: 110, + left: 20, + right: 50, + }; + + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editor} + targetPosition={target} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10); + expect(toolTipTop).toBeLessThanOrEqual(target.top); + }); + + it("tooltop won't display above the target when insufficient space", () => { + const editor = { + getBoundingClientRect() { + return { + width: 100, + height: 100, + top: 0, + bottom: 100, + left: 0, + right: 100, + }; + }, + }; + const target = { + width: 30, + height: 10, + top: 0, + bottom: 10, + left: 20, + right: 50, + }; + + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editor} + targetPosition={target} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10); + expect(toolTipTop).toBeGreaterThanOrEqual(target.bottom); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js new file mode 100644 index 0000000000..391e5628df --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.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 React from "react"; +import { shallow } from "enzyme"; +import PreviewFunction from "../PreviewFunction"; + +function render(props) { + return shallow(<PreviewFunction {...props} />, { context: { l10n: L10N } }); +} + +describe("PreviewFunction", () => { + it("should return a span", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan).toMatchSnapshot(); + expect(returnedSpan.name()).toEqual("span"); + }); + + it('should return a span with a class of "function-signature"', () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.hasClass("function-signature")).toBe(true); + }); + + it("should return a span with 3 children", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children()).toHaveLength(3); + }); + + describe("function name", () => { + it("should be a span", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().name()).toEqual("span"); + }); + + it('should have a "function-name" class', () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().hasClass("function-name")).toBe( + true + ); + }); + + it("should be be set to userDisplayName if defined", () => { + const item = { + name: "", + userDisplayName: "chuck", + displayName: "norris", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("chuck"); + }); + + it('should use displayName if defined & no "userDisplayName" exist', () => { + const item = { + displayName: "norris", + name: "last", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("norris"); + }); + + it('should use to name if no "userDisplayName"/"displayName" exist', () => { + const item = { + name: "last", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("last"); + }); + }); + + describe("render parentheses", () => { + let leftParen; + let rightParen; + + beforeAll(() => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + const children = returnedSpan.children(); + leftParen = returnedSpan.childAt(1); + rightParen = returnedSpan.childAt(children.length - 1); + }); + + it("should be spans", () => { + expect(leftParen.name()).toEqual("span"); + expect(rightParen.name()).toEqual("span"); + }); + + it("should create a left paren", () => { + expect(leftParen.text()).toEqual("("); + }); + + it("should create a right paren", () => { + expect(rightParen.text()).toEqual(")"); + }); + }); + + describe("render parameters", () => { + let returnedSpan; + let children; + + beforeAll(() => { + const item = { + name: "", + parameterNames: ["one", "two", "three"], + }; + returnedSpan = render({ func: item }); + children = returnedSpan.children(); + }); + + it("should render spans according to the dynamic params given", () => { + expect(children).toHaveLength(8); + }); + + it("should render the parameters names", () => { + expect(returnedSpan.childAt(2).text()).toEqual("one"); + }); + + it("should render the parameters commas", () => { + expect(returnedSpan.childAt(3).text()).toEqual(", "); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js new file mode 100644 index 0000000000..2751f3abd6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import ResultList from "../ResultList"; + +const selectItem = jest.fn(); +const selectedIndex = 1; +const payload = { + items: [ + { + id: 0, + subtitle: "subtitle", + title: "title", + value: "value", + }, + { + id: 1, + subtitle: "subtitle 1", + title: "title 1", + value: "value 1", + }, + ], + selected: selectedIndex, + selectItem, +}; + +describe("Result list", () => { + it("should call onClick function", () => { + const wrapper = shallow(<ResultList {...payload} />); + + wrapper.childAt(selectedIndex).simulate("click"); + expect(selectItem).toHaveBeenCalled(); + }); + + it("should render the component", () => { + const wrapper = shallow(<ResultList {...payload} />); + expect(wrapper).toMatchSnapshot(); + }); + + it("selected index should have 'selected class'", () => { + const wrapper = shallow(<ResultList {...payload} />); + const childHasClass = wrapper.childAt(selectedIndex).hasClass("selected"); + + expect(childHasClass).toEqual(true); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js new file mode 100644 index 0000000000..c0fff81b24 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import configureStore from "redux-mock-store"; + +import SearchInput from "../SearchInput"; + +describe("SearchInput", () => { + // !! wrapper is defined outside test scope + // so it will keep values between tests + const mockStore = configureStore([]); + const store = mockStore({ + ui: { mutableSearchOptions: { "foo-search": {} } }, + }); + const wrapper = shallow( + <SearchInput + store={store} + query="" + count={5} + placeholder="A placeholder" + summaryMsg="So many results" + showErrorEmoji={false} + isLoading={false} + onChange={() => {}} + onKeyDown={() => {}} + searchKey="foo-search" + showSearchModifiers={false} + showExcludePatterns={false} + showClose={true} + handleClose={jest.fn()} + setSearchOptions={jest.fn()} + /> + ).dive(); + + it("renders", () => expect(wrapper).toMatchSnapshot()); + + it("shows nav buttons", () => { + wrapper.setProps({ + handleNext: jest.fn(), + handlePrev: jest.fn(), + }); + expect(wrapper).toMatchSnapshot(); + }); + + it("shows svg error emoji", () => { + wrapper.setProps({ showErrorEmoji: true }); + expect(wrapper).toMatchSnapshot(); + }); + + it("shows svg magnifying glass", () => { + wrapper.setProps({ showErrorEmoji: false }); + expect(wrapper).toMatchSnapshot(); + }); + + describe("with optional onHistoryScroll", () => { + const searches = ["foo", "bar", "baz"]; + const createSearch = term => ({ + target: { value: term }, + key: "Enter", + }); + + const scrollUp = currentTerm => ({ + key: "ArrowUp", + target: { value: currentTerm }, + preventDefault: jest.fn(), + }); + const scrollDown = currentTerm => ({ + key: "ArrowDown", + target: { value: currentTerm }, + preventDefault: jest.fn(), + }); + + it("stores entered history in state", () => { + wrapper.setProps({ + onHistoryScroll: jest.fn(), + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + }); + + it("stores scroll history in state", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + expect(wrapper.state().history[1]).toEqual(searches[1]); + }); + + it("scrolls up stored history on arrow up", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[1])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + expect(wrapper.state().history[1]).toEqual(searches[1]); + expect(onHistoryScroll).toHaveBeenCalledWith(searches[0]); + }); + + it("scrolls down stored history on arrow down", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + wrapper.find("input").simulate("keyDown", createSearch(searches[2])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[2])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[1])); + wrapper.find("input").simulate("keyDown", scrollDown(searches[0])); + expect(onHistoryScroll.mock.calls[2][0]).toBe(searches[1]); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap new file mode 100644 index 0000000000..7ab4ed1ee6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Accordion basic render 1`] = ` +<ul + className="accordion" +> + <li + className="accordion-item-1" + key="0" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow expanded" + /> + <span + className="header-label" + > + Test Accordion Item 1 + </span> + </h2> + <div + className="_content" + > + <div /> + </div> + </li> + <li + className="accordion-item-2" + key="1" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow " + /> + <span + className="header-label" + > + Test Accordion Item 2 + </span> + <div + className="header-buttons" + tabIndex="-1" + > + <button /> + </div> + </h2> + </li> + <li + className="accordion-item-3" + key="2" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow expanded" + /> + <span + className="header-label" + > + Test Accordion Item 3 + </span> + </h2> + <div + className="_content" + > + <div /> + </div> + </li> +</ul> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap new file mode 100644 index 0000000000..cbeeeaa3f2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Badge render 1`] = ` +<span + className="badge text-white text-center" +> + 3 +</span> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap new file mode 100644 index 0000000000..5078cebc9e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BracketArrow render 1`] = ` +<div + className="bracket-arrow down" + style={ + Object { + "bottom": 50, + "left": 10, + "top": 20, + } + } +/> +`; + +exports[`BracketArrow render up 1`] = ` +<div + className="bracket-arrow up" + style={ + Object { + "bottom": 50, + "left": 10, + "top": 20, + } + } +/> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap new file mode 100644 index 0000000000..fd60784327 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dropdown render 1`] = ` +<div + className="dropdown-block" +> + <div + className="dropdown" + onClick={[Function]} + style={ + Object { + "display": "block", + } + } + > + <div /> + </div> + <button + className="dropdown-button" + onClick={[Function]} + > + ✅ + </button> + <div + className="dropdown-mask" + onClick={[Function]} + style={ + Object { + "display": "block", + } + } + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap new file mode 100644 index 0000000000..e9b9639749 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal renders 1`] = ` +<div + className="modal-wrapper" + onClick={[Function]} +> + <div + className="modal entering" + onClick={[Function]} + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap new file mode 100644 index 0000000000..1c3589a6f8 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap @@ -0,0 +1,549 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Popover mount popover 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="popover" +> + <div + className="popover orientation-right" + style={ + Object { + "left": 500, + "top": -50, + } + } + > + <BracketArrow + left={-4} + orientation="left" + top={98} + > + <div + className="bracket-arrow left" + style={ + Object { + "bottom": undefined, + "left": -4, + "top": 98, + } + } + /> + </BracketArrow> + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": 500, + "orientation": "right", + "targetMid": Object { + "x": -14, + "y": 98, + }, + "top": -50, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="popover orientation-right" + style="top: -50px; left: 500px;" + > + <div + class="bracket-arrow left" + style="left: -4px; top: 98px;" + /> + <div + class="gap" + > + <svg + style="height: 0px; width: 480px; position: absolute; margin-left: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,300,100,0,100,0,0,400,100,400,100,300" + /> + </svg> + </div> + <h1> + Poppy! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="popover" + > + <svg + style={ + Object { + "height": 0, + "marginLeft": -100, + "marginTop": undefined, + "position": "absolute", + "width": 480, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + 300, + 100, + 0, + 100, + 0, + 0, + 400, + 100, + 400, + 100, + 300, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Poppy! + </h1> + </div> +</Popover> +`; + +exports[`Popover mount tooltip 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="tooltip" +> + <div + className="tooltip orientation-down" + style={ + Object { + "left": -8, + "top": 0, + } + } + > + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": -8, + "orientation": "down", + "targetMid": Object { + "x": 0, + "y": 0, + }, + "top": 0, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="tooltip orientation-down" + style="top: 0px; left: -8px;" + > + <div + class="gap" + > + <svg + style="height: -250px; width: 100px; position: absolute; margin-top: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,-250,0,-250,28,100,128,100" + /> + </svg> + </div> + <h1> + Toolie! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="tooltip" + > + <svg + style={ + Object { + "height": -250, + "marginLeft": undefined, + "marginTop": -100, + "position": "absolute", + "width": 100, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + -250, + 0, + -250, + 28, + 100, + 128, + 100, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Toolie! + </h1> + </div> +</Popover> +`; + +exports[`Popover render (tooltip) 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="tooltip" +> + <div + className="tooltip orientation-down" + style={ + Object { + "left": -8, + "top": 0, + } + } + > + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": -8, + "orientation": "down", + "targetMid": Object { + "x": 0, + "y": 0, + }, + "top": 0, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="tooltip orientation-down" + style="top: 0px; left: -8px;" + > + <div + class="gap" + > + <svg + style="height: -250px; width: 100px; position: absolute; margin-top: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,-250,0,-250,28,100,128,100" + /> + </svg> + </div> + <h1> + Toolie! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="tooltip" + > + <svg + style={ + Object { + "height": -250, + "marginLeft": undefined, + "marginTop": -100, + "position": "absolute", + "width": 100, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + -250, + 0, + -250, + 28, + 100, + 128, + 100, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Toolie! + </h1> + </div> +</Popover> +`; + +exports[`Popover render 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="popover" +> + <div + className="popover orientation-right" + style={ + Object { + "left": 500, + "top": -50, + } + } + > + <BracketArrow + left={-4} + orientation="left" + top={98} + > + <div + className="bracket-arrow left" + style={ + Object { + "bottom": undefined, + "left": -4, + "top": 98, + } + } + /> + </BracketArrow> + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": 500, + "orientation": "right", + "targetMid": Object { + "x": -14, + "y": 98, + }, + "top": -50, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="popover orientation-right" + style="top: -50px; left: 500px;" + > + <div + class="bracket-arrow left" + style="left: -4px; top: 98px;" + /> + <div + class="gap" + > + <svg + style="height: 0px; width: 480px; position: absolute; margin-left: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,300,100,0,100,0,0,400,100,400,100,300" + /> + </svg> + </div> + <h1> + Poppy! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="popover" + > + <svg + style={ + Object { + "height": 0, + "marginLeft": -100, + "marginTop": undefined, + "position": "absolute", + "width": 480, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + 300, + 100, + 0, + 100, + 0, + 0, + 400, + 100, + 400, + 100, + 300, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Poppy! + </h1> + </div> +</Popover> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap new file mode 100644 index 0000000000..e766bd45aa --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewFunction should return a span 1`] = ` +<span + className="function-signature" +> + <span + className="function-name" + > + <anonymous> + </span> + <span + className="paren" + > + ( + </span> + <span + className="paren" + > + ) + </span> +</span> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap new file mode 100644 index 0000000000..d3d8b27575 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Result list should render the component 1`] = ` +<ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" +> + <li + aria-describedby="0-subtitle" + aria-labelledby="0-title" + className="result-item" + key="0value0" + onClick={[Function]} + role="option" + title="value" + > + <div + className="title" + id="0-title" + > + title + </div> + <div + className="subtitle" + id="0-subtitle" + > + subtitle + </div> + </li> + <li + aria-describedby="1-subtitle" + aria-labelledby="1-title" + className="result-item selected" + key="1value 11" + onClick={[Function]} + role="option" + title="value 1" + > + <div + className="title" + id="1-title" + > + title 1 + </div> + <div + className="subtitle" + id="1-subtitle" + > + subtitle 1 + </div> + </li> +</ul> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap new file mode 100644 index 0000000000..c56a13dc3b --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchInput renders 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows nav buttons 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows svg error emoji 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows svg magnifying glass 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/A11yIntention.spec.js b/devtools/client/debugger/src/components/test/A11yIntention.spec.js new file mode 100644 index 0000000000..6a529b851d --- /dev/null +++ b/devtools/client/debugger/src/components/test/A11yIntention.spec.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import A11yIntention from "../A11yIntention"; + +function render() { + return shallow( + <A11yIntention> + <span>hello world</span> + </A11yIntention> + ); +} + +describe("A11yIntention", () => { + it("renders its children", () => { + const component = render(); + expect(component).toMatchSnapshot(); + }); + + it("indicates that the mouse or keyboard is being used", () => { + const component = render(); + expect(component.prop("className")).toEqual("A11y-mouse"); + + component.simulate("keyDown"); + expect(component.prop("className")).toEqual("A11y-keyboard"); + + component.simulate("mouseDown"); + expect(component.prop("className")).toEqual("A11y-mouse"); + }); +}); diff --git a/devtools/client/debugger/src/components/test/Outline.spec.js b/devtools/client/debugger/src/components/test/Outline.spec.js new file mode 100644 index 0000000000..c104da53c3 --- /dev/null +++ b/devtools/client/debugger/src/components/test/Outline.spec.js @@ -0,0 +1,304 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import Outline from "../../components/PrimaryPanes/Outline"; +import { makeSymbolDeclaration } from "../../utils/test-head"; +import { mockcx } from "../../utils/test-mockup"; +import { showMenu } from "../../context-menu/menu"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +jest.mock("../../context-menu/menu", () => ({ showMenu: jest.fn() })); +jest.mock("../../utils/clipboard", () => ({ copyToTheClipboard: jest.fn() })); + +const sourceId = "id"; +const mockFunctionText = "mock function text"; + +function generateDefaults(overrides) { + return { + cx: mockcx, + selectLocation: jest.fn(), + selectedSource: { id: sourceId }, + getFunctionText: jest.fn().mockReturnValue(mockFunctionText), + flashLineRange: jest.fn(), + isHidden: false, + symbols: {}, + selectedLocation: { id: sourceId }, + onAlphabetizeClick: jest.fn(), + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<Outline.WrappedComponent {...props} />); + const instance = component.instance(); + return { component, props, instance }; +} + +describe("Outline", () => { + afterEach(() => { + copyToTheClipboard.mockClear(); + showMenu.mockClear(); + }); + + it("renders a list of functions when properties change", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("my_example_function1", 21), + makeSymbolDeclaration("my_example_function2", 22), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("selects a line of code in the current file on click", async () => { + const startLine = 12; + const symbols = { + functions: [makeSymbolDeclaration("my_example_function", startLine)], + }; + + const { component, props } = render({ symbols }); + + const { selectLocation } = props; + const listItem = component.find("li").first(); + listItem.simulate("click"); + expect(selectLocation).toHaveBeenCalledWith(mockcx, { + line: startLine, + column: undefined, + sourceId, + source: { + id: sourceId, + }, + sourceActor: null, + sourceActorId: undefined, + sourceUrl: "", + }); + }); + + describe("renders outline", () => { + describe("renders loading", () => { + it("if symbols is not defined", () => { + const { component } = render({ + symbols: null, + }); + expect(component).toMatchSnapshot(); + }); + }); + + it("renders ignore anonymous functions", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("my_example_function1", 21), + makeSymbolDeclaration("anonymous", 25), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + describe("renders placeholder", () => { + it("`No File Selected` if selectedSource is not defined", async () => { + const { component } = render({ + selectedSource: null, + }); + expect(component).toMatchSnapshot(); + }); + + it("`No functions` if all func are anonymous", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("anonymous", 25), + makeSymbolDeclaration("anonymous", 30), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("`No functions` if symbols has no func", async () => { + const symbols = { + functions: [], + }; + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + }); + + it("sorts functions alphabetically by function name", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("c_function", 25), + makeSymbolDeclaration("x_function", 30), + makeSymbolDeclaration("a_function", 70), + ], + }; + + const { component } = render({ + symbols, + alphabetizeOutline: true, + }); + expect(component).toMatchSnapshot(); + }); + + it("calls onAlphabetizeClick when sort button is clicked", async () => { + const symbols = { + functions: [makeSymbolDeclaration("example_function", 25)], + }; + + const { component, props } = render({ symbols }); + + await component + .find(".outline-footer") + .find("button") + .simulate("click", {}); + + expect(props.onAlphabetizeClick).toHaveBeenCalled(); + }); + + it("renders functions by function class", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("x_function", 25, 26, "x_klass"), + makeSymbolDeclaration("a2_function", 30, 31, "a_klass"), + makeSymbolDeclaration("a1_function", 70, 71, "a_klass"), + ], + classes: [ + makeSymbolDeclaration("x_klass", 24, 27), + makeSymbolDeclaration("a_klass", 29, 72), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("renders functions by function class, alphabetically", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("x_function", 25, 26, "x_klass"), + makeSymbolDeclaration("a2_function", 30, 31, "a_klass"), + makeSymbolDeclaration("a1_function", 70, 71, "a_klass"), + ], + classes: [ + makeSymbolDeclaration("x_klass", 24, 27), + makeSymbolDeclaration("a_klass", 29, 72), + ], + }; + + const { component } = render({ + symbols, + alphabetizeOutline: true, + }); + expect(component).toMatchSnapshot(); + }); + + it("selects class on click on class headline", async () => { + const symbols = { + functions: [makeSymbolDeclaration("x_function", 25, 26, "x_klass")], + classes: [makeSymbolDeclaration("x_klass", 24, 27)], + }; + + const { component, props } = render({ symbols }); + + await component.find("h2").simulate("click", {}); + + expect(props.selectLocation).toHaveBeenCalledWith(mockcx, { + line: 24, + column: undefined, + sourceId, + source: { + id: sourceId, + }, + sourceActor: null, + sourceActorId: undefined, + sourceUrl: "", + }); + }); + + it("does not select an item if selectedSource is not defined", async () => { + const { instance, props } = render({ selectedSource: null }); + await instance.selectItem({}); + expect(props.selectLocation).not.toHaveBeenCalled(); + }); + }); + + describe("onContextMenu of Outline", () => { + it("is called onContextMenu for each item", async () => { + const event = { event: "oncontextmenu" }; + const fn = makeSymbolDeclaration("exmple_function", 2); + const symbols = { + functions: [fn], + }; + + const { component, instance } = render({ symbols }); + instance.onContextMenu = jest.fn(() => {}); + await component + .find(".outline-list__element") + .simulate("contextmenu", event); + + expect(instance.onContextMenu).toHaveBeenCalledWith(event, fn); + }); + + it("does not show menu with no selected source", async () => { + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const { instance } = render({ + selectedSource: null, + }); + await instance.onContextMenu(mockEvent, {}); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(showMenu).not.toHaveBeenCalled(); + }); + + it("shows menu to copy func, copies to clipboard on click", async () => { + const startLine = 12; + const endLine = 21; + const func = makeSymbolDeclaration( + "my_example_function", + startLine, + endLine + ); + const symbols = { + functions: [func], + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const { instance, props } = render({ symbols }); + await instance.onContextMenu(mockEvent, func); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + + const expectedMenuOptions = [ + { + accesskey: "F", + click: expect.any(Function), + disabled: false, + id: "node-menu-copy-function", + label: "Copy function", + }, + ]; + expect(props.getFunctionText).toHaveBeenCalledWith(12); + expect(showMenu).toHaveBeenCalledWith(mockEvent, expectedMenuOptions); + + showMenu.mock.calls[0][1][0].click(); + expect(copyToTheClipboard).toHaveBeenCalledWith(mockFunctionText); + expect(props.flashLineRange).toHaveBeenCalledWith({ + end: endLine, + sourceId, + start: startLine, + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/test/OutlineFilter.spec.js b/devtools/client/debugger/src/components/test/OutlineFilter.spec.js new file mode 100644 index 0000000000..91ec7c0d97 --- /dev/null +++ b/devtools/client/debugger/src/components/test/OutlineFilter.spec.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import OutlineFilter from "../../components/PrimaryPanes/OutlineFilter"; + +function generateDefaults(overrides) { + return { + filter: "", + updateFilter: jest.fn(), + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<OutlineFilter {...props} />); + const instance = component.instance(); + return { component, props, instance }; +} + +describe("OutlineFilter", () => { + it("shows an input with no value when filter is empty", async () => { + const { component } = render({ filter: "" }); + expect(component).toMatchSnapshot(); + }); + + it("shows an input with the filter when it is not empty", async () => { + const { component } = render({ filter: "abc" }); + expect(component).toMatchSnapshot(); + }); + + it("calls props.updateFilter on change", async () => { + const updateFilter = jest.fn(); + const { component } = render({ updateFilter }); + const input = component.find("input"); + input.simulate("change", { target: { value: "a" } }); + input.simulate("change", { target: { value: "ab" } }); + expect(updateFilter).toHaveBeenCalled(); + expect(updateFilter.mock.calls[0][0]).toBe("a"); + expect(updateFilter.mock.calls[1][0]).toBe("ab"); + }); +}); diff --git a/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js new file mode 100644 index 0000000000..3cd21bac05 --- /dev/null +++ b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js @@ -0,0 +1,898 @@ +/* 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 React from "react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; + +import { shallow, mount } from "enzyme"; +import { QuickOpenModal } from "../QuickOpenModal"; +import { mockcx } from "../../utils/test-mockup"; +import { getDisplayURL } from "../../utils/sources-tree/getURL"; +import { searchKeys } from "../../constants"; + +jest.mock("fuzzaldrin-plus"); + +import { filter } from "fuzzaldrin-plus"; + +function generateModal(propOverrides, renderType = "shallow") { + const mockStore = configureStore([]); + const store = mockStore({ + ui: { + mutableSearchOptions: { + [searchKeys.QUICKOPEN_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + }, + }, + }); + const props = { + cx: mockcx, + enabled: false, + query: "", + searchType: "sources", + displayedSources: [], + blackBoxRanges: {}, + tabUrls: [], + selectSpecificLocation: jest.fn(), + setQuickOpenQuery: jest.fn(), + highlightLineRange: jest.fn(), + clearHighlightLineRange: jest.fn(), + closeQuickOpen: jest.fn(), + shortcutsModalEnabled: false, + symbols: { functions: [] }, + symbolsLoading: false, + toggleShortcutsModal: jest.fn(), + isOriginal: false, + thread: "FakeThread", + ...propOverrides, + }; + return { + wrapper: + renderType === "shallow" + ? shallow( + <Provider store={store}> + <QuickOpenModal {...props} /> + </Provider> + ).dive() + : mount( + <Provider store={store}> + <QuickOpenModal {...props} /> + </Provider> + ), + props, + }; +} + +function generateQuickOpenResult(title) { + return { + id: "qor", + value: "", + title, + }; +} + +async function waitForUpdateResultsThrottle() { + await new Promise(res => + setTimeout(res, QuickOpenModal.UPDATE_RESULTS_THROTTLE) + ); +} + +describe("QuickOpenModal", () => { + beforeEach(() => { + filter.mockClear(); + }); + test("Doesn't render when disabled", () => { + const { wrapper } = generateModal(); + expect(wrapper).toMatchSnapshot(); + }); + + test("Renders when enabled", () => { + const { wrapper } = generateModal({ enabled: true }); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount", () => { + const { wrapper } = generateModal({ enabled: true }, "mount"); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount & searchType = functions", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "@", + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + test("toggles shortcut modal if enabled", () => { + const { props } = generateModal( + { + enabled: true, + query: "test", + shortcutsModalEnabled: true, + toggleShortcutsModal: jest.fn(), + }, + "shallow" + ); + expect(props.toggleShortcutsModal).toHaveBeenCalled(); + }); + + test("shows top sources", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + displayedSources: [ + { + url: "mozilla.com", + displayURL: getDisplayURL("mozilla.com"), + }, + ], + tabUrls: ["mozilla.com"], + }, + "shallow" + ); + expect(wrapper.state("results")).toEqual([ + { + id: undefined, + icon: "tab result-item-icon", + subtitle: "mozilla.com", + title: "mozilla.com", + url: "mozilla.com", + value: "mozilla.com", + source: { + url: "mozilla.com", + displayURL: getDisplayURL("mozilla.com"), + }, + }, + ]); + }); + + describe("shows loading", () => { + it("loads with function type search", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + searchType: "functions", + symbolsLoading: true, + }, + "shallow" + ); + expect(wrapper).toMatchSnapshot(); + }); + }); + + test("Ensure anonymous functions do not render in QuickOpenModal", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "@", + searchType: "functions", + symbols: { + functions: [ + generateQuickOpenResult("anonymous"), + generateQuickOpenResult("c"), + generateQuickOpenResult("anonymous"), + ], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.find("ResultList")).toHaveLength(1); + expect(wrapper.find("li")).toHaveLength(1); + }); + + test("Basic render with mount & searchType = variables", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "#", + searchType: "variables", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount & searchType = shortcuts", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "?", + searchType: "shortcuts", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.find("ResultList")).toHaveLength(1); + expect(wrapper.find("li")).toHaveLength(3); + }); + + test("updateResults on enable", () => { + const { wrapper } = generateModal({}, "mount"); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ enabled: true }); + expect(wrapper).toMatchSnapshot(); + }); + + test("basic source search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + wrapper.find("input").simulate("change", { target: { value: "somefil" } }); + await waitForUpdateResultsThrottle(); + expect(filter).toHaveBeenCalledWith([], "somefil", { + key: "value", + maxResults: 100, + }); + }); + + test("basic gotoSource search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "gotoSource", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + wrapper + .find("input") + .simulate("change", { target: { value: "somefil:33" } }); + + await waitForUpdateResultsThrottle(); + + expect(filter).toHaveBeenCalledWith([], "somefil", { + key: "value", + maxResults: 100, + }); + }); + + describe("empty symbol search", () => { + it("basic symbol search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + // symbol searching relies on a source being selected. + // So we dummy out the source and the API. + selectedSource: { id: "foo", text: "yo" }, + selectedContentLoaded: true, + }, + "mount" + ); + + wrapper + .find("input") + .simulate("change", { target: { value: "@someFunc" } }); + await waitForUpdateResultsThrottle(); + expect(filter).toHaveBeenCalledWith([], "someFunc", { + key: "value", + maxResults: 100, + }); + }); + + it("does not do symbol search if no selected source", () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + // symbol searching relies on a source being selected. + // So we dummy out the source and the API. + selectedSource: null, + selectedContentLoaded: false, + }, + "mount" + ); + wrapper + .find("input") + .simulate("change", { target: { value: "@someFunc" } }); + expect(filter).not.toHaveBeenCalled(); + }); + }); + + test("Simple goto search query = :abc & searchType = goto", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":abc", + searchType: "goto", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.childAt(0)).toMatchSnapshot(); + expect(wrapper.childAt(0).state().results).toEqual(null); + }); + + describe("onEnter", () => { + it("on Enter go to location", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + selectedSource: { id: "foo" }, + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 12, + line: 34, + sourceId: "foo", + source: { + id: "foo", + }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + }); + + it("on Enter go to location with sourceId", () => { + const sourceId = "source_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 12, + line: 34, + sourceId, + source: { + id: sourceId, + }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + }); + + it("on Enter with no location, does no action", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":", + searchType: "goto", + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on Enter with empty results, handle no item", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [], + selectedIndex: 0, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle symbol shortcut", () => { + const symbols = [":", "#", "@"]; + for (const symbol of symbols) { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{ id: symbol }], + selectedIndex: 0, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(symbol); + } + }); + + it("on Enter, returns the result with the selected index", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{ id: "@" }, { id: ":" }, { id: "#" }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(":"); + }); + + it("on Enter with results, handle result item", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "other", + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: undefined, + sourceId: id, + line: 0, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle functions result item", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "functions", + symbols: { + functions: [], + variables: {}, + }, + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: undefined, + line: 0, + sourceId: id, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle gotoSource search", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":3:4", + searchType: "gotoSource", + symbols: { + functions: [], + variables: {}, + }, + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 4, + line: 3, + sourceId: id, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle shortcuts search", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@", + searchType: "shortcuts", + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const id = "#"; + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(id); + }); + }); + + describe("onKeyDown", () => { + it("does nothing if search type is not goto", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + }, + "shallow" + ); + wrapper.find("Connect(SearchInput)").simulate("keydown", {}); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Tab, close modal", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + }, + "shallow" + ); + const event = { + key: "Tab", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.closeQuickOpen).toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + }); + }); + + describe("with arrow keys", () => { + it("on ArrowUp, traverse results up with functions", () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "functions", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + const location = { + sourceId: "sourceId", + start: { + line: 1, + }, + end: { + line: 3, + }, + }; + + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.highlightLineRange).toHaveBeenCalledWith({ + sourceId: "sourceId", + end: 3, + start: 1, + }); + }); + + it("on ArrowDown, traverse down with no results", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "goto", + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowDown", + }; + wrapper.setState(() => ({ + results: null, + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.selectSpecificLocation).not.toHaveBeenCalledWith(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on ArrowUp, traverse results up to function with no location", () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "functions", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + wrapper.setState(() => ({ + results: [{ id: "0", location: null }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + expect(props.clearHighlightLineRange).toHaveBeenCalled(); + }); + + it( + "on ArrowDown, traverse down results, without " + + "taking action if no selectedSource", + () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "variables", + selectedSource: null, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowDown", + }; + const location = { + sourceId: "sourceId", + start: { + line: 7, + }, + }; + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(2); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + } + ); + + it( + "on ArrowUp, traverse up results, without taking action if " + + "the query is not for variables or functions", + () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + const location = { + sourceId: "sourceId", + start: { + line: 7, + }, + }; + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + } + ); + }); + + describe("showErrorEmoji", () => { + it("true when no count + query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when count + query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "dasdasdas", + }, + "mount" + ); + wrapper.setState(() => ({ + results: [1, 2], + })); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when no query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + searchType: "other", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when goto numeric ':2222'", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":2222", + searchType: "goto", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("true when goto not numeric ':22k22'", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":22k22", + searchType: "goto", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js new file mode 100644 index 0000000000..d3264c02e0 --- /dev/null +++ b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import { ShortcutsModal } from "../ShortcutsModal"; + +function render(overrides = {}) { + const props = { + enabled: true, + handleClose: jest.fn(), + ...overrides, + }; + const component = shallow(<ShortcutsModal {...props} />); + + return { component, props }; +} + +describe("ShortcutsModal", () => { + it("renders when enabled", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("renders nothing when not enabled", () => { + const { component } = render({ + enabled: false, + }); + expect(component.text()).toBe(""); + }); +}); diff --git a/devtools/client/debugger/src/components/test/WelcomeBox.spec.js b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js new file mode 100644 index 0000000000..0a1dbc7459 --- /dev/null +++ b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import { WelcomeBox } from "../WelcomeBox"; + +function render(overrides = {}) { + const props = { + horizontal: false, + togglePaneCollapse: jest.fn(), + endPanelCollapsed: false, + setActiveSearch: jest.fn(), + openQuickOpen: jest.fn(), + toggleShortcutsModal: jest.fn(), + setPrimaryPaneTab: jest.fn(), + ...overrides, + }; + const component = shallow(<WelcomeBox {...props} />); + + return { component, props }; +} + +describe("WelomeBox", () => { + it("renders with default values", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("doesn't render toggle button in horizontal mode", () => { + const { component } = render({ + horizontal: true, + }); + expect(component.find("PaneToggleButton")).toHaveLength(0); + }); + + it("calls correct function on searchSources click", () => { + const { component, props } = render(); + + component.find(".welcomebox__searchSources").simulate("click"); + expect(props.openQuickOpen).toHaveBeenCalled(); + }); + + it("calls correct function on searchProject click", () => { + const { component, props } = render(); + + component.find(".welcomebox__searchProject").simulate("click"); + expect(props.setActiveSearch).toHaveBeenCalled(); + }); + + it("calls correct function on allShotcuts click", () => { + const { component, props } = render(); + + component.find(".welcomebox__allShortcuts").simulate("click"); + expect(props.toggleShortcutsModal).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/test/WhyPaused.spec.js b/devtools/client/debugger/src/components/test/WhyPaused.spec.js new file mode 100644 index 0000000000..eff87c7cd1 --- /dev/null +++ b/devtools/client/debugger/src/components/test/WhyPaused.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import WhyPaused from "../SecondaryPanes/WhyPaused.js"; + +function render(why, delay) { + const props = { why, delay }; + const component = shallow(<WhyPaused.WrappedComponent {...props} />); + + return { component, props }; +} + +describe("WhyPaused", () => { + it("should pause reason with message", () => { + const why = { + type: "breakpoint", + message: "bla is hit", + }; + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show pause reason with exception details", () => { + const why = { + type: "exception", + exception: { + class: "ReferenceError", + isError: true, + preview: { + name: "ReferenceError", + message: "o is not defined", + }, + }, + }; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show pause reason with exception string", () => { + const why = { + type: "exception", + exception: "Not Available", + }; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show an empty div when there is no pause reason", () => { + const why = undefined; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap new file mode 100644 index 0000000000..80fdfa1dec --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`A11yIntention renders its children 1`] = ` +<div + className="A11y-mouse" + onKeyDown={[Function]} + onMouseDown={[Function]} +> + <span> + hello world + </span> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap new file mode 100644 index 0000000000..4e2e2c98fd --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap @@ -0,0 +1,505 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Outline renders a list of functions when properties change 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="my_example_function1:21:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function1", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="my_example_function2:22:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function2", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders functions by function class 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__class" + key="x_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + x_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="x_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + <li + className="outline-list__class" + key="a_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + a_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="a2_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a2_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="a1_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a1_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders functions by function class, alphabetically 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__class" + key="a_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + a_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="a1_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a1_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="a2_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a2_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + <li + className="outline-list__class" + key="x_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + x_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="x_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="active" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders ignore anonymous functions 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="my_example_function1:21:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function1", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders loading if symbols is not defined 1`] = ` +<div + className="outline-pane-info" +> + Loading… +</div> +`; + +exports[`Outline renders outline renders placeholder \`No File Selected\` if selectedSource is not defined 1`] = ` +<div + className="outline-pane-info" +> + No file selected +</div> +`; + +exports[`Outline renders outline renders placeholder \`No functions\` if all func are anonymous 1`] = ` +<div + className="outline-pane-info" +> + No functions +</div> +`; + +exports[`Outline renders outline renders placeholder \`No functions\` if symbols has no func 1`] = ` +<div + className="outline-pane-info" +> + No functions +</div> +`; + +exports[`Outline renders outline sorts functions alphabetically by function name 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="a_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="c_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "c_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="x_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="active" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap new file mode 100644 index 0000000000..c4e03b77cd --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OutlineFilter shows an input with no value when filter is empty 1`] = ` +<div + className="outline-filter" +> + <form> + <input + className="outline-filter-input devtools-filterinput" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter functions" + type="text" + value="" + /> + </form> +</div> +`; + +exports[`OutlineFilter shows an input with the filter when it is not empty 1`] = ` +<div + className="outline-filter" +> + <form> + <input + className="outline-filter-input devtools-filterinput" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter functions" + type="text" + value="abc" + /> + </form> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap new file mode 100644 index 0000000000..83d643a597 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap @@ -0,0 +1,1694 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QuickOpenModal Basic render with mount & searchType = functions 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="@" + searchType="functions" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="@" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="@" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="@" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + > + <ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Basic render with mount & searchType = variables 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="#" + searchType="variables" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="#" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="#" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="#" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Basic render with mount 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field big" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="big" + > + <ul + aria-live="polite" + className="result-list big" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Doesn't render when disabled 1`] = `""`; + +exports[`QuickOpenModal Renders when enabled 1`] = ` +<Slide + handleClose={[Function]} + in={true} +> + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + /> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="big" + /> +</Slide> +`; + +exports[`QuickOpenModal Simple goto search query = :abc & searchType = goto 1`] = ` +<QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":abc" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} +> + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":abc" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":abc" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value=":abc" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> +</QuickOpenModal> +`; + +exports[`QuickOpenModal showErrorEmoji false when count + query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="dasdasdas" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="dasdasdas" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="dasdasdas" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field big" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="dasdasdas" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji false when goto numeric ':2222' 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":2222" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":2222" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":2222" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value=":2222" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji false when no query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="other" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + > + <ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji true when goto not numeric ':22k22' 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":22k22" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":22k22" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":22k22" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value=":22k22" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji true when no count + query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="test" + searchType="other" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="test" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="test" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="test" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal shows loading loads with function type search 1`] = ` +<Slide + handleClose={[Function]} + in={true} +> + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Loading…" + /> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + /> +</Slide> +`; + +exports[`QuickOpenModal updateResults on enable 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={false} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + /> +</Provider> +`; + +exports[`QuickOpenModal updateResults on enable 2`] = ` +<Provider + enabled={true} + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={false} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + /> +</Provider> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap new file mode 100644 index 0000000000..06ddc45c91 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShortcutsModal renders when enabled 1`] = ` +<Slide + additionalClass="shortcuts-modal" + handleClose={[MockFunction]} + in={true} +> + <div + className="shortcuts-content" + > + <div + className="shortcuts-section" + > + <h2> + Editor + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Toggle Breakpoint + </span> + <span> + <span + className="keystroke" + key="Ctrl+B" + > + Ctrl+B + </span> + </span> + </li> + <li> + <span> + Edit Conditional Breakpoint + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+B" + > + Ctrl+Shift+B + </span> + </span> + </li> + <li> + <span> + Edit Log Point + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+Y" + > + Ctrl+Shift+Y + </span> + </span> + </li> + </ul> + </div> + <div + className="shortcuts-section" + > + <h2> + Stepping + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Pause/Resume + </span> + <span> + <span + className="keystroke" + key="F8" + > + F8 + </span> + </span> + </li> + <li> + <span> + Step Over + </span> + <span> + <span + className="keystroke" + key="F10" + > + F10 + </span> + </span> + </li> + <li> + <span> + Step In + </span> + <span> + <span + className="keystroke" + key="F11" + > + F11 + </span> + </span> + </li> + <li> + <span> + Step Out + </span> + <span> + <span + className="keystroke" + key="Shift+F11" + > + Shift+F11 + </span> + </span> + </li> + </ul> + </div> + <div + className="shortcuts-section" + > + <h2> + Search + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Go to file + </span> + <span> + <span + className="keystroke" + key="Ctrl+P" + > + Ctrl+P + </span> + </span> + </li> + <li> + <span> + Find in files + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+F" + > + Ctrl+Shift+F + </span> + </span> + </li> + <li> + <span> + Find function + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+O" + > + Ctrl+Shift+O + </span> + </span> + </li> + <li> + <span> + Go to line + </span> + <span> + <span + className="keystroke" + key="Ctrl+G" + > + Ctrl+G + </span> + </span> + </li> + </ul> + </div> + </div> +</Slide> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap new file mode 100644 index 0000000000..9828e88ef4 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WelomeBox renders with default values 1`] = ` +<div + className="welcomebox" +> + <div + className="alignlabel" + > + <div + className="shortcutFunction" + > + <p + className="welcomebox__searchSources" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+P + </span> + <span + className="shortcutLabel" + > + Go to file + </span> + </p> + <p + className="welcomebox__searchProject" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+Shift+F + </span> + <span + className="shortcutLabel" + > + Find in files + </span> + </p> + <p + className="welcomebox__allShortcuts" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+/ + </span> + <span + className="shortcutLabel" + > + Show all shortcuts + </span> + </p> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap new file mode 100644 index 0000000000..0762a0b69d --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WhyPaused should pause reason with message 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-breakpoint" + /> + <div + className="message" + > + bla is hit + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; + +exports[`WhyPaused should show an empty div when there is no pause reason 1`] = ` +<div + className="" +/> +`; + +exports[`WhyPaused should show pause reason with exception details 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-exception" + /> + <div + className="message warning" + > + ReferenceError: o is not defined + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; + +exports[`WhyPaused should show pause reason with exception string 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-exception" + /> + <div + className="message warning" + > + Not Available + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; diff --git a/devtools/client/debugger/src/components/variables.css b/devtools/client/debugger/src/components/variables.css new file mode 100644 index 0000000000..628c590714 --- /dev/null +++ b/devtools/client/debugger/src/components/variables.css @@ -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/>. */ + +:root { + /* header height is 28px + 1px for its border */ + --editor-header-height: 29px; + /* footer height is 24px + 1px for its border */ + --editor-footer-height: 25px; + /* searchbar height is 24px + 1px for its top border */ + --editor-searchbar-height: 25px; + /* Remove once https://bugzilla.mozilla.org/show_bug.cgi?id=1520440 lands */ + --theme-code-line-height: calc(15 / 11); + /* Background and text colors and opacity for skipped breakpoint panes */ + --skip-pausing-background-color: var(--theme-toolbar-hover); + --skip-pausing-opacity: 0.6; + --skip-pausing-color: var(--theme-body-color); +} + +:root.theme-light, +:root .theme-light { + --search-overlays-semitransparent: rgba(221, 225, 228, 0.66); + --popup-shadow-color: #d0d0d0; + --theme-inline-preview-background: rgba(192, 105, 255, 0.05); + --theme-inline-preview-border-color: #ebd1ff; + --theme-inline-preview-label-color: #6300a6; + --theme-inline-preview-label-background: rgb(244, 230, 255); +} + +:root.theme-dark, +:root .theme-dark { + --search-overlays-semitransparent: rgba(42, 46, 56, 0.66); + --popup-shadow-color: #5c667b; + --theme-inline-preview-background: rgba(192, 105, 255, 0.05); + --theme-inline-preview-border-color: #47326c; + --theme-inline-preview-label-color: #dfccff; + --theme-inline-preview-label-background: #3f2e5f; +} + +/* Animations */ + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/devtools/client/debugger/src/constants.js b/devtools/client/debugger/src/constants.js new file mode 100644 index 0000000000..67b6ca5362 --- /dev/null +++ b/devtools/client/debugger/src/constants.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 const searchKeys = { + PROJECT_SEARCH: "project-search", + FILE_SEARCH: "file-search", + QUICKOPEN_SEARCH: "quickopen-search", +}; + +export const primaryPaneTabs = { + SOURCES: "sources", + OUTLINE: "outline", + PROJECT_SEARCH: "project", +}; diff --git a/devtools/client/debugger/src/context-menu/menu.js b/devtools/client/debugger/src/context-menu/menu.js new file mode 100644 index 0000000000..ff56ce34e3 --- /dev/null +++ b/devtools/client/debugger/src/context-menu/menu.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const Menu = require("devtools/client/framework/menu"); +const MenuItem = require("devtools/client/framework/menu-item"); + +export function showMenu(evt, items) { + if (items.length === 0) { + return; + } + + const menu = new Menu(); + items + .filter(item => item.visible === undefined || item.visible === true) + .forEach(item => { + const menuItem = new MenuItem(item); + menuItem.submenu = createSubMenu(item.submenu); + menu.append(menuItem); + }); + + menu.popup(evt.screenX, evt.screenY, window.parent.document); +} + +function createSubMenu(subItems) { + if (subItems) { + const subMenu = new Menu(); + subItems.forEach(subItem => { + subMenu.append(new MenuItem(subItem)); + }); + return subMenu; + } + return null; +} + +export function buildMenu(items) { + return items + .map(itm => { + const hide = typeof itm.hidden === "function" ? itm.hidden() : itm.hidden; + return hide ? null : itm.item; + }) + .filter(itm => itm !== null); +} diff --git a/devtools/client/debugger/src/context-menu/moz.build b/devtools/client/debugger/src/context-menu/moz.build new file mode 100644 index 0000000000..48089353f1 --- /dev/null +++ b/devtools/client/debugger/src/context-menu/moz.build @@ -0,0 +1,8 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +CompiledModules( + "menu.js", +) diff --git a/devtools/client/debugger/src/debugger.css b/devtools/client/debugger/src/debugger.css new file mode 100644 index 0000000000..e6540e2bef --- /dev/null +++ b/devtools/client/debugger/src/debugger.css @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Shared imports */ +@import url("chrome://devtools/content/shared/components/AppErrorBoundary.css"); +@import url("chrome://devtools/content/shared/components/SearchModifiers.css"); + +/* Devtools imports */ +@import url("chrome://devtools/content/debugger/src/components/variables.css"); +@import url("chrome://devtools/content/debugger/src/components/A11yIntention.css"); +@import url("chrome://devtools/content/debugger/src/components/App.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/AccessibleImage.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Accordion.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Badge.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/BracketArrow.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Button/styles/CloseButton.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Button/styles/CommandBarButton.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Button/styles/PaneToggleButton.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Dropdown.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/menu.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Modal.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/Popover.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/PreviewFunction.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/ResultList.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/SearchInput.css"); +@import url("chrome://devtools/content/debugger/src/components/shared/SourceIcon.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Breakpoints.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/ConditionalPanel.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Editor.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Footer.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/InlinePreview.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Preview.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Preview/Popup.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/HighlightCalls.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/SearchInFileBar.css"); +@import url("chrome://devtools/content/debugger/src/components/Editor/Tabs.css"); +@import url("chrome://devtools/content/debugger/src/components/PrimaryPanes/Outline.css"); +@import url("chrome://devtools/content/debugger/src/components/PrimaryPanes/OutlineFilter.css"); +@import url("chrome://devtools/content/debugger/src/components/PrimaryPanes/ProjectSearch.css"); +@import url("chrome://devtools/content/debugger/src/components/PrimaryPanes/Sources.css"); +@import url("chrome://devtools/content/debugger/src/components/QuickOpenModal.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/CommandBar.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/EventListeners.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Expressions.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Frames/Frames.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Frames/Group.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Scopes.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/SecondaryPanes.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/WhyPaused.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/Threads.css"); +@import url("chrome://devtools/content/debugger/src/components/SecondaryPanes/XHRBreakpoints.css"); +@import url("chrome://devtools/content/debugger/src/components/ShortcutsModal.css"); +@import url("chrome://devtools/content/debugger/src/components/WelcomeBox.css"); +@import url("chrome://devtools/content/debugger/src/utils/editor/source-editor.css"); + +@import url("chrome://devtools/content/shared/components/splitter/SplitBox.css"); diff --git a/devtools/client/debugger/src/main.js b/devtools/client/debugger/src/main.js new file mode 100644 index 0000000000..ee6313a689 --- /dev/null +++ b/devtools/client/debugger/src/main.js @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 * as firefox from "./client/firefox"; + +import { asyncStore, verifyPrefSchema, prefs } from "./utils/prefs"; +import { setupHelper } from "./utils/dbg"; +import { setToolboxTelemetry } from "./utils/telemetry"; + +import { + bootstrapApp, + bootstrapStore, + bootstrapWorkers, + unmountRoot, + teardownWorkers, +} from "./utils/bootstrap"; + +import { initialBreakpointsState } from "./reducers/breakpoints"; +import { initialSourcesState } from "./reducers/sources"; +import { initialUIState } from "./reducers/ui"; +import { initialSourceBlackBoxState } from "./reducers/source-blackbox"; + +const { sanitizeBreakpoints } = require("devtools/client/shared/thread-utils"); + +async function syncBreakpoints() { + const breakpoints = await asyncStore.pendingBreakpoints; + const breakpointValues = Object.values(sanitizeBreakpoints(breakpoints)); + return Promise.all( + breakpointValues.map(({ disabled, options, generatedLocation }) => { + if (disabled) { + return Promise.resolve(); + } + // Set the breakpoint on the server using the generated location as generated + // sources are known on server, not original sources. + return firefox.clientCommands.setBreakpoint(generatedLocation, options); + }) + ); +} + +async function syncXHRBreakpoints() { + const breakpoints = await asyncStore.xhrBreakpoints; + return Promise.all( + breakpoints.map(({ path, method, disabled }) => { + if (!disabled) { + firefox.clientCommands.setXHRBreakpoint(path, method); + } + }) + ); +} + +function setPauseOnExceptions() { + const { pauseOnExceptions, pauseOnCaughtException } = prefs; + return firefox.clientCommands.pauseOnExceptions( + pauseOnExceptions, + pauseOnCaughtException + ); +} + +async function loadInitialState(commands, toolbox) { + const pendingBreakpoints = sanitizeBreakpoints( + await asyncStore.pendingBreakpoints + ); + const tabs = { tabs: await asyncStore.tabs }; + const xhrBreakpoints = await asyncStore.xhrBreakpoints; + const blackboxedRanges = await asyncStore.blackboxedRanges; + const eventListenerBreakpoints = await asyncStore.eventListenerBreakpoints; + const breakpoints = initialBreakpointsState(xhrBreakpoints); + const sourceBlackBox = initialSourceBlackBoxState({ blackboxedRanges }); + const sources = initialSourcesState(); + const ui = initialUIState(); + + return { + pendingBreakpoints, + tabs, + breakpoints, + eventListenerBreakpoints, + sources, + sourceBlackBox, + ui, + }; +} + +export async function bootstrap({ + commands, + fluentBundles, + resourceCommand, + workers: panelWorkers, + panel, +}) { + verifyPrefSchema(); + + const initialState = await loadInitialState(commands, panel.toolbox); + const workers = bootstrapWorkers(panelWorkers); + + const { store, actions, selectors } = bootstrapStore( + firefox.clientCommands, + workers, + panel, + initialState + ); + + const connected = firefox.onConnect( + commands, + resourceCommand, + actions, + store + ); + + await syncBreakpoints(); + await syncXHRBreakpoints(); + await setPauseOnExceptions(); + + setupHelper({ + store, + actions, + selectors, + workers, + targetCommand: commands.targetCommand, + client: firefox.clientCommands, + }); + + setToolboxTelemetry(panel.toolbox.telemetry); + + bootstrapApp(store, panel.getToolboxStore(), { + fluentBundles, + toolboxDoc: panel.panelWin.parent.document, + }); + await connected; + return { store, actions, selectors, client: firefox.clientCommands }; +} + +export async function destroy() { + firefox.onDisconnect(); + unmountRoot(); + teardownWorkers(); +} diff --git a/devtools/client/debugger/src/moz.build b/devtools/client/debugger/src/moz.build new file mode 100644 index 0000000000..7dad9e4f59 --- /dev/null +++ b/devtools/client/debugger/src/moz.build @@ -0,0 +1,21 @@ +# 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 += [ + "actions", + "client", + "components", + "context-menu", + "reducers", + "selectors", + "utils", + "workers", +] + +CompiledModules( + "constants.js", + "main.js", + "vendors.js", +) diff --git a/devtools/client/debugger/src/reducers/ast.js b/devtools/client/debugger/src/reducers/ast.js new file mode 100644 index 0000000000..9796815727 --- /dev/null +++ b/devtools/client/debugger/src/reducers/ast.js @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Ast reducer + * @module reducers/ast + */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +export function initialASTState() { + return { + // We are using mutable objects as we never return the dictionary as-is from the selectors + // but only their values. + // Note that all these dictionaries are storing objects as values + // which all will have a threadActorId attribute. + + // We have two maps, a first one for original sources. + // This is keyed by source id. + mutableOriginalSourcesSymbols: {}, + + // And another one, for generated sources. + // This is keyed by source actor id. + mutableSourceActorSymbols: {}, + + mutableInScopeLines: {}, + }; +} + +function update(state = initialASTState(), action) { + switch (action.type) { + case "SET_SYMBOLS": { + const { location } = action; + if (action.status === "start") { + return state; + } + + const entry = { + value: action.value, + threadActorId: location.sourceActor?.thread, + }; + if (location.source.isOriginal) { + state.mutableOriginalSourcesSymbols[location.source.id] = entry; + } else { + if (!location.sourceActor) { + throw new Error( + "Expects a location with a source actor when adding symbols for non-original sources" + ); + } + state.mutableSourceActorSymbols[location.sourceActor.id] = entry; + } + return { + ...state, + }; + } + + case "IN_SCOPE_LINES": { + state.mutableInScopeLines[makeBreakpointId(action.location)] = { + lines: action.lines, + threadActorId: action.location.sourceActor?.thread, + }; + return { + ...state, + }; + } + + case "RESUME": { + return { ...state, mutableInScopeLines: {} }; + } + + case "REMOVE_THREAD": { + function clearDict(dict, threadId) { + for (const key in dict) { + if (dict[key].threadActorId == threadId) { + delete dict[key]; + } + } + } + clearDict(state.mutableSourceActorSymbols, action.threadActorID); + clearDict(state.mutableOriginalSourcesSymbols, action.threadActorID); + clearDict(state.mutableInScopeLines, action.threadActorID); + return { ...state }; + } + + default: { + return state; + } + } +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/breakpoints.js b/devtools/client/debugger/src/reducers/breakpoints.js new file mode 100644 index 0000000000..127e71abae --- /dev/null +++ b/devtools/client/debugger/src/reducers/breakpoints.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Breakpoints reducer + * @module reducers/breakpoints + */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +export function initialBreakpointsState(xhrBreakpoints = []) { + return { + breakpoints: {}, + xhrBreakpoints, + }; +} + +function update(state = initialBreakpointsState(), action) { + switch (action.type) { + case "SET_BREAKPOINT": { + if (action.status === "start") { + return setBreakpoint(state, action); + } + return state; + } + + case "REMOVE_BREAKPOINT": { + if (action.status === "start") { + return removeBreakpoint(state, action); + } + return state; + } + + case "CLEAR_BREAKPOINTS": { + return { ...state, breakpoints: {} }; + } + + case "REMOVE_THREAD": { + return removeBreakpointsForSources(state, action.sources); + } + + case "SET_XHR_BREAKPOINT": { + return addXHRBreakpoint(state, action); + } + + case "REMOVE_XHR_BREAKPOINT": { + return removeXHRBreakpoint(state, action); + } + + case "UPDATE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + + case "ENABLE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + + case "DISABLE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + case "CLEAR_XHR_BREAKPOINTS": { + if (action.status == "start") { + return state; + } + return { ...state, xhrBreakpoints: [] }; + } + } + + return state; +} + +function addXHRBreakpoint(state, action) { + const { xhrBreakpoints } = state; + const { breakpoint } = action; + const { path, method } = breakpoint; + + const existingBreakpointIndex = state.xhrBreakpoints.findIndex( + bp => bp.path === path && bp.method === method + ); + + if (existingBreakpointIndex === -1) { + return { + ...state, + xhrBreakpoints: [...xhrBreakpoints, breakpoint], + }; + } else if (xhrBreakpoints[existingBreakpointIndex] !== breakpoint) { + const newXhrBreakpoints = [...xhrBreakpoints]; + newXhrBreakpoints[existingBreakpointIndex] = breakpoint; + return { + ...state, + xhrBreakpoints: newXhrBreakpoints, + }; + } + + return state; +} + +function removeXHRBreakpoint(state, action) { + const { breakpoint } = action; + const { xhrBreakpoints } = state; + + if (action.status === "start") { + return state; + } + + return { + ...state, + xhrBreakpoints: xhrBreakpoints.filter( + bp => bp.path !== breakpoint.path || bp.method !== breakpoint.method + ), + }; +} + +function updateXHRBreakpoint(state, action) { + const { breakpoint, index } = action; + const { xhrBreakpoints } = state; + const newXhrBreakpoints = [...xhrBreakpoints]; + newXhrBreakpoints[index] = breakpoint; + return { + ...state, + xhrBreakpoints: newXhrBreakpoints, + }; +} + +function setBreakpoint(state, { breakpoint }) { + const id = makeBreakpointId(breakpoint.location); + const breakpoints = { ...state.breakpoints, [id]: breakpoint }; + return { ...state, breakpoints }; +} + +function removeBreakpoint(state, { breakpoint }) { + const id = makeBreakpointId(breakpoint.location); + const breakpoints = { ...state.breakpoints }; + delete breakpoints[id]; + return { ...state, breakpoints }; +} + +function removeBreakpointsForSources(state, sources) { + const remainingBreakpoints = {}; + for (const [id, breakpoint] of Object.entries(state.breakpoints)) { + if (!sources.includes(breakpoint.location.source)) { + remainingBreakpoints[id] = breakpoint; + } + } + return { ...state, breakpoints: remainingBreakpoints }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/event-listeners.js b/devtools/client/debugger/src/reducers/event-listeners.js new file mode 100644 index 0000000000..d0418b9ece --- /dev/null +++ b/devtools/client/debugger/src/reducers/event-listeners.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs } from "../utils/prefs"; + +export function initialEventListenerState() { + return { + active: [], + categories: [], + expanded: [], + logEventBreakpoints: prefs.logEventBreakpoints, + }; +} + +function update(state = initialEventListenerState(), action) { + switch (action.type) { + case "UPDATE_EVENT_LISTENERS": + return { ...state, active: action.active }; + + case "RECEIVE_EVENT_LISTENER_TYPES": + return { ...state, categories: action.categories }; + + case "UPDATE_EVENT_LISTENER_EXPANDED": + return { ...state, expanded: action.expanded }; + + case "TOGGLE_EVENT_LISTENERS": { + const { logEventBreakpoints } = action; + prefs.logEventBreakpoints = logEventBreakpoints; + return { ...state, logEventBreakpoints }; + } + + default: + return state; + } +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/exceptions.js b/devtools/client/debugger/src/reducers/exceptions.js new file mode 100644 index 0000000000..a4a26de5df --- /dev/null +++ b/devtools/client/debugger/src/reducers/exceptions.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Exceptions reducer + * @module reducers/exceptionss + */ + +export function initialExceptionsState() { + return { + // Store exception objects created by actions/exceptions.js's addExceptionFromResources() + // This is keyed by source actor id, and values are arrays of exception objects. + mutableExceptionsMap: new Map(), + }; +} + +function update(state = initialExceptionsState(), action) { + switch (action.type) { + case "ADD_EXCEPTION": + return updateExceptions(state, action); + case "REMOVE_THREAD": { + return removeExceptionsFromThread(state, action); + } + } + return state; +} + +function updateExceptions(state, action) { + const { mutableExceptionsMap } = state; + const { exception } = action; + const { sourceActorId } = exception; + + let exceptions = mutableExceptionsMap.get(sourceActorId); + if (!exceptions) { + exceptions = []; + mutableExceptionsMap.set(sourceActorId, exceptions); + } else if ( + exceptions.some(({ lineNumber, columnNumber }) => { + return ( + lineNumber == exception.lineNumber && + columnNumber == exception.columnNumber + ); + }) + ) { + // Avoid adding duplicated exceptions for the same line/column + return state; + } + + // As these arrays are only used by getSelectedSourceExceptions selector method, + // which coalesce multiple arrays and always return new array instance, + // it isn't important to clone these array in case of modification. + exceptions.push(exception); + + return { + ...state, + }; +} + +function removeExceptionsFromThread(state, action) { + const { mutableExceptionsMap } = state; + const { threadActorID } = action; + const sizeBefore = mutableExceptionsMap.size; + for (const [sourceActorId, exceptions] of mutableExceptionsMap) { + // All exceptions relates to the same source actor, and so, the same thread actor. + if (exceptions[0].threadActorId == threadActorID) { + mutableExceptionsMap.delete(sourceActorId); + } + } + if (sizeBefore != mutableExceptionsMap.size) { + return { + ...state, + }; + } + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/expressions.js b/devtools/client/debugger/src/reducers/expressions.js new file mode 100644 index 0000000000..d8589eca84 --- /dev/null +++ b/devtools/client/debugger/src/reducers/expressions.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Expressions reducer + * @module reducers/expressions + */ + +import { prefs } from "../utils/prefs"; + +export const initialExpressionState = () => ({ + expressions: restoreExpressions(), + expressionError: false, + autocompleteMatches: {}, + currentAutocompleteInput: null, +}); + +function update(state = initialExpressionState(), action) { + switch (action.type) { + case "ADD_EXPRESSION": + if (action.expressionError) { + return { ...state, expressionError: !!action.expressionError }; + } + return appendExpressionToList(state, { + input: action.input, + value: null, + updating: true, + }); + + case "UPDATE_EXPRESSION": + const key = action.expression.input; + const newState = updateExpressionInList(state, key, { + input: action.input, + value: null, + updating: true, + }); + + return { ...newState, expressionError: !!action.expressionError }; + + case "EVALUATE_EXPRESSION": + return updateExpressionInList(state, action.input, { + input: action.input, + value: action.value, + updating: false, + }); + + case "EVALUATE_EXPRESSIONS": + const { inputs, results } = action; + + return inputs.reduce( + (_state, input, index) => + updateExpressionInList(_state, input, { + input, + value: results[index], + updating: false, + }), + state + ); + + case "DELETE_EXPRESSION": + return deleteExpression(state, action.input); + + case "CLEAR_EXPRESSION_ERROR": + return { ...state, expressionError: false }; + + case "AUTOCOMPLETE": + const { matchProp, matches } = action.result; + + return { + ...state, + currentAutocompleteInput: matchProp, + autocompleteMatches: { + ...state.autocompleteMatches, + [matchProp]: matches, + }, + }; + + case "CLEAR_AUTOCOMPLETE": + return { + ...state, + autocompleteMatches: {}, + currentAutocompleteInput: "", + }; + } + + return state; +} + +function restoreExpressions() { + const exprs = prefs.expressions; + if (!exprs.length) { + return []; + } + + return exprs; +} + +function storeExpressions({ expressions }) { + // Return the expressions without the `value` property + prefs.expressions = expressions.map(({ input, updating }) => ({ + input, + updating, + })); +} + +function appendExpressionToList(state, value) { + const newState = { ...state, expressions: [...state.expressions, value] }; + + storeExpressions(newState); + return newState; +} + +function updateExpressionInList(state, key, value) { + const list = [...state.expressions]; + const index = list.findIndex(e => e.input == key); + list[index] = value; + + const newState = { ...state, expressions: list }; + storeExpressions(newState); + return newState; +} + +function deleteExpression(state, input) { + const list = [...state.expressions]; + const index = list.findIndex(e => e.input == input); + list.splice(index, 1); + const newState = { ...state, expressions: list }; + storeExpressions(newState); + return newState; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/index.js b/devtools/client/debugger/src/reducers/index.js new file mode 100644 index 0000000000..9bddc857b2 --- /dev/null +++ b/devtools/client/debugger/src/reducers/index.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Reducer index + * @module reducers/index + */ + +import expressions, { initialExpressionState } from "./expressions"; +import sourceActors, { initialSourceActorsState } from "./source-actors"; +import sources, { initialSourcesState } from "./sources"; +import sourceBlackBox, { initialSourceBlackBoxState } from "./source-blackbox"; +import sourcesContent, { initialSourcesContentState } from "./sources-content"; +import tabs, { initialTabState } from "./tabs"; +import breakpoints, { initialBreakpointsState } from "./breakpoints"; +import pendingBreakpoints from "./pending-breakpoints"; +import pause, { initialPauseState } from "./pause"; +import ui, { initialUIState } from "./ui"; +import ast, { initialASTState } from "./ast"; +import preview, { initialPreviewState } from "./preview"; +import projectTextSearch, { + initialProjectTextSearchState, +} from "./project-text-search"; +import quickOpen, { initialQuickOpenState } from "./quick-open"; +import sourcesTree, { initialSourcesTreeState } from "./sources-tree"; +import threads, { initialThreadsState } from "./threads"; +import eventListenerBreakpoints, { + initialEventListenerState, +} from "./event-listeners"; +import exceptions, { initialExceptionsState } from "./exceptions"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +/** + * Note that this is only used by jest tests. + * + * Production is using loadInitialState() in main.js + */ +export function initialState() { + return { + sources: initialSourcesState(), + sourcesContent: initialSourcesContentState(), + expressions: initialExpressionState(), + sourceActors: initialSourceActorsState(), + sourceBlackBox: initialSourceBlackBoxState(), + tabs: initialTabState(), + breakpoints: initialBreakpointsState(), + pendingBreakpoints: {}, + pause: initialPauseState(), + ui: initialUIState(), + ast: initialASTState(), + projectTextSearch: initialProjectTextSearchState(), + quickOpen: initialQuickOpenState(), + sourcesTree: initialSourcesTreeState(), + threads: initialThreadsState(), + objectInspector: objectInspector.reducer.initialOIState(), + eventListenerBreakpoints: initialEventListenerState(), + preview: initialPreviewState(), + exceptions: initialExceptionsState(), + }; +} + +export default { + expressions, + sourceActors, + sourceBlackBox, + sourcesContent, + sources, + tabs, + breakpoints, + pendingBreakpoints, + pause, + ui, + ast, + projectTextSearch, + quickOpen, + sourcesTree, + threads, + objectInspector: objectInspector.reducer.default, + eventListenerBreakpoints, + preview, + exceptions, +}; diff --git a/devtools/client/debugger/src/reducers/moz.build b/devtools/client/debugger/src/reducers/moz.build new file mode 100644 index 0000000000..ab5a23dcfb --- /dev/null +++ b/devtools/client/debugger/src/reducers/moz.build @@ -0,0 +1,28 @@ +# 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( + "ast.js", + "breakpoints.js", + "event-listeners.js", + "exceptions.js", + "expressions.js", + "index.js", + "pause.js", + "pending-breakpoints.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "source-blackbox.js", + "sources.js", + "sources-content.js", + "sources-tree.js", + "tabs.js", + "threads.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/reducers/pause.js b/devtools/client/debugger/src/reducers/pause.js new file mode 100644 index 0000000000..b6de8dc2f2 --- /dev/null +++ b/devtools/client/debugger/src/reducers/pause.js @@ -0,0 +1,409 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 complexity: ["error", 36]*/ + +/** + * Pause reducer + * @module reducers/pause + */ + +import { prefs } from "../utils/prefs"; + +// Pause state associated with an individual thread. + +// Pause state describing all threads. + +export function initialPauseState(thread = "UnknownThread") { + return { + cx: { + navigateCounter: 0, + }, + // This `threadcx` is the `cx` variable we pass around in components and actions. + // This is pulled via getThreadContext(). + // This stores information about the currently selected thread and its paused state. + threadcx: { + navigateCounter: 0, + thread, + pauseCounter: 0, + }, + highlightedCalls: null, + threads: {}, + skipPausing: prefs.skipPausing, + mapScopes: prefs.mapScopes, + shouldPauseOnExceptions: prefs.pauseOnExceptions, + shouldPauseOnCaughtExceptions: prefs.pauseOnCaughtExceptions, + }; +} + +const resumedPauseState = { + isPaused: false, + frames: null, + framesLoading: false, + frameScopes: { + generated: {}, + original: {}, + mappings: {}, + }, + selectedFrameId: null, + why: null, + inlinePreview: {}, + highlightedCalls: null, +}; + +const createInitialPauseState = () => ({ + ...resumedPauseState, + isWaitingOnBreak: false, + command: null, + previousLocation: null, + expandedScopes: new Set(), + lastExpandedScopes: [], +}); + +export function getThreadPauseState(state, thread) { + // Thread state is lazily initialized so that we don't have to keep track of + // the current set of worker threads. + return state.threads[thread] || createInitialPauseState(); +} + +function update(state = initialPauseState(), action) { + // Actions need to specify any thread they are operating on. These helpers + // manage updating the pause state for that thread. + const threadState = () => { + if (!action.thread) { + throw new Error(`Missing thread in action ${action.type}`); + } + return getThreadPauseState(state, action.thread); + }; + + const updateThreadState = newThreadState => { + if (!action.thread) { + throw new Error(`Missing thread in action ${action.type}`); + } + return { + ...state, + threads: { + ...state.threads, + [action.thread]: { ...threadState(), ...newThreadState }, + }, + }; + }; + + switch (action.type) { + case "SELECT_THREAD": { + return { + ...state, + threadcx: { + ...state.threadcx, + thread: action.thread, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + + case "INSERT_THREAD": { + // When navigating to a new location, + // we receive NAVIGATE early, which clear things + // then we have REMOVE_THREAD of the previous thread. + // INSERT_THREAD will be the very first event with the new thread actor ID. + // Automatically select the new top level thread. + if (action.newThread.isTopLevel) { + return { + ...state, + threadcx: { + ...state.threadcx, + thread: action.newThread.actor, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + break; + } + + case "REMOVE_THREAD": { + if ( + action.threadActorID in state.threads || + action.threadActorID == state.threadcx.thread + ) { + // Remove the thread from the cached list + const threads = { ...state.threads }; + delete threads[action.threadActorID]; + let threadcx = state.threadcx; + + // And also switch to another thread if this was the currently selected one. + // As we don't store thread objects in this reducer, and only store thread actor IDs, + // we can't try to find the top level thread. So we pick the first available thread, + // and hope that's the top level one. + if (state.threadcx.thread == action.threadActorID) { + threadcx = { + ...threadcx, + thread: Object.keys(threads)[0], + pauseCounter: threadcx.pauseCounter + 1, + }; + } + return { + ...state, + threadcx, + threads, + }; + } + break; + } + + case "PAUSED": { + const { thread, frame, why } = action; + state = { + ...state, + threadcx: { + ...state.threadcx, + pauseCounter: state.threadcx.pauseCounter + 1, + thread, + }, + }; + + return updateThreadState({ + isWaitingOnBreak: false, + selectedFrameId: frame ? frame.id : undefined, + isPaused: true, + frames: frame ? [frame] : undefined, + framesLoading: true, + frameScopes: { ...resumedPauseState.frameScopes }, + why, + shouldBreakpointsPaneOpenOnPause: why.type === "breakpoint", + }); + } + + case "FETCHED_FRAMES": { + const { frames } = action; + + // We typically receive a PAUSED action before this one, + // with only the first frame. Here, we avoid replacing it + // with a copy of it in order to avoid triggerring selectors + // uncessarily + // (note that in jest, action's frames might be empty) + // (and if we resume in between PAUSED and FETCHED_FRAMES + // threadState().frames might be null) + if (threadState().frames) { + const previousFirstFrame = threadState().frames[0]; + if (previousFirstFrame.id == frames[0]?.id) { + frames.splice(0, 1, previousFirstFrame); + } + } + return updateThreadState({ frames, framesLoading: false }); + } + + case "MAP_FRAMES": { + const { selectedFrameId, frames } = action; + return updateThreadState({ frames, selectedFrameId }); + } + + case "MAP_FRAME_DISPLAY_NAMES": { + const { frames } = action; + return updateThreadState({ frames }); + } + + case "ADD_SCOPES": { + const { frame, status, value } = action; + const selectedFrameId = frame.id; + + const generated = { + ...threadState().frameScopes.generated, + [selectedFrameId]: { + pending: status !== "done", + scope: value, + }, + }; + + return updateThreadState({ + frameScopes: { + ...threadState().frameScopes, + generated, + }, + }); + } + + case "MAP_SCOPES": { + const { frame, status, value } = action; + const selectedFrameId = frame.id; + + const original = { + ...threadState().frameScopes.original, + [selectedFrameId]: { + pending: status !== "done", + scope: value?.scope, + }, + }; + + const mappings = { + ...threadState().frameScopes.mappings, + [selectedFrameId]: value?.mappings, + }; + + return updateThreadState({ + frameScopes: { + ...threadState().frameScopes, + original, + mappings, + }, + }); + } + + case "BREAK_ON_NEXT": + return updateThreadState({ isWaitingOnBreak: true }); + + case "SELECT_FRAME": + return updateThreadState({ selectedFrameId: action.frame.id }); + + case "PAUSE_ON_EXCEPTIONS": { + const { shouldPauseOnExceptions, shouldPauseOnCaughtExceptions } = action; + + prefs.pauseOnExceptions = shouldPauseOnExceptions; + prefs.pauseOnCaughtExceptions = shouldPauseOnCaughtExceptions; + + // Preserving for the old debugger + prefs.ignoreCaughtExceptions = !shouldPauseOnCaughtExceptions; + + return { + ...state, + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + }; + } + + case "COMMAND": + if (action.status === "start") { + return updateThreadState({ + ...resumedPauseState, + command: action.command, + previousLocation: getPauseLocation(threadState(), action), + }); + } + return updateThreadState({ command: null }); + + case "RESUME": { + if (action.thread == state.threadcx.thread) { + state = { + ...state, + threadcx: { + ...state.threadcx, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + + return updateThreadState({ + ...resumedPauseState, + expandedScopes: new Set(), + lastExpandedScopes: [...threadState().expandedScopes], + shouldBreakpointsPaneOpenOnPause: false, + }); + } + + case "EVALUATE_EXPRESSION": + return updateThreadState({ + command: action.status === "start" ? "expression" : null, + }); + + case "NAVIGATE": { + const navigateCounter = state.cx.navigateCounter + 1; + return { + ...state, + cx: { + navigateCounter, + }, + threadcx: { + navigateCounter, + thread: action.mainThread.actor, + pauseCounter: 0, + }, + threads: { + ...state.threads, + [action.mainThread.actor]: { + ...getThreadPauseState(state, action.mainThread.actor), + ...resumedPauseState, + }, + }, + }; + } + + case "TOGGLE_SKIP_PAUSING": { + const { skipPausing } = action; + prefs.skipPausing = skipPausing; + + return { ...state, skipPausing }; + } + + case "TOGGLE_MAP_SCOPES": { + const { mapScopes } = action; + prefs.mapScopes = mapScopes; + return { ...state, mapScopes }; + } + + case "SET_EXPANDED_SCOPE": { + const { path, expanded } = action; + const expandedScopes = new Set(threadState().expandedScopes); + if (expanded) { + expandedScopes.add(path); + } else { + expandedScopes.delete(path); + } + return updateThreadState({ expandedScopes }); + } + + case "ADD_INLINE_PREVIEW": { + const { frame, previews } = action; + const selectedFrameId = frame.id; + + return updateThreadState({ + inlinePreview: { + ...threadState().inlinePreview, + [selectedFrameId]: previews, + }, + }); + } + + case "HIGHLIGHT_CALLS": { + const { highlightedCalls } = action; + return updateThreadState({ ...threadState(), highlightedCalls }); + } + + case "UNHIGHLIGHT_CALLS": { + return updateThreadState({ + ...threadState(), + highlightedCalls: null, + }); + } + + case "RESET_BREAKPOINTS_PANE_STATE": { + return updateThreadState({ + ...threadState(), + shouldBreakpointsPaneOpenOnPause: false, + }); + } + } + + return state; +} + +function getPauseLocation(state, action) { + const { frames, previousLocation } = state; + + // NOTE: We store the previous location so that we ensure that we + // do not stop at the same location twice when we step over. + if (action.command !== "stepOver") { + return null; + } + + const frame = frames?.[0]; + if (!frame) { + return previousLocation; + } + + return { + location: frame.location, + generatedLocation: frame.generatedLocation, + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/pending-breakpoints.js b/devtools/client/debugger/src/reducers/pending-breakpoints.js new file mode 100644 index 0000000000..25e9a65dd4 --- /dev/null +++ b/devtools/client/debugger/src/reducers/pending-breakpoints.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Pending breakpoints reducer. + * + * Pending breakpoints are a more lightweight version compared to regular breakpoints objects. + * They are meant to be persisted across Firefox restarts and stored into async-storage. + * This reducer data is saved into asyncStore from bootstrap.js and restored from main.js. + * + * The main difference with pending breakpoints is that we only save breakpoints + * against source with an URL as only them can be restored. (source IDs are different across reloads). + * The second difference is that we don't store the whole source object but only the source URL. + */ + +import { isPrettyURL } from "../utils/source"; +import assert from "../utils/assert"; + +function update(state = {}, action) { + switch (action.type) { + case "SET_BREAKPOINT": + if (action.status === "start") { + return setBreakpoint(state, action.breakpoint); + } + return state; + + case "REMOVE_BREAKPOINT": + if (action.status === "start") { + return removeBreakpoint(state, action.breakpoint); + } + return state; + + case "REMOVE_PENDING_BREAKPOINT": + return removePendingBreakpoint(state, action.pendingBreakpoint); + + case "CLEAR_BREAKPOINTS": { + return {}; + } + } + + return state; +} + +function shouldBreakpointBePersisted(breakpoint) { + // We only save breakpoint for source with URL. + // Source without URL can only be identified via their source actor ID + // which isn't persisted across reloads. + return !breakpoint.options.hidden && breakpoint.location.source.url; +} + +function setBreakpoint(state, breakpoint) { + if (!shouldBreakpointBePersisted(breakpoint)) { + return state; + } + + const id = makeIdFromBreakpoint(breakpoint); + const pendingBreakpoint = createPendingBreakpoint(breakpoint); + + return { ...state, [id]: pendingBreakpoint }; +} + +function removeBreakpoint(state, breakpoint) { + if (!shouldBreakpointBePersisted(breakpoint)) { + return state; + } + + const id = makeIdFromBreakpoint(breakpoint); + state = { ...state }; + + delete state[id]; + return state; +} + +function removePendingBreakpoint(state, pendingBreakpoint) { + const id = makeIdFromPendingBreakpoint(pendingBreakpoint); + state = { ...state }; + + delete state[id]; + return state; +} + +/** + * Return a unique identifier for a given breakpoint, + * using its original location, or for pretty-printed sources, + * its generated location. + * + * @param {Object} breakpoint + */ +function makeIdFromBreakpoint(breakpoint) { + const location = isPrettyURL(breakpoint.location.sourceUrl) + ? breakpoint.generatedLocation + : breakpoint.location; + + const { sourceUrl, line, column } = location; + const sourceUrlString = sourceUrl || ""; + const columnString = column || ""; + + return `${sourceUrlString}:${line}:${columnString}`; +} + +function makeIdFromPendingBreakpoint(pendingBreakpoint) { + const { sourceUrl, line, column } = pendingBreakpoint.location; + const sourceUrlString = sourceUrl || ""; + const columnString = column || ""; + + return `${sourceUrlString}:${line}:${columnString}`; +} + +/** + * Convert typical debugger frontend location (created via location.js:createLocation) + * to a more lightweight flavor of it which will be stored in async storage. + */ +function createPendingLocation(location) { + assert(location.hasOwnProperty("line"), "location must have a line"); + assert(location.hasOwnProperty("column"), "location must have a column"); + + const { sourceUrl, line, column } = location; + assert(sourceUrl !== undefined, "pending location must have a source url"); + return { sourceUrl, line, column }; +} + +/** + * Create a new pending breakpoint, which is a more lightweight version of the regular breakpoint object. + */ +function createPendingBreakpoint(bp) { + return { + options: bp.options, + disabled: bp.disabled, + location: createPendingLocation(bp.location), + generatedLocation: createPendingLocation(bp.generatedLocation), + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/preview.js b/devtools/client/debugger/src/reducers/preview.js new file mode 100644 index 0000000000..d7ee396bf5 --- /dev/null +++ b/devtools/client/debugger/src/reducers/preview.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function initialPreviewState() { + return { + preview: null, + previewCount: 0, + }; +} + +function update(state = initialPreviewState(), action) { + switch (action.type) { + case "CLEAR_PREVIEW": { + return { ...state, preview: null }; + } + + case "START_PREVIEW": { + return { ...state, previewCount: state.previewCount + 1 }; + } + + case "SET_PREVIEW": { + return { ...state, preview: action.value }; + } + } + + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/project-text-search.js b/devtools/client/debugger/src/reducers/project-text-search.js new file mode 100644 index 0000000000..e803718240 --- /dev/null +++ b/devtools/client/debugger/src/reducers/project-text-search.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @format + +/** + * Project text search reducer + * @module reducers/project-text-search + */ + +import { createLocation } from "../utils/location"; + +export const statusType = { + initial: "INITIAL", + fetching: "FETCHING", + cancelled: "CANCELLED", + done: "DONE", + error: "ERROR", +}; + +export function initialProjectTextSearchState(state) { + return { + query: state?.query || "", + results: [], + ongoingSearch: null, + status: statusType.initial, + }; +} + +function update(state = initialProjectTextSearchState(), action) { + switch (action.type) { + case "ADD_QUERY": + return { ...state, query: action.query }; + + case "ADD_SEARCH_RESULT": + const { location, matches } = action; + if (matches.length === 0) { + return state; + } + + const result = { + type: "RESULT", + location, + // `matches` are generated by project-search worker's `findSourceMatches` method + matches: matches.map(m => ({ + type: "MATCH", + location: createLocation({ + ...location, + // `matches` only contain line and column + // `location` will already refer to the right source/sourceActor + line: m.line, + column: m.column, + }), + matchIndex: m.matchIndex, + match: m.match, + value: m.value, + })), + }; + return { ...state, results: [...state.results, result] }; + + case "UPDATE_STATUS": + const ongoingSearch = + action.status == statusType.fetching ? state.ongoingSearch : null; + return { ...state, status: action.status, ongoingSearch }; + + case "CLEAR_SEARCH_RESULTS": + return { ...state, results: [] }; + + case "ADD_ONGOING_SEARCH": + return { ...state, ongoingSearch: action.ongoingSearch }; + + case "CLEAR_SEARCH": + case "CLOSE_PROJECT_SEARCH": + return initialProjectTextSearchState(); + case "NAVIGATE": + return initialProjectTextSearchState(state); + } + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/quick-open.js b/devtools/client/debugger/src/reducers/quick-open.js new file mode 100644 index 0000000000..459e530e7b --- /dev/null +++ b/devtools/client/debugger/src/reducers/quick-open.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Quick Open reducer + * @module reducers/quick-open + */ + +import { parseQuickOpenQuery } from "../utils/quick-open"; + +export const initialQuickOpenState = () => ({ + enabled: false, + query: "", + searchType: "sources", +}); + +export default function update(state = initialQuickOpenState(), action) { + switch (action.type) { + case "OPEN_QUICK_OPEN": + if (action.query != null) { + return { + ...state, + enabled: true, + query: action.query, + searchType: parseQuickOpenQuery(action.query), + }; + } + return { ...state, enabled: true }; + case "CLOSE_QUICK_OPEN": + return initialQuickOpenState(); + case "SET_QUICK_OPEN_QUERY": + return { + ...state, + query: action.query, + searchType: parseQuickOpenQuery(action.query), + }; + default: + return state; + } +} diff --git a/devtools/client/debugger/src/reducers/source-actors.js b/devtools/client/debugger/src/reducers/source-actors.js new file mode 100644 index 0000000000..a7dfa19621 --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-actors.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * This reducer stores the list of all source actors as well their breakable lines. + * + * There is a one-one relationship with Source Actors from the server codebase, + * as well as SOURCE Resources distributed by the ResourceCommand API. + * + */ +function initialSourceActorsState() { + return { + // Map(Source Actor ID: string => SourceActor: object) + // See create.js: `createSourceActor` for the shape of the source actor objects. + mutableSourceActors: new Map(), + + // Map(Source Actor ID: string => Breakable lines: Array<Number>) + // The array is the list of all lines where breakpoints can be set + mutableBreakableLines: new Map(), + + // Set(Source Actor ID: string) + // List of all IDs of source actor which have a valid related source map / original source. + // The SourceActor object may have a sourceMapURL attribute set, + // but this may be invalid. The source map URL or source map file content may be invalid. + // In these scenarios we will remove the source actor from this set. + mutableSourceActorsWithSourceMap: new Set(), + }; +} + +export const initial = initialSourceActorsState(); + +export default function update(state = initialSourceActorsState(), action) { + switch (action.type) { + case "INSERT_SOURCE_ACTORS": { + for (const sourceActor of action.sourceActors) { + state.mutableSourceActors.set(sourceActor.id, sourceActor); + + // If the sourceMapURL attribute is set, consider that it is valid. + // But this may be revised later and removed from this Set. + if (sourceActor.sourceMapURL) { + state.mutableSourceActorsWithSourceMap.add(sourceActor.id); + } + } + return { + ...state, + }; + } + + case "REMOVE_THREAD": { + for (const sourceActor of state.mutableSourceActors.values()) { + if (sourceActor.thread == action.threadActorID) { + state.mutableSourceActors.delete(sourceActor.id); + state.mutableBreakableLines.delete(sourceActor.id); + state.mutableSourceActorsWithSourceMap.delete(sourceActor.id); + } + } + return { + ...state, + }; + } + + case "SET_SOURCE_ACTOR_BREAKABLE_LINES": + return updateBreakableLines(state, action); + + case "CLEAR_SOURCE_ACTOR_MAP_URL": + if (state.mutableSourceActorsWithSourceMap.delete(action.sourceActorId)) { + return { + ...state, + }; + } + return state; + } + + return state; +} + +function updateBreakableLines(state, action) { + const { sourceActorId } = action; + + // Ignore breakable lines for source actors that aren't/no longer registered + if (!state.mutableSourceActors.has(sourceActorId)) { + return state; + } + + state.mutableBreakableLines.set(sourceActorId, action.breakableLines); + return { + ...state, + }; +} diff --git a/devtools/client/debugger/src/reducers/source-blackbox.js b/devtools/client/debugger/src/reducers/source-blackbox.js new file mode 100644 index 0000000000..84e6d9ba8c --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-blackbox.js @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Reducer containing all data about sources being "black boxed". + * + * i.e. sources which should be ignored by the debugger. + * Typically, these sources should be hidden from paused stack frames, + * and any debugger statement or breakpoint should be ignored. + */ + +export function initialSourceBlackBoxState(state) { + return { + /* FORMAT: + * blackboxedRanges: { + * [source url]: [range, range, ...], -- source lines blackboxed + * [source url]: [], -- whole source blackboxed + * ... + * } + */ + blackboxedRanges: state?.blackboxedRanges ?? {}, + + blackboxedSet: state?.blackboxedRanges + ? new Set(Object.keys(state.blackboxedRanges)) + : new Set(), + + sourceMapIgnoreListUrls: [], + }; +} + +function update(state = initialSourceBlackBoxState(), action) { + switch (action.type) { + case "BLACKBOX_WHOLE_SOURCES": { + const { sources } = action; + + const currentBlackboxedRanges = { ...state.blackboxedRanges }; + const currentBlackboxedSet = new Set(state.blackboxedSet); + + for (const source of sources) { + currentBlackboxedRanges[source.url] = []; + currentBlackboxedSet.add(source.url); + } + + return { + ...state, + blackboxedRanges: currentBlackboxedRanges, + blackboxedSet: currentBlackboxedSet, + }; + } + + case "BLACKBOX_SOURCE_RANGES": { + const { source, ranges } = action; + + const currentBlackboxedRanges = { ...state.blackboxedRanges }; + const currentBlackboxedSet = new Set(state.blackboxedSet); + + if (!currentBlackboxedRanges[source.url]) { + currentBlackboxedRanges[source.url] = []; + currentBlackboxedSet.add(source.url); + } else { + currentBlackboxedRanges[source.url] = [ + ...state.blackboxedRanges[source.url], + ]; + } + + // Add new blackboxed lines in acsending order + for (const newRange of ranges) { + const index = currentBlackboxedRanges[source.url].findIndex( + range => + range.end.line <= newRange.start.line && + range.end.column <= newRange.start.column + ); + currentBlackboxedRanges[source.url].splice(index + 1, 0, newRange); + } + + return { + ...state, + blackboxedRanges: currentBlackboxedRanges, + blackboxedSet: currentBlackboxedSet, + }; + } + + case "UNBLACKBOX_WHOLE_SOURCES": { + const { sources } = action; + + const currentBlackboxedRanges = { ...state.blackboxedRanges }; + const currentBlackboxedSet = new Set(state.blackboxedSet); + + for (const source of sources) { + delete currentBlackboxedRanges[source.url]; + currentBlackboxedSet.delete(source.url); + } + + return { + ...state, + blackboxedRanges: currentBlackboxedRanges, + blackboxedSet: currentBlackboxedSet, + }; + } + + case "UNBLACKBOX_SOURCE_RANGES": { + const { source, ranges } = action; + + const currentBlackboxedRanges = { + ...state.blackboxedRanges, + [source.url]: [...state.blackboxedRanges[source.url]], + }; + + for (const newRange of ranges) { + const index = currentBlackboxedRanges[source.url].findIndex( + range => + range.start.line === newRange.start.line && + range.end.line === newRange.end.line + ); + + if (index !== -1) { + currentBlackboxedRanges[source.url].splice(index, 1); + } + } + + return { + ...state, + blackboxedRanges: currentBlackboxedRanges, + }; + } + + case "ADD_SOURCEMAP_IGNORE_LIST_SOURCES": + if (action.status == "done") { + return { + ...state, + sourceMapIgnoreListUrls: [ + ...state.sourceMapIgnoreListUrls, + ...action.value, + ], + }; + } + return state; + + case "NAVIGATE": + return initialSourceBlackBoxState(state); + } + + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/sources-content.js b/devtools/client/debugger/src/reducers/sources-content.js new file mode 100644 index 0000000000..b60cb059b9 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources-content.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources content reducer. + * + * This store the textual content for each source. + */ + +import { pending, fulfilled, rejected } from "../utils/async-value"; + +export function initialSourcesContentState() { + return { + /** + * Text content of all the original sources. + * This is large data, so this is only fetched on-demand for a subset of sources. + * This state attribute is mutable in order to avoid cloning this possibly large map + * on each new source. But selectors are never based on the map. Instead they only + * query elements of the map. + * + * Map(source id => AsyncValue<String>) + */ + mutableOriginalSourceTextContentMapBySourceId: new Map(), + + /** + * Text content of all the generated sources. + * + * Map(source actor is => AsyncValue<String>) + */ + mutableGeneratedSourceTextContentMapBySourceActorId: new Map(), + + /** + * Incremental number that is bumped each time we navigate to a new page. + * + * This is used to better handle async race condition where we mix previous page data + * with the new page. As sources are keyed by URL we may easily conflate the two page loads data. + */ + epoch: 1, + }; +} + +function update(state = initialSourcesContentState(), action) { + switch (action.type) { + case "LOAD_ORIGINAL_SOURCE_TEXT": + if (!action.sourceId) { + throw new Error("No source id found."); + } + return updateSourceTextContent(state, action); + + case "LOAD_GENERATED_SOURCE_TEXT": + if (!action.sourceActorId) { + throw new Error("No source actor id found."); + } + return updateSourceTextContent(state, action); + + case "REMOVE_THREAD": + return removeThread(state, action); + } + + return state; +} + +/* + * Update a source's loaded text content. + */ +function updateSourceTextContent(state, action) { + // If there was a navigation between the time the action was started and + // completed, we don't want to update the store. + if (action.epoch !== state.epoch) { + return state; + } + + let content; + if (action.status === "start") { + content = pending(); + } else if (action.status === "error") { + content = rejected(action.error); + } else if (typeof action.value.text === "string") { + content = fulfilled({ + type: "text", + value: action.value.text, + contentType: action.value.contentType, + }); + } else { + content = fulfilled({ + type: "wasm", + value: action.value.text, + }); + } + + if (action.sourceId && action.sourceActorId) { + throw new Error( + "Both the source id and the source actor should not exist at the same time" + ); + } + + if (action.sourceId) { + state.mutableOriginalSourceTextContentMapBySourceId.set( + action.sourceId, + content + ); + } + + if (action.sourceActorId) { + state.mutableGeneratedSourceTextContentMapBySourceActorId.set( + action.sourceActorId, + content + ); + } + + return { + ...state, + }; +} + +function removeThread(state, action) { + const originalSizeBefore = + state.mutableOriginalSourceTextContentMapBySourceId.size; + for (const source of action.sources) { + state.mutableOriginalSourceTextContentMapBySourceId.delete(source.id); + } + const generatedSizeBefore = + state.mutableGeneratedSourceTextContentMapBySourceActorId.size; + for (const actor of action.actors) { + state.mutableGeneratedSourceTextContentMapBySourceActorId.delete(actor.id); + } + if ( + originalSizeBefore != + state.mutableOriginalSourceTextContentMapBySourceId.size || + generatedSizeBefore != + state.mutableGeneratedSourceTextContentMapBySourceActorId.size + ) { + return { ...state }; + } + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/sources-tree.js b/devtools/client/debugger/src/reducers/sources-tree.js new file mode 100644 index 0000000000..3658b0b059 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources-tree.js @@ -0,0 +1,585 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources tree reducer + * + * A Source Tree is composed of: + * + * - Thread Items To designate targets/threads. + * These are the roots of the Tree if no project directory is selected. + * + * - Group Items To designates the different domains used in the website. + * These are direct children of threads and may contain directory or source items. + * + * - Directory Items To designate all the folders. + * Note that each every folder has an items. The Source Tree React component is doing the magic to coallesce folders made of only one sub folder. + * + * - Source Items To designate sources. + * They are the leaves of the Tree. (we should not have empty directories.) + */ + +const IGNORED_URLS = ["debugger eval code", "XStringBundle"]; +const IGNORED_EXTENSIONS = ["css", "svg", "png"]; +import { isPretty } from "../utils/source"; +import { prefs } from "../utils/prefs"; + +export function initialSourcesTreeState() { + return { + // List of all Thread Tree Items. + // All other item types are children of these and aren't store in + // the reducer as top level objects. + threadItems: [], + + // List of `uniquePath` of Tree Items that are expanded. + // This should be all but Source Tree Items. + expanded: new Set(), + + // Reference to the currently focused Tree Item. + // It can be any type of Tree Item. + focusedItem: null, + + // Project root set from the Source Tree. + // This focuses the source tree on a subset of sources. + // This is a `uniquePath`, where ${thread} is replaced by "top-level" + // when we picked an item from the main thread. This allows to preserve + // the root selection on page reload. + projectDirectoryRoot: prefs.projectDirectoryRoot, + + // The name is displayed in Source Tree header + projectDirectoryRootName: prefs.projectDirectoryRootName, + + // Reports if the top level target is a web extension. + // If so, we should display all web extension sources. + isWebExtension: false, + + /** + * Boolean, to be set to true in order to display WebExtension's content scripts + * that are applied to the current page we are debugging. + * + * Covered by: browser_dbg-content-script-sources.js + * Bound to: devtools.chrome.enabled + * + */ + chromeAndExtensionsEnabled: prefs.chromeAndExtensionsEnabled, + }; +} + +// eslint-disable-next-line complexity +export default function update(state = initialSourcesTreeState(), action) { + switch (action.type) { + case "ADD_ORIGINAL_SOURCES": { + const { generatedSourceActor } = action; + const validOriginalSources = action.originalSources.filter(source => + isSourceVisibleInSourceTree( + source, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!validOriginalSources.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const source of validOriginalSources) { + changed |= addSource(threadItems, source, generatedSourceActor); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + case "INSERT_SOURCE_ACTORS": { + // With this action, we only cover generated sources. + // (i.e. we need something else for sourcemapped/original sources) + // But we do want to process source actors in order to be able to display + // distinct Source Tree Items for sources with the same URL loaded in distinct thread. + // (And may be also later be able to highlight the many sources with the same URL loaded in a given thread) + const newSourceActors = action.sourceActors.filter(sourceActor => + isSourceVisibleInSourceTree( + sourceActor.sourceObject, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!newSourceActors.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const sourceActor of newSourceActors) { + // We mostly wanted to read the thread of the SourceActor, + // most of the interesting attributes are on the Source Object. + changed |= addSource( + threadItems, + sourceActor.sourceObject, + sourceActor + ); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + + case "INSERT_THREAD": + state = { ...state }; + addThread(state, action.newThread); + return state; + + case "REMOVE_THREAD": { + const { threadActorID } = action; + const index = state.threadItems.findIndex(item => { + return item.threadActorID == threadActorID; + }); + + if (index == -1) { + return state; + } + + // Also clear focusedItem and expanded items related + // to this thread. These fields store uniquePath which starts + // with the thread actor ID. + let { focusedItem } = state; + if (focusedItem && focusedItem.uniquePath.startsWith(threadActorID)) { + focusedItem = null; + } + const expanded = new Set(); + for (const path of state.expanded) { + if (!path.startsWith(threadActorID)) { + expanded.add(path); + } + } + + const threadItems = [...state.threadItems]; + threadItems.splice(index, 1); + return { + ...state, + threadItems, + focusedItem, + expanded, + }; + } + + case "SET_EXPANDED_STATE": + return updateExpanded(state, action); + + case "SET_FOCUSED_SOURCE_ITEM": + return { ...state, focusedItem: action.item }; + + case "SET_PROJECT_DIRECTORY_ROOT": + const { url, name } = action; + return updateProjectDirectoryRoot(state, url, name); + + case "BLACKBOX_WHOLE_SOURCES": + case "BLACKBOX_SOURCE_RANGES": { + const sources = action.sources || [action.source]; + return updateBlackbox(state, sources, true); + } + + case "UNBLACKBOX_WHOLE_SOURCES": { + const sources = action.sources || [action.source]; + return updateBlackbox(state, sources, false); + } + } + return state; +} + +function addThread(state, thread) { + const threadActorID = thread.actor; + // When processing the top level target, + // see if we are debugging an extension. + if (thread.isTopLevel) { + state.isWebExtension = thread.isWebExtension; + } + let threadItem = state.threadItems.find(item => { + return item.threadActorID == threadActorID; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(threadActorID); + state.threadItems = [...state.threadItems, threadItem]; + } else { + // We force updating the list to trigger mapStateToProps + // as the getSourcesTreeSources selector is awaiting for the `thread` attribute + // which we will set here. + state.threadItems = [...state.threadItems]; + } + // Inject the reducer thread object on Thread Tree Items + // (this is handy shortcut to have access to from React components) + // (this is also used by sortThreadItems to sort the thread as a Tree in the Browser Toolbox) + threadItem.thread = thread; + state.threadItems.sort(sortThreadItems); +} + +function updateBlackbox(state, sources, shouldBlackBox) { + const threadItems = [...state.threadItems]; + + for (const source of sources) { + for (const threadItem of threadItems) { + const sourceTreeItem = findSourceInThreadItem(source, threadItem); + if (sourceTreeItem) { + sourceTreeItem.isBlackBoxed = shouldBlackBox; + } + } + } + return { ...state, threadItems }; +} + +function updateExpanded(state, action) { + // We receive the full list of all expanded items + // (not only the one added/removed) + return { + ...state, + expanded: new Set(action.expanded), + }; +} + +/** + * Update the project directory root + */ +function updateProjectDirectoryRoot(state, root, name) { + // Only persists root within the top level target. + // Otherwise the thread actor ID will change on page reload and we won't match anything + if (!root || root.startsWith("top-level")) { + prefs.projectDirectoryRoot = root; + prefs.projectDirectoryRootName = name; + } + + return { + ...state, + projectDirectoryRoot: root, + projectDirectoryRootName: name, + }; +} + +function isSourceVisibleInSourceTree( + source, + chromeAndExtensionsEnabled, + debuggeeIsWebExtension +) { + return ( + !!source.url && + !IGNORED_EXTENSIONS.includes(source.displayURL.fileExtension) && + !IGNORED_URLS.includes(source.url) && + !isPretty(source) && + // Only accept web extension sources when the chrome pref is enabled (to allows showing content scripts), + // or when we are debugging an extension + (!source.isExtension || + chromeAndExtensionsEnabled || + debuggeeIsWebExtension) + ); +} + +function addSource(threadItems, source, sourceActor) { + // Ensure creating or fetching the related Thread Item + let threadItem = threadItems.find(item => { + return item.threadActorID == sourceActor.thread; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(sourceActor.thread); + // Note that threadItems will be cloned once to force a state update + // by the callsite of `addSourceActor` + threadItems.push(threadItem); + threadItems.sort(sortThreadItems); + } + + // Then ensure creating or fetching the related Group Item + // About `source` versus `sourceActor`: + const { displayURL } = source; + const { group } = displayURL; + + let groupItem = threadItem.children.find(item => { + return item.groupName == group; + }); + + if (!groupItem) { + groupItem = createGroupTreeItem(group, threadItem, source); + // Copy children in order to force updating react in case we picked + // this directory as a project root + threadItem.children = [...threadItem.children, groupItem]; + // As we add a new item, re-sort the groups in this thread + threadItem.children.sort(sortItems); + } + + // Then ensure creating or fetching all possibly nested Directory Item(s) + const { path } = displayURL; + const parentPath = path.substring(0, path.lastIndexOf("/")); + const directoryItem = addOrGetParentDirectory(groupItem, parentPath); + + // Check if a previous source actor registered this source. + // It happens if we load the same url multiple times, or, + // for inline sources (=HTML pages with inline scripts). + const existing = directoryItem.children.find(item => { + return item.type == "source" && item.source == source; + }); + if (existing) { + return false; + } + + // Finaly, create the Source Item and register it in its parent Directory Item + const sourceItem = createSourceTreeItem(source, sourceActor, directoryItem); + // Copy children in order to force updating react in case we picked + // this directory as a project root + directoryItem.children = [...directoryItem.children, sourceItem]; + // Re-sort the items in this directory + directoryItem.children.sort(sortItems); + + return true; +} +/** + * Find all the source items in tree + * @param {Object} item - Current item node in the tree + * @param {Function} callback + */ +function findSourceInThreadItem(source, threadItem) { + const { displayURL } = source; + const { group, path } = displayURL; + const groupItem = threadItem.children.find(item => { + return item.groupName == group; + }); + if (!groupItem) return null; + + const parentPath = path.substring(0, path.lastIndexOf("/")); + const directoryItem = groupItem._allGroupDirectoryItems.find(item => { + return item.type == "directory" && item.path == parentPath; + }); + if (!directoryItem) return null; + + return directoryItem.children.find(item => { + return item.type == "source" && item.source == source; + }); +} + +function sortItems(a, b) { + if (a.type == "directory" && b.type == "source") { + return -1; + } else if (b.type == "directory" && a.type == "source") { + return 1; + } else if (a.type == "directory" && b.type == "directory") { + return a.path.localeCompare(b.path); + } else if (a.type == "source" && b.type == "source") { + return a.source.displayURL.filename.localeCompare( + b.source.displayURL.filename + ); + } + return 0; +} + +function sortThreadItems(a, b) { + // Jest tests aren't emitting the necessary actions to populate the thread attributes. + // Ignore sorting for them. + if (!a.thread || !b.thread) { + return 0; + } + + // Top level target is always listed first + if (a.thread.isTopLevel) { + return -1; + } else if (b.thread.isTopLevel) { + return 1; + } + + // Process targets should come next and after that frame targets + if (a.thread.targetType == "process" && b.thread.targetType == "frame") { + return -1; + } else if ( + a.thread.targetType == "frame" && + b.thread.targetType == "process" + ) { + return 1; + } + + // And we display the worker targets last. + if ( + a.thread.targetType.endsWith("worker") && + !b.thread.targetType.endsWith("worker") + ) { + return 1; + } else if ( + !a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return -1; + } + + // Order the process targets by their process ids + if (a.thread.processID > b.thread.processID) { + return 1; + } else if (a.thread.processID < b.thread.processID) { + return 0; + } + + // Order the frame targets and the worker targets by their target name + if (a.thread.targetType == "frame" && b.thread.targetType == "frame") { + return a.thread.name.localeCompare(b.thread.name); + } else if ( + a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return a.thread.name.localeCompare(b.thread.name); + } + + return 0; +} + +/** + * For a given URL's path, in the given group (i.e. typically a given scheme+domain), + * return the already existing parent directory item, or create it if it doesn't exists. + * Note that it will create all ancestors up to the Group Item. + * + * @param {GroupItem} groupItem + * The Group Item for the group where the path should be displayed. + * @param {String} path + * Path of the directory for which we want a Directory Item. + * @return {GroupItem|DirectoryItem} + * The parent Item where this path should be inserted. + * Note that it may be displayed right under the Group Item if the path is empty. + */ +function addOrGetParentDirectory(groupItem, path) { + // We reached the top of the Tree, so return the Group Item. + if (!path) { + return groupItem; + } + // See if we have this directory already registered by a previous source + const existing = groupItem._allGroupDirectoryItems.find(item => { + return item.type == "directory" && item.path == path; + }); + if (existing) { + return existing; + } + // It doesn't exists, so we will create a new Directory Item. + // But now, lookup recursively for the parent Item for this to-be-create Directory Item + const parentPath = path.substring(0, path.lastIndexOf("/")); + const parentDirectory = addOrGetParentDirectory(groupItem, parentPath); + + // We can now create the new Directory Item and register it in its parent Item. + const directory = createDirectoryTreeItem(path, parentDirectory); + // Copy children in order to force updating react in case we picked + // this directory as a project root + parentDirectory.children = [...parentDirectory.children, directory]; + // Re-sort the items in this directory + parentDirectory.children.sort(sortItems); + + // Also maintain the list of all group items, + // Which helps speedup querying for existing items. + groupItem._allGroupDirectoryItems.push(directory); + + return directory; +} + +/** + * Definition of all Items of a SourceTree + */ +// Highlights the attributes that all Source Tree Item should expose +function createBaseTreeItem({ type, parent, uniquePath, children }) { + return { + // Can be: thread, group, directory or source + type, + // Reference to the parent TreeItem + parent, + // This attribute is used for two things: + // * as a string key identified in the React Tree + // * for project root in order to find the root in the tree + // It is of the form: + // `${ThreadActorID}|${GroupName}|${DirectoryPath}|${SourceID}` + // Group and path/ID are optional. + // `|` is used as separator in order to avoid having this character being used in name/path/IDs. + uniquePath, + // Array of TreeItem, children of this item. + // Will be null for Source Tree Item + children, + }; +} +function createThreadTreeItem(thread) { + return { + ...createBaseTreeItem({ + type: "thread", + // Each thread is considered as an independant root item + parent: null, + uniquePath: thread, + // Children of threads will only be Group Items + children: [], + }), + + // This will be used to set the reducer's thread object. + // This threadActorID attribute isn't meant to be used outside of this selector. + // A `thread` attribute will be exposed from INSERT_THREAD action. + threadActorID: thread, + }; +} +function createGroupTreeItem(groupName, parent, source) { + return { + ...createBaseTreeItem({ + type: "group", + parent, + uniquePath: `${parent.uniquePath}|${groupName}`, + // Children of Group can be Directory and Source items + children: [], + }), + + groupName, + + // When a content script appear in a web page, + // a dedicated group is created for it and should + // be having an extension icon. + isForExtensionSource: source.isExtension, + + // List of all nested items for this group. + // This helps find any nested directory in a given group without having to walk the tree. + // This is meant to be used only by the reducer. + _allGroupDirectoryItems: [], + }; +} +function createDirectoryTreeItem(path, parent) { + // If the parent is a group we want to use '/' as separator + const pathSeparator = parent.type == "directory" ? "/" : "|"; + + // `path` will be the absolute path from the group/domain, + // while we want to append only the directory name in uniquePath. + // Also, we need to strip '/' prefix. + const relativePath = + parent.type == "directory" + ? path.replace(parent.path, "").replace(/^\//, "") + : path; + + return { + ...createBaseTreeItem({ + type: "directory", + parent, + uniquePath: `${parent.uniquePath}${pathSeparator}${relativePath}`, + // Children can be nested Directory or Source items + children: [], + }), + + // This is the absolute path from the "group" + // i.e. the path from the domain name + // For http://mozilla.org/foo/bar folder, + // path will be: + // foo/bar + path, + }; +} +function createSourceTreeItem(source, sourceActor, parent) { + return { + ...createBaseTreeItem({ + type: "source", + parent, + uniquePath: `${parent.uniquePath}|${source.id}`, + // Sources items are leaves of the SourceTree + children: null, + }), + + source, + sourceActor, + }; +} diff --git a/devtools/client/debugger/src/reducers/sources.js b/devtools/client/debugger/src/reducers/sources.js new file mode 100644 index 0000000000..0d1c55be13 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources.js @@ -0,0 +1,361 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources reducer + * @module reducers/sources + */ + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { prefs } from "../utils/prefs"; +import { createPendingSelectedLocation } from "../utils/location"; + +export function initialSourcesState(state) { + /* eslint sort-keys: "error" */ + return { + /** + * List of all breakpoint positions for all sources (generated and original). + * Map of source id (string) to dictionary object whose keys are line numbers + * and values of array of positions. + * A position is an object made with two attributes: + * location and generatedLocation. Both refering to breakpoint positions + * in original and generated sources. + * In case of generated source, the two location will be the same. + * + * Map(source id => Dictionary(int => array<Position>)) + */ + mutableBreakpointPositions: new Map(), + + /** + * List of all breakable lines for original sources only. + * + * Map(source id => array<int : breakable line numbers>) + */ + mutableOriginalBreakableLines: new Map(), + + /** + * Map of the source id's to one or more related original source id's + * Only generated sources which have related original sources will be maintained here. + * + * Map(source id => array<Original Source ID>) + */ + mutableOriginalSources: new Map(), + + /** + * List of override objects whose sources texts have been locally overridden. + * + * Object { sourceUrl, path } + */ + mutableOverrideSources: state?.mutableOverrideSources || new Map(), + + /** + * Mapping of source id's to one or more source-actor's. + * Dictionary whose keys are source id's and values are arrays + * made of all the related source-actor's. + * Note: The source mapped here are only generated sources. + * + * "source" are the objects stored in this reducer, in the `sources` attribute. + * "source-actor" are the objects stored in the "source-actors.js" reducer, in its `sourceActors` attribute. + * + * Map(source id => array<Source Actor object>) + */ + mutableSourceActors: new Map(), + + /** + * All currently available sources. + * + * See create.js: `createSourceObject` method for the description of stored objects. + */ + mutableSources: new Map(), + + /** + * All sources associated with a given URL. When using source maps, multiple + * sources can have the same URL. + * + * Map(url => array<source>) + */ + mutableSourcesPerUrl: new Map(), + + /** + * When we want to select a source that isn't available yet, use this. + * The location object should have a url attribute instead of a sourceId. + * + * See `createPendingSelectedLocation` for the definition of this object. + */ + pendingSelectedLocation: prefs.pendingSelectedLocation, + + /** + * The actual currently selected location. + * Only set if the related source is already registered in the sources reducer. + * Otherwise, pendingSelectedLocation should be used. Typically for sources + * which are about to be created. + * + * It also includes line and column information. + * + * See `createLocation` for the definition of this object. + */ + selectedLocation: undefined, + + /** + * By default, if we have a source-mapped source, we would automatically try + * to select and show the content of the original source. But, if we explicitly + * select a generated source, we remember this choice. That, until we explicitly + * select an original source. + * Note that selections related to non-source-mapped sources should never + * change this setting. + */ + shouldSelectOriginalLocation: true, + }; + /* eslint-disable sort-keys */ +} + +function update(state = initialSourcesState(), action) { + switch (action.type) { + case "ADD_SOURCES": + return addSources(state, action.sources); + + case "ADD_ORIGINAL_SOURCES": + return addSources(state, action.originalSources); + + case "INSERT_SOURCE_ACTORS": + return insertSourceActors(state, action); + + case "SET_SELECTED_LOCATION": { + let pendingSelectedLocation = null; + + if (action.location.source.url) { + pendingSelectedLocation = createPendingSelectedLocation( + action.location + ); + prefs.pendingSelectedLocation = pendingSelectedLocation; + } + + return { + ...state, + selectedLocation: action.location, + pendingSelectedLocation, + shouldSelectOriginalLocation: action.shouldSelectOriginalLocation, + }; + } + + case "CLEAR_SELECTED_LOCATION": { + const pendingSelectedLocation = { url: "" }; + prefs.pendingSelectedLocation = pendingSelectedLocation; + + return { + ...state, + selectedLocation: null, + pendingSelectedLocation, + }; + } + + case "SET_PENDING_SELECTED_LOCATION": { + const pendingSelectedLocation = { + url: action.url, + line: action.line, + column: action.column, + }; + + prefs.pendingSelectedLocation = pendingSelectedLocation; + return { ...state, pendingSelectedLocation }; + } + + case "SET_ORIGINAL_BREAKABLE_LINES": { + state.mutableOriginalBreakableLines.set( + action.sourceId, + action.breakableLines + ); + + return { + ...state, + }; + } + + case "ADD_BREAKPOINT_POSITIONS": { + // Merge existing and new reported position if some where already stored + let positions = state.mutableBreakpointPositions.get(action.source.id); + if (positions) { + positions = { ...positions, ...action.positions }; + } else { + positions = action.positions; + } + + state.mutableBreakpointPositions.set(action.source.id, positions); + + return { + ...state, + }; + } + + case "REMOVE_THREAD": { + return removeSourcesAndActors(state, action); + } + + case "SET_OVERRIDE": { + state.mutableOverrideSources.set(action.url, action.path); + return state; + } + + case "REMOVE_OVERRIDE": { + if (state.mutableOverrideSources.has(action.url)) { + state.mutableOverrideSources.delete(action.url); + } + return state; + } + } + + return state; +} + +/* + * Add sources to the sources store + * - Add the source to the sources store + * - Add the source URL to the source url map + */ +function addSources(state, sources) { + for (const source of sources) { + state.mutableSources.set(source.id, source); + + // Update the source url map + const existing = state.mutableSourcesPerUrl.get(source.url); + if (existing) { + // We never return this array from selectors as-is, + // we either return the first entry or lookup for a precise entry + // so we can mutate it. + existing.push(source); + } else { + state.mutableSourcesPerUrl.set(source.url, [source]); + } + + // In case of original source, maintain the mapping of generated source to original sources map. + if (source.isOriginal) { + const generatedSourceId = originalToGeneratedId(source.id); + let originalSourceIds = + state.mutableOriginalSources.get(generatedSourceId); + if (!originalSourceIds) { + originalSourceIds = []; + state.mutableOriginalSources.set(generatedSourceId, originalSourceIds); + } + // We never return this array out of selectors, so mutate the list + originalSourceIds.push(source.id); + } + } + + return { ...state }; +} + +function removeSourcesAndActors(state, action) { + const { + mutableSourcesPerUrl, + mutableSources, + mutableOriginalSources, + mutableSourceActors, + mutableOriginalBreakableLines, + mutableBreakpointPositions, + } = state; + + const newState = { ...state }; + + for (const removedSource of action.sources) { + const sourceId = removedSource.id; + + // Clear the urls Map + const sourceUrl = removedSource.url; + if (sourceUrl) { + const sourcesForSameUrl = ( + mutableSourcesPerUrl.get(sourceUrl) || [] + ).filter(s => s != removedSource); + if (!sourcesForSameUrl.length) { + // All sources with this URL have been removed + mutableSourcesPerUrl.delete(sourceUrl); + } else { + // There are other sources still alive with the same URL + mutableSourcesPerUrl.set(sourceUrl, sourcesForSameUrl); + } + } + + mutableSources.delete(sourceId); + + // Note that the caller of this method queried the reducer state + // to aggregate the related original sources. + // So if we were having related original sources, they will be + // in `action.sources`. + mutableOriginalSources.delete(sourceId); + + // If a source is removed, immediately remove all its related source actors. + // It can speed-up the following for loop cleaning actors. + mutableSourceActors.delete(sourceId); + + if (removedSource.isOriginal) { + mutableOriginalBreakableLines.delete(sourceId); + } + + mutableBreakpointPositions.delete(sourceId); + + if (newState.selectedLocation?.source == removedSource) { + newState.selectedLocation = null; + } + } + + for (const removedActor of action.actors) { + const sourceId = removedActor.source; + const actorsForSource = mutableSourceActors.get(sourceId); + // actors may have already been cleared by the previous for..loop + if (!actorsForSource) { + continue; + } + const idx = actorsForSource.indexOf(removedActor); + if (idx != -1) { + actorsForSource.splice(idx, 1); + // While the Map is mutable, we expect new array instance on each new change + mutableSourceActors.set(sourceId, [...actorsForSource]); + } + + // Remove the entry in the Map if there is no more actors for that source + if (!actorsForSource.length) { + mutableSourceActors.delete(sourceId); + } + + if (newState.selectedLocation?.sourceActor == removedActor) { + newState.selectedLocation = null; + } + } + + return newState; +} + +function insertSourceActors(state, action) { + const { sourceActors } = action; + + const { mutableSourceActors } = state; + // The `sourceActor` objects are defined from `newGeneratedSources` action: + // https://searchfox.org/mozilla-central/rev/4646b826a25d3825cf209db890862b45fa09ffc3/devtools/client/debugger/src/actions/sources/newSources.js#300-314 + for (const sourceActor of sourceActors) { + const sourceId = sourceActor.source; + // We always clone the array of source actors as we return it from selectors. + // So the map is mutable, but its values are considered immutable and will change + // anytime there is a new actor added per source ID. + const existing = mutableSourceActors.get(sourceId); + if (existing) { + mutableSourceActors.set(sourceId, [...existing, sourceActor]); + } else { + mutableSourceActors.set(sourceId, [sourceActor]); + } + } + + const scriptActors = sourceActors.filter( + item => item.introductionType === "scriptElement" + ); + if (scriptActors.length) { + // If new HTML sources are being added, we need to clear the breakpoint + // positions since the new source is a <script> with new breakpoints. + for (const { source } of scriptActors) { + state.mutableBreakpointPositions.delete(source); + } + } + + return { ...state }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/tabs.js b/devtools/client/debugger/src/reducers/tabs.js new file mode 100644 index 0000000000..98e33b255c --- /dev/null +++ b/devtools/client/debugger/src/reducers/tabs.js @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Tabs reducer + * @module reducers/tabs + */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { isSimilarTab } from "../utils/tabs"; + +export function initialTabState() { + return { tabs: [] }; +} + +function update(state = initialTabState(), action) { + switch (action.type) { + case "ADD_TAB": + return updateTabList(state, action.source, action.sourceActor); + + case "MOVE_TAB": + return moveTabInList(state, action); + + case "MOVE_TAB_BY_SOURCE_ID": + return moveTabInListBySourceId(state, action); + + case "CLOSE_TAB": + return removeSourceFromTabList(state, action); + + case "CLOSE_TABS": + return removeSourcesFromTabList(state, action); + + case "ADD_ORIGINAL_SOURCES": + return addVisibleTabsForOriginalSources( + state, + action.originalSources, + action.generatedSourceActor + ); + + case "INSERT_SOURCE_ACTORS": + return addVisibleTabsForSourceActors(state, action.sourceActors); + + case "REMOVE_THREAD": { + return resetTabsForThread(state, action.threadActorID); + } + + default: + return state; + } +} + +function matchesSource(tab, source) { + return tab.source?.id === source.id || matchesUrl(tab, source); +} + +function matchesUrl(tab, source) { + return tab.url === source.url && tab.isOriginal == isOriginalId(source.id); +} + +function addVisibleTabsForSourceActors(state, sourceActors) { + let changed = false; + // Lookups for tabs matching any source actor's URL + // and reference their source and sourceActor attribute + // so that the tab becomes visible. + const tabs = state.tabs.map(tab => { + const sourceActor = sourceActors.find(actor => + matchesUrl(tab, actor.sourceObject) + ); + if (!sourceActor) { + return tab; + } + changed = true; + return { + ...tab, + source: sourceActor.sourceObject, + sourceActor, + }; + }); + + return changed ? { tabs } : state; +} + +function addVisibleTabsForOriginalSources( + state, + sources, + generatedSourceActor +) { + let changed = false; + + // Lookups for tabs matching any source's URL + // and reference their source and sourceActor attribute + // so that the tab becomes visible. + const tabs = state.tabs.map(tab => { + const source = sources.find(s => matchesUrl(tab, s)); + if (!source) { + return tab; + } + changed = true; + return { + ...tab, + source, + // All currently reported original sources are related to a single source actor + sourceActor: generatedSourceActor, + }; + }); + + return changed ? { tabs } : state; +} + +function removeSourceFromTabList(state, { source }) { + const newTabs = state.tabs.filter(tab => !matchesSource(tab, source)); + if (newTabs.length == state.tabs.length) { + return state; + } + return { tabs: newTabs }; +} + +function removeSourcesFromTabList(state, { sources }) { + const newTabs = sources.reduce( + (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)), + state.tabs + ); + if (newTabs.length == state.tabs.length) { + return state; + } + + return { tabs: newTabs }; +} + +function resetTabsForThread(state, threadActorID) { + let changed = false; + // Nullify source and sourceActor attributes of all tabs + // related to the given thread so that they become hidden. + // + // They may later be restored if a source matches their URL again. + // This is similar to persistTabs, but specific to a unique thread. + const tabs = state.tabs.map(tab => { + if (tab.sourceActor?.thread != threadActorID) { + return tab; + } + changed = true; + return { + ...tab, + source: null, + sourceActor: null, + }; + }); + + return changed ? { tabs } : state; +} + +/** + * Adds the new source to the tab list if it is not already there. + */ +function updateTabList(state, source, sourceActor) { + const { url } = source; + const isOriginal = isOriginalId(source.id); + + let { tabs } = state; + // Set currentIndex to -1 for URL-less tabs so that they aren't + // filtered by isSimilarTab + const currentIndex = url + ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal)) + : -1; + + if (currentIndex === -1) { + const newTab = { + url, + source, + isOriginal, + sourceActor, + }; + // New tabs are added first in the list + tabs = [newTab, ...tabs]; + } else { + return state; + } + + return { ...state, tabs }; +} + +function moveTabInList(state, { url, tabIndex: newIndex }) { + const { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.url == url); + return moveTab(tabs, currentIndex, newIndex); +} + +function moveTabInListBySourceId(state, { sourceId, tabIndex: newIndex }) { + const { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.source?.id == sourceId); + return moveTab(tabs, currentIndex, newIndex); +} + +function moveTab(tabs, currentIndex, newIndex) { + const item = tabs[currentIndex]; + + const newTabs = Array.from(tabs); + // Remove the item from its current location + newTabs.splice(currentIndex, 1); + // And add it to the new one + newTabs.splice(newIndex, 0, item); + + return { tabs: newTabs }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js new file mode 100644 index 0000000000..aa7f71d7a5 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { initialBreakpointsState } from "../breakpoints"; +import { getBreakpointsForSource } from "../../selectors/breakpoints"; + +import { makeMockBreakpoint, makeMockSource } from "../../utils/test-mockup"; + +function initializeStateWith(data) { + const state = initialBreakpointsState(); + state.breakpoints = data; + return state; +} + +describe("Breakpoints Selectors", () => { + it("it gets a breakpoint for an original source", () => { + const sourceId = "server1.conn1.child1/source1/originalSource"; + const matchingBreakpoints = { + id1: makeMockBreakpoint(makeMockSource(undefined, sourceId), 1), + }; + + const otherBreakpoints = { + id2: makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1), + }; + + const data = { + ...matchingBreakpoints, + ...otherBreakpoints, + }; + + const breakpoints = initializeStateWith(data); + const allBreakpoints = Object.values(matchingBreakpoints); + const sourceBreakpoints = getBreakpointsForSource( + { breakpoints }, + sourceId + ); + + expect(sourceBreakpoints).toEqual(allBreakpoints); + expect(sourceBreakpoints[0] === allBreakpoints[0]).toBe(true); + }); + + it("it gets a breakpoint for a generated source", () => { + const generatedSourceId = "random-source"; + const matchingBreakpoints = { + id1: { + ...makeMockBreakpoint(makeMockSource(undefined, generatedSourceId), 1), + location: { line: 1, source: { id: "original-source-id-1" } }, + }, + }; + + const otherBreakpoints = { + id2: { + ...makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1), + location: { line: 1, source: { id: "original-source-id-2" } }, + }, + }; + + const data = { + ...matchingBreakpoints, + ...otherBreakpoints, + }; + + const breakpoints = initializeStateWith(data); + + const allBreakpoints = Object.values(matchingBreakpoints); + const sourceBreakpoints = getBreakpointsForSource( + { breakpoints }, + generatedSourceId + ); + + expect(sourceBreakpoints).toEqual(allBreakpoints); + }); +}); diff --git a/devtools/client/debugger/src/reducers/tests/quick-open.spec.js b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js new file mode 100644 index 0000000000..cd91b040be --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import update, { initialQuickOpenState } from "../quick-open"; +import { + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, +} from "../../selectors/quick-open"; +import { + setQuickOpenQuery, + openQuickOpen, + closeQuickOpen, +} from "../../actions/quick-open"; + +describe("quickOpen reducer", () => { + test("initial state", () => { + const state = update(undefined, { type: "FAKE" }); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("opens the quickOpen modal", () => { + const state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true); + }); + + test("closes the quickOpen modal", () => { + let state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true); + state = update(initialQuickOpenState(), closeQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(false); + }); + + test("leaves query alone on open if not provided", () => { + const state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("set query on open if provided", () => { + const state = update(initialQuickOpenState(), openQuickOpen("@")); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual("@"); + expect(getQuickOpenType({ quickOpen: state })).toEqual("functions"); + }); + + test("clear query on close", () => { + const state = update(initialQuickOpenState(), closeQuickOpen()); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("sets the query to the provided string", () => { + const state = update(initialQuickOpenState(), setQuickOpenQuery("test")); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual("test"); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); +}); diff --git a/devtools/client/debugger/src/reducers/tests/ui.spec.js b/devtools/client/debugger/src/reducers/tests/ui.spec.js new file mode 100644 index 0000000000..0be451429f --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/ui.spec.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs } from "../../utils/prefs"; +import update, { initialUIState } from "../ui"; + +describe("ui reducer", () => { + it("toggle framework grouping to false", () => { + const state = initialUIState(); + const value = false; + const updatedState = update(state, { + type: "TOGGLE_FRAMEWORK_GROUPING", + value, + }); + expect(updatedState.frameworkGroupingOn).toBe(value); + expect(prefs.frameworkGroupingOn).toBe(value); + }); + + it("toggle framework grouping to true", () => { + const state = initialUIState(); + const value = true; + const updatedState = update(state, { + type: "TOGGLE_FRAMEWORK_GROUPING", + value, + }); + expect(updatedState.frameworkGroupingOn).toBe(value); + expect(prefs.frameworkGroupingOn).toBe(value); + }); +}); diff --git a/devtools/client/debugger/src/reducers/threads.js b/devtools/client/debugger/src/reducers/threads.js new file mode 100644 index 0000000000..0131c6c7e8 --- /dev/null +++ b/devtools/client/debugger/src/reducers/threads.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Threads reducer + * @module reducers/threads + */ + +export function initialThreadsState() { + return { + threads: [], + + // List of thread actor IDs which are current tracing. + // i.e. where JavaScript tracing is enabled. + mutableTracingThreads: new Set(), + }; +} + +export default function update(state = initialThreadsState(), action) { + switch (action.type) { + case "INSERT_THREAD": + return { + ...state, + threads: [...state.threads, action.newThread], + }; + + case "REMOVE_THREAD": + return { + ...state, + threads: state.threads.filter( + thread => action.threadActorID != thread.actor + ), + }; + + case "UPDATE_SERVICE_WORKER_STATUS": + return { + ...state, + threads: state.threads.map(t => { + if (t.actor == action.thread) { + return { ...t, serviceWorkerStatus: action.status }; + } + return t; + }), + }; + + case "TRACING_TOGGLED": + const { mutableTracingThreads } = state; + const sizeBefore = mutableTracingThreads.size; + if (action.enabled) { + mutableTracingThreads.add(action.thread); + } else { + mutableTracingThreads.delete(action.thread); + } + // We may receive toggle events when we change the logging method + // while we are already tracing, but the list of tracing thread stays the same. + const changed = mutableTracingThreads.size != sizeBefore; + if (changed) { + return { + ...state, + mutableTracingThreads, + }; + } + return state; + + default: + return state; + } +} diff --git a/devtools/client/debugger/src/reducers/ui.js b/devtools/client/debugger/src/reducers/ui.js new file mode 100644 index 0000000000..192d2c9751 --- /dev/null +++ b/devtools/client/debugger/src/reducers/ui.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 complexity: ["error", 35]*/ + +/** + * UI reducer + * @module reducers/ui + */ + +import { prefs, features } from "../utils/prefs"; +import { searchKeys } from "../constants"; + +export const initialUIState = () => ({ + selectedPrimaryPaneTab: "sources", + activeSearch: null, + startPanelCollapsed: prefs.startPanelCollapsed, + endPanelCollapsed: prefs.endPanelCollapsed, + frameworkGroupingOn: prefs.frameworkGroupingOn, + + // This is used from Outline's copy to clipboard context menu + // and QuickOpen to highlight lines temporarily. + // If defined, it will be an object with following attributes: + // - sourceId, String + // - start, Number, start line to highlight, 1-based + // - end, Number, end line to highlight, 1-based + highlightedLineRange: null, + + conditionalPanelLocation: null, + isLogPoint: false, + orientation: "horizontal", + viewport: null, + cursorPosition: null, + inlinePreviewEnabled: features.inlinePreview, + editorWrappingEnabled: prefs.editorWrapping, + javascriptEnabled: true, + javascriptTracingLogMethod: prefs.javascriptTracingLogMethod, + mutableSearchOptions: prefs.searchOptions || { + [searchKeys.FILE_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + [searchKeys.PROJECT_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + [searchKeys.QUICKOPEN_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + }, + hideIgnoredSources: prefs.hideIgnoredSources, + sourceMapIgnoreListEnabled: prefs.sourceMapIgnoreListEnabled, +}); + +function update(state = initialUIState(), action) { + switch (action.type) { + case "TOGGLE_ACTIVE_SEARCH": { + return { ...state, activeSearch: action.value }; + } + + case "TOGGLE_FRAMEWORK_GROUPING": { + prefs.frameworkGroupingOn = action.value; + return { ...state, frameworkGroupingOn: action.value }; + } + + case "TOGGLE_INLINE_PREVIEW": { + features.inlinePreview = action.value; + return { ...state, inlinePreviewEnabled: action.value }; + } + + case "TOGGLE_EDITOR_WRAPPING": { + prefs.editorWrapping = action.value; + return { ...state, editorWrappingEnabled: action.value }; + } + + case "TOGGLE_JAVASCRIPT_ENABLED": { + return { ...state, javascriptEnabled: action.value }; + } + + case "TOGGLE_SOURCE_MAPS_ENABLED": { + prefs.clientSourceMapsEnabled = action.value; + return { ...state }; + } + + case "SET_ORIENTATION": { + return { ...state, orientation: action.orientation }; + } + + case "TOGGLE_PANE": { + if (action.position == "start") { + prefs.startPanelCollapsed = action.paneCollapsed; + return { ...state, startPanelCollapsed: action.paneCollapsed }; + } + + prefs.endPanelCollapsed = action.paneCollapsed; + return { ...state, endPanelCollapsed: action.paneCollapsed }; + } + + case "HIGHLIGHT_LINES": { + return { ...state, highlightedLineRange: action.location }; + } + + case "CLOSE_QUICK_OPEN": + case "CLEAR_HIGHLIGHT_LINES": + if (!state.highlightedLineRange) { + return state; + } + return { ...state, highlightedLineRange: null }; + + case "OPEN_CONDITIONAL_PANEL": + return { + ...state, + conditionalPanelLocation: action.location, + isLogPoint: action.log, + }; + + case "CLOSE_CONDITIONAL_PANEL": + return { ...state, conditionalPanelLocation: null }; + + case "SET_PRIMARY_PANE_TAB": + return { ...state, selectedPrimaryPaneTab: action.tabName }; + + case "CLOSE_PROJECT_SEARCH": { + if (state.activeSearch === "project") { + return { ...state, activeSearch: null }; + } + return state; + } + + case "SET_VIEWPORT": { + return { ...state, viewport: action.viewport }; + } + + case "SET_CURSOR_POSITION": { + return { ...state, cursorPosition: action.cursorPosition }; + } + + case "NAVIGATE": { + return { ...state, activeSearch: null, highlightedLineRange: null }; + } + + case "REMOVE_THREAD": { + // Reset the highlighted range if the related source has been removed + const sourceId = state.highlightedLineRange?.sourceId; + if (sourceId && action.sources.some(s => s.id == sourceId)) { + return { ...state, highlightedLineRange: null }; + } + return state; + } + + case "SET_JAVASCRIPT_TRACING_LOG_METHOD": { + prefs.javascriptTracingLogMethod = action.value; + return { ...state, javascriptTracingLogMethod: action.value }; + } + + case "SET_SEARCH_OPTIONS": { + state.mutableSearchOptions[action.searchKey] = { + ...state.mutableSearchOptions[action.searchKey], + ...action.searchOptions, + }; + prefs.searchOptions = state.mutableSearchOptions; + return { ...state }; + } + + case "HIDE_IGNORED_SOURCES": { + const { shouldHide } = action; + if (shouldHide !== state.hideIgnoredSources) { + prefs.hideIgnoredSources = shouldHide; + return { ...state, hideIgnoredSources: shouldHide }; + } + return state; + } + + case "ENABLE_SOURCEMAP_IGNORELIST": { + const { shouldEnable } = action; + if (shouldEnable !== state.sourceMapIgnoreListEnabled) { + prefs.sourceMapIgnoreListEnabled = shouldEnable; + return { ...state, sourceMapIgnoreListEnabled: shouldEnable }; + } + return state; + } + + default: { + return state; + } + } +} + +export default update; diff --git a/devtools/client/debugger/src/selectors/ast.js b/devtools/client/debugger/src/selectors/ast.js new file mode 100644 index 0000000000..f3384fdc58 --- /dev/null +++ b/devtools/client/debugger/src/selectors/ast.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +export function getSymbols(state, location) { + if (!location) { + return null; + } + if (location.source.isOriginal) { + return ( + state.ast.mutableOriginalSourcesSymbols[location.source.id]?.value || null + ); + } + if (!location.sourceActor) { + throw new Error( + "Expects a location with a source actor when passing non-original sources to getSymbols" + ); + } + return ( + state.ast.mutableSourceActorSymbols[location.sourceActor.id]?.value || null + ); +} + +export function getInScopeLines(state, location) { + return state.ast.mutableInScopeLines[makeBreakpointId(location)]?.lines; +} + +export function hasInScopeLines(state, location) { + return !!getInScopeLines(state, location); +} diff --git a/devtools/client/debugger/src/selectors/breakpointAtLocation.js b/devtools/client/debugger/src/selectors/breakpointAtLocation.js new file mode 100644 index 0000000000..c661894dbb --- /dev/null +++ b/devtools/client/debugger/src/selectors/breakpointAtLocation.js @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSelectedSource, getBreakpointPositionsForLine } from "./sources"; +import { getBreakpointsList } from "./breakpoints"; +import { isGenerated } from "../utils/source"; + +function getColumn(column, selectedSource) { + if (column) { + return column; + } + + return isGenerated(selectedSource) ? undefined : 0; +} + +function getLocation(bp, selectedSource) { + return isGenerated(selectedSource) + ? bp.generatedLocation || bp.location + : bp.location; +} + +function getBreakpointsForSource(state, selectedSource) { + const breakpoints = getBreakpointsList(state); + + return breakpoints.filter(bp => { + const location = getLocation(bp, selectedSource); + return location.sourceId === selectedSource.id; + }); +} + +function findBreakpointAtLocation( + breakpoints, + selectedSource, + { line, column } +) { + return breakpoints.find(breakpoint => { + const location = getLocation(breakpoint, selectedSource); + const sameLine = location.line === line; + if (!sameLine) { + return false; + } + + if (column === undefined) { + return true; + } + + return location.column === getColumn(column, selectedSource); + }); +} + +// returns the closest active column breakpoint +function findClosestBreakpoint(breakpoints, column) { + if (!breakpoints || !breakpoints.length) { + return null; + } + + const firstBreakpoint = breakpoints[0]; + return breakpoints.reduce((closestBp, currentBp) => { + const currentColumn = currentBp.generatedLocation.column; + const closestColumn = closestBp.generatedLocation.column; + // check that breakpoint has a column. + if (column && currentColumn && closestColumn) { + const currentDistance = Math.abs(currentColumn - column); + const closestDistance = Math.abs(closestColumn - column); + + return currentDistance < closestDistance ? currentBp : closestBp; + } + return closestBp; + }, firstBreakpoint); +} + +/* + * Finds a breakpoint at a location (line, column) of the + * selected source. + * + * This is useful for finding a breakpoint when the + * user clicks in the gutter or on a token. + */ +export function getBreakpointAtLocation(state, location) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + throw new Error("no selectedSource"); + } + const breakpoints = getBreakpointsForSource(state, selectedSource); + + return findBreakpointAtLocation(breakpoints, selectedSource, location); +} + +export function getBreakpointsAtLine(state, line) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + throw new Error("no selectedSource"); + } + const breakpoints = getBreakpointsForSource(state, selectedSource); + + return breakpoints.filter( + breakpoint => getLocation(breakpoint, selectedSource).line === line + ); +} + +export function getClosestBreakpoint(state, position) { + const columnBreakpoints = getBreakpointsAtLine(state, position.line); + const breakpoint = findClosestBreakpoint(columnBreakpoints, position.column); + return breakpoint; +} + +export function getClosestBreakpointPosition(state, position) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + throw new Error("no selectedSource"); + } + + const columnBreakpoints = getBreakpointPositionsForLine( + state, + selectedSource.id, + position.line + ); + + return findClosestBreakpoint(columnBreakpoints, position.column); +} diff --git a/devtools/client/debugger/src/selectors/breakpointSources.js b/devtools/client/debugger/src/selectors/breakpointSources.js new file mode 100644 index 0000000000..6de2772521 --- /dev/null +++ b/devtools/client/debugger/src/selectors/breakpointSources.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createSelector } from "reselect"; +import { getSelectedSource } from "./sources"; +import { getBreakpointsList } from "./breakpoints"; +import { getFilename } from "../utils/source"; +import { getSelectedLocation } from "../utils/selected-location"; + +// Returns a list of sources with their related breakpoints: +// [{ source, breakpoints: [breakpoint1, ...] }, ...] +// +// This only returns sources for which we have a visible breakpoint. +// This will return either generated or original source based on the currently +// selected source. +export const getBreakpointSources = createSelector( + getBreakpointsList, + getSelectedSource, + (breakpoints, selectedSource) => { + const visibleBreakpoints = breakpoints.filter( + bp => + !bp.options.hidden && + (bp.text || bp.originalText || bp.options.condition || bp.disabled) + ); + + const sources = new Map(); + for (const breakpoint of visibleBreakpoints) { + // Depending on the selected source, this will match the original or generated + // location of the given selected source. + const location = getSelectedLocation(breakpoint, selectedSource); + const { source } = location; + + // We may have more than one breakpoint per source, + // so use the map to have a unique entry per source. + if (!sources.has(source)) { + sources.set(source, { + source, + breakpoints: [breakpoint], + filename: getFilename(source), + }); + } else { + sources.get(source).breakpoints.push(breakpoint); + } + } + + // Returns an array of breakpoints info per source, sorted by source's filename + return [...sources.values()].sort((a, b) => + a.filename.localeCompare(b.filename) + ); + } +); diff --git a/devtools/client/debugger/src/selectors/breakpoints.js b/devtools/client/debugger/src/selectors/breakpoints.js new file mode 100644 index 0000000000..38b39f71f3 --- /dev/null +++ b/devtools/client/debugger/src/selectors/breakpoints.js @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createSelector } from "reselect"; + +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { makeBreakpointId } from "../utils/breakpoint"; + +// This method is only used from the main test helper +export function getBreakpointsMap(state) { + return state.breakpoints.breakpoints; +} + +export const getBreakpointsList = createSelector( + state => state.breakpoints.breakpoints, + breakpoints => Object.values(breakpoints) +); + +export function getBreakpointCount(state) { + return getBreakpointsList(state).length; +} + +export function getBreakpoint(state, location) { + if (!location) { + return undefined; + } + + const breakpoints = getBreakpointsMap(state); + return breakpoints[makeBreakpointId(location)]; +} + +/** + * Gets the breakpoints on a line or within a range of lines + * @param {Object} state + * @param {Number} sourceId + * @param {Number|Object} lines - line or an object with a start and end range of lines + * @returns {Array} breakpoints + */ +export function getBreakpointsForSource(state, sourceId, lines) { + if (!sourceId) { + return []; + } + + const isGeneratedSource = isGeneratedId(sourceId); + const breakpoints = getBreakpointsList(state); + return breakpoints.filter(bp => { + const location = isGeneratedSource ? bp.generatedLocation : bp.location; + + if (lines) { + const isOnLineOrWithinRange = + typeof lines == "number" + ? location.line == lines + : location.line >= lines.start.line && + location.line <= lines.end.line; + return location.sourceId === sourceId && isOnLineOrWithinRange; + } + return location.sourceId === sourceId; + }); +} + +export function getHiddenBreakpoint(state) { + const breakpoints = getBreakpointsList(state); + return breakpoints.find(bp => bp.options.hidden); +} + +export function hasLogpoint(state, location) { + const breakpoint = getBreakpoint(state, location); + return breakpoint?.options.logValue; +} + +export function getXHRBreakpoints(state) { + return state.breakpoints.xhrBreakpoints; +} + +export const shouldPauseOnAnyXHR = createSelector( + getXHRBreakpoints, + xhrBreakpoints => { + const emptyBp = xhrBreakpoints.find(({ path }) => path.length === 0); + if (!emptyBp) { + return false; + } + + return !emptyBp.disabled; + } +); diff --git a/devtools/client/debugger/src/selectors/event-listeners.js b/devtools/client/debugger/src/selectors/event-listeners.js new file mode 100644 index 0000000000..dcbcd8109f --- /dev/null +++ b/devtools/client/debugger/src/selectors/event-listeners.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getActiveEventListeners(state) { + return state.eventListenerBreakpoints.active; +} + +export function getEventListenerBreakpointTypes(state) { + return state.eventListenerBreakpoints.categories; +} + +export function getEventListenerExpanded(state) { + return state.eventListenerBreakpoints.expanded; +} + +export function shouldLogEventBreakpoints(state) { + return state.eventListenerBreakpoints.logEventBreakpoints; +} diff --git a/devtools/client/debugger/src/selectors/exceptions.js b/devtools/client/debugger/src/selectors/exceptions.js new file mode 100644 index 0000000000..30230706cd --- /dev/null +++ b/devtools/client/debugger/src/selectors/exceptions.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createSelector } from "reselect"; +import { shallowEqual, arrayShallowEqual } from "../utils/shallow-equal"; + +import { getSelectedSource, getSourceActorsForSource } from "./"; + +export const getSelectedSourceExceptions = createSelector( + getSelectedSourceActors, + // Do not retrieve mutableExceptionsMap as it will never change and createSelector would + // prevent re-running the selector in case of modification. state.exception is the `state` + // in the reducer, which we take care of cloning in case of new exception. + state => state.exceptions, + (sourceActors, exceptionsState) => { + const { mutableExceptionsMap } = exceptionsState; + const sourceExceptions = []; + + for (const sourceActor of sourceActors) { + const exceptions = mutableExceptionsMap.get(sourceActor.id); + if (exceptions) { + sourceExceptions.push(...exceptions); + } + } + + return sourceExceptions; + }, + // Shallow compare both input and output because of arrays being possibly always + // different instance but with same content. + { + memoizeOptions: { + equalityCheck: shallowEqual, + resultEqualityCheck: arrayShallowEqual, + }, + } +); + +function getSelectedSourceActors(state) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + return []; + } + return getSourceActorsForSource(state, selectedSource.id); +} + +export function getSelectedException(state, line, column) { + const sourceExceptions = getSelectedSourceExceptions(state); + + if (!sourceExceptions) { + return undefined; + } + + return sourceExceptions.find( + sourceExc => + sourceExc.lineNumber === line && sourceExc.columnNumber === column + ); +} diff --git a/devtools/client/debugger/src/selectors/expressions.js b/devtools/client/debugger/src/selectors/expressions.js new file mode 100644 index 0000000000..6cbe829943 --- /dev/null +++ b/devtools/client/debugger/src/selectors/expressions.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createSelector } from "reselect"; + +const getExpressionsWrapper = state => state.expressions; + +export const getExpressions = createSelector( + getExpressionsWrapper, + expressions => expressions.expressions +); + +const getAutocompleteMatches = createSelector( + getExpressionsWrapper, + expressions => expressions.autocompleteMatches +); + +export function getExpression(state, input) { + return getExpressions(state).find(exp => exp.input == input); +} + +export function getAutocompleteMatchset(state) { + const input = state.expressions.currentAutocompleteInput; + if (!input) { + return null; + } + return getAutocompleteMatches(state)[input]; +} + +export const getExpressionError = createSelector( + getExpressionsWrapper, + expressions => expressions.expressionError +); diff --git a/devtools/client/debugger/src/selectors/getCallStackFrames.js b/devtools/client/debugger/src/selectors/getCallStackFrames.js new file mode 100644 index 0000000000..558e07aa17 --- /dev/null +++ b/devtools/client/debugger/src/selectors/getCallStackFrames.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSelectedSource } from "./sources"; +import { getBlackBoxRanges } from "./source-blackbox"; +import { getCurrentThreadFrames } from "./pause"; +import { annotateFrames } from "../utils/pause/frames"; +import { isFrameBlackBoxed } from "../utils/source"; +import { createSelector } from "reselect"; + +function getLocation(frame, isGeneratedSource) { + return isGeneratedSource + ? frame.generatedLocation || frame.location + : frame.location; +} + +function getSourceForFrame(frame, isGeneratedSource) { + return getLocation(frame, isGeneratedSource).source; +} + +function appendSource(frame, selectedSource) { + const isGeneratedSource = selectedSource && !selectedSource.isOriginal; + return { + ...frame, + location: getLocation(frame, isGeneratedSource), + source: getSourceForFrame(frame, isGeneratedSource), + }; +} + +export function formatCallStackFrames( + frames, + selectedSource, + blackboxedRanges +) { + if (!frames) { + return null; + } + + const formattedFrames = frames + .filter(frame => getSourceForFrame(frame)) + .map(frame => appendSource(frame, selectedSource)) + .filter(frame => !isFrameBlackBoxed(frame, blackboxedRanges)); + + return annotateFrames(formattedFrames); +} + +export const getCallStackFrames = createSelector( + getCurrentThreadFrames, + getSelectedSource, + getBlackBoxRanges, + formatCallStackFrames +); diff --git a/devtools/client/debugger/src/selectors/index.js b/devtools/client/debugger/src/selectors/index.js new file mode 100644 index 0000000000..66220ec101 --- /dev/null +++ b/devtools/client/debugger/src/selectors/index.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./ast"; +export * from "./breakpoints"; +export { + getClosestBreakpoint, + getBreakpointAtLocation, + getBreakpointsAtLine, + getClosestBreakpointPosition, +} from "./breakpointAtLocation"; +export { getBreakpointSources } from "./breakpointSources"; +export * from "./event-listeners"; +export * from "./exceptions"; +export * from "./expressions"; +export { getCallStackFrames } from "./getCallStackFrames"; +export { isLineInScope } from "./isLineInScope"; +export { isSelectedFrameVisible } from "./isSelectedFrameVisible"; +export * from "./pause"; +export * from "./pending-breakpoints"; +export * from "./preview"; +export * from "./project-text-search"; +export * from "./quick-open"; +export * from "./source-actors"; +export * from "./source-blackbox"; +export * from "./sources-content"; +export * from "./sources-tree"; +export * from "./sources"; +export * from "./tabs"; +export * from "./threads"; +export * from "./ui"; +export { + getVisibleBreakpoints, + getFirstVisibleBreakpoints, +} from "./visibleBreakpoints"; +export * from "./visibleColumnBreakpoints"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +const { reducer } = objectInspector; + +Object.keys(reducer).forEach(function (key) { + if (key === "default" || key === "__esModule") { + return; + } + Object.defineProperty(exports, key, { + enumerable: true, + get: reducer[key], + }); +}); diff --git a/devtools/client/debugger/src/selectors/isLineInScope.js b/devtools/client/debugger/src/selectors/isLineInScope.js new file mode 100644 index 0000000000..f8ca089b81 --- /dev/null +++ b/devtools/client/debugger/src/selectors/isLineInScope.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 { getInScopeLines } from "./ast"; +import { getVisibleSelectedFrame } from "./pause"; + +// Checks if a line is considered in scope +// We consider all lines in scope, if we do not have lines in scope. +export function isLineInScope(state, line) { + const frame = getVisibleSelectedFrame(state); + if (!frame) { + return false; + } + + const lines = getInScopeLines(state, frame.location); + if (!lines) { + return true; + } + + return lines.includes(line); +} diff --git a/devtools/client/debugger/src/selectors/isSelectedFrameVisible.js b/devtools/client/debugger/src/selectors/isSelectedFrameVisible.js new file mode 100644 index 0000000000..bd0dc7a456 --- /dev/null +++ b/devtools/client/debugger/src/selectors/isSelectedFrameVisible.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 { + originalToGeneratedId, + isOriginalId, +} from "devtools/client/shared/source-map-loader/index"; +import { getSelectedFrame, getSelectedLocation, getCurrentThread } from "."; + +function getGeneratedId(sourceId) { + if (isOriginalId(sourceId)) { + return originalToGeneratedId(sourceId); + } + + return sourceId; +} + +/* + * Checks to if the selected frame's source is currently + * selected. + */ +export function isSelectedFrameVisible(state) { + const thread = getCurrentThread(state); + const selectedLocation = getSelectedLocation(state); + const selectedFrame = getSelectedFrame(state, thread); + + if (!selectedFrame || !selectedLocation) { + return false; + } + + if (isOriginalId(selectedLocation.sourceId)) { + return selectedLocation.sourceId === selectedFrame.location.sourceId; + } + + return ( + selectedLocation.sourceId === + getGeneratedId(selectedFrame.location.sourceId) + ); +} diff --git a/devtools/client/debugger/src/selectors/moz.build b/devtools/client/debugger/src/selectors/moz.build new file mode 100644 index 0000000000..e58b638f0f --- /dev/null +++ b/devtools/client/debugger/src/selectors/moz.build @@ -0,0 +1,35 @@ +# 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( + "ast.js", + "breakpointAtLocation.js", + "breakpoints.js", + "breakpointSources.js", + "event-listeners.js", + "exceptions.js", + "expressions.js", + "getCallStackFrames.js", + "index.js", + "isLineInScope.js", + "isSelectedFrameVisible.js", + "pause.js", + "pending-breakpoints.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "source-blackbox.js", + "sources-tree.js", + "sources-content.js", + "sources.js", + "tabs.js", + "threads.js", + "visibleBreakpoints.js", + "visibleColumnBreakpoints.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/selectors/pause.js b/devtools/client/debugger/src/selectors/pause.js new file mode 100644 index 0000000000..61900e9f8c --- /dev/null +++ b/devtools/client/debugger/src/selectors/pause.js @@ -0,0 +1,267 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { getThreadPauseState } from "../reducers/pause"; +import { getSelectedSourceId, getSelectedLocation } from "./sources"; + +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; + +// eslint-disable-next-line +import { getSelectedLocation as _getSelectedLocation } from "../utils/selected-location"; +import { createSelector } from "reselect"; + +export const getSelectedFrame = createSelector( + (state, thread) => state.pause.threads[thread], + threadPauseState => { + if (!threadPauseState) return null; + const { selectedFrameId, frames } = threadPauseState; + if (frames) { + return frames.find(frame => frame.id == selectedFrameId); + } + return null; + } +); + +export const getVisibleSelectedFrame = createSelector( + getSelectedLocation, + state => getSelectedFrame(state, getCurrentThread(state)), + (selectedLocation, selectedFrame) => { + if (!selectedFrame) { + return null; + } + + const { id, displayName } = selectedFrame; + + return { + id, + displayName, + location: _getSelectedLocation(selectedFrame, selectedLocation), + }; + } +); + +export function getContext(state) { + return state.pause.cx; +} + +export function getThreadContext(state) { + return state.pause.threadcx; +} + +export function getPauseReason(state, thread) { + return getThreadPauseState(state.pause, thread).why; +} + +export function getShouldBreakpointsPaneOpenOnPause(state, thread) { + return getThreadPauseState(state.pause, thread) + .shouldBreakpointsPaneOpenOnPause; +} + +export function getPauseCommand(state, thread) { + return getThreadPauseState(state.pause, thread).command; +} + +export function isStepping(state, thread) { + return ["stepIn", "stepOver", "stepOut"].includes( + getPauseCommand(state, thread) + ); +} + +export function getCurrentThread(state) { + return getThreadContext(state).thread; +} + +export function getIsPaused(state, thread) { + return getThreadPauseState(state.pause, thread).isPaused; +} + +export function getIsCurrentThreadPaused(state) { + return getIsPaused(state, getCurrentThread(state)); +} + +export function isEvaluatingExpression(state, thread) { + return getThreadPauseState(state.pause, thread).command === "expression"; +} + +export function getIsWaitingOnBreak(state, thread) { + return getThreadPauseState(state.pause, thread).isWaitingOnBreak; +} + +export function getShouldPauseOnExceptions(state) { + return state.pause.shouldPauseOnExceptions; +} + +export function getShouldPauseOnCaughtExceptions(state) { + return state.pause.shouldPauseOnCaughtExceptions; +} + +export function getFrames(state, thread) { + const { frames, framesLoading } = getThreadPauseState(state.pause, thread); + return framesLoading ? null : frames; +} + +export function getCurrentThreadFrames(state) { + const { frames, framesLoading } = getThreadPauseState( + state.pause, + getCurrentThread(state) + ); + return framesLoading ? null : frames; +} + +function getGeneratedFrameId(frameId) { + if (frameId.includes("-originalFrame")) { + // The mapFrames can add original stack frames -- get generated frameId. + return frameId.substr(0, frameId.lastIndexOf("-originalFrame")); + } + return frameId; +} + +export function getGeneratedFrameScope(state, thread, frameId) { + if (!frameId) { + return null; + } + + return getFrameScopes(state, thread).generated[getGeneratedFrameId(frameId)]; +} + +export function getOriginalFrameScope(state, thread, sourceId, frameId) { + if (!frameId || !sourceId) { + return null; + } + + const isGenerated = isGeneratedId(sourceId); + const original = getFrameScopes(state, thread).original[ + getGeneratedFrameId(frameId) + ]; + + if (!isGenerated && original && (original.pending || original.scope)) { + return original; + } + + return null; +} + +// This is only used by tests +export function getFrameScopes(state, thread) { + return getThreadPauseState(state.pause, thread).frameScopes; +} + +export function getSelectedFrameBindings(state, thread) { + const scopes = getFrameScopes(state, thread); + const selectedFrameId = getSelectedFrameId(state, thread); + if (!scopes || !selectedFrameId) { + return null; + } + + const frameScope = scopes.generated[selectedFrameId]; + if (!frameScope || frameScope.pending) { + return null; + } + + let currentScope = frameScope.scope; + let frameBindings = []; + while (currentScope && currentScope.type != "object") { + if (currentScope.bindings) { + const bindings = Object.keys(currentScope.bindings.variables); + const args = [].concat( + ...currentScope.bindings.arguments.map(argument => + Object.keys(argument) + ) + ); + + frameBindings = [...frameBindings, ...bindings, ...args]; + } + currentScope = currentScope.parent; + } + + return frameBindings; +} + +function getFrameScope(state, thread, sourceId, frameId) { + return ( + getOriginalFrameScope(state, thread, sourceId, frameId) || + getGeneratedFrameScope(state, thread, frameId) + ); +} + +// This is only used by tests +export function getSelectedScope(state, thread) { + const sourceId = getSelectedSourceId(state); + const frameId = getSelectedFrameId(state, thread); + + const frameScope = getFrameScope(state, thread, sourceId, frameId); + if (!frameScope) { + return null; + } + + return frameScope.scope || null; +} + +export function getSelectedOriginalScope(state, thread) { + const sourceId = getSelectedSourceId(state); + const frameId = getSelectedFrameId(state, thread); + return getOriginalFrameScope(state, thread, sourceId, frameId); +} + +export function getSelectedGeneratedScope(state, thread) { + const frameId = getSelectedFrameId(state, thread); + return getGeneratedFrameScope(state, thread, frameId); +} + +export function getSelectedScopeMappings(state, thread) { + const frameId = getSelectedFrameId(state, thread); + if (!frameId) { + return null; + } + + return getFrameScopes(state, thread).mappings[frameId]; +} + +export function getSelectedFrameId(state, thread) { + return getThreadPauseState(state.pause, thread).selectedFrameId; +} + +export function isTopFrameSelected(state, thread) { + const selectedFrameId = getSelectedFrameId(state, thread); + const topFrame = getTopFrame(state, thread); + return selectedFrameId == topFrame?.id; +} + +export function getTopFrame(state, thread) { + const frames = getFrames(state, thread); + return frames?.[0]; +} + +export function getSkipPausing(state) { + return state.pause.skipPausing; +} + +export function getHighlightedCalls(state, thread) { + return getThreadPauseState(state.pause, thread).highlightedCalls; +} + +export function isMapScopesEnabled(state) { + return state.pause.mapScopes; +} + +export function getInlinePreviews(state, thread, frameId) { + return getThreadPauseState(state.pause, thread).inlinePreview[ + getGeneratedFrameId(frameId) + ]; +} + +// This is only used by tests +export function getSelectedInlinePreviews(state) { + const thread = getCurrentThread(state); + const frameId = getSelectedFrameId(state, thread); + if (!frameId) { + return null; + } + + return getInlinePreviews(state, thread, frameId); +} + +export function getLastExpandedScopes(state, thread) { + return getThreadPauseState(state.pause, thread).lastExpandedScopes; +} diff --git a/devtools/client/debugger/src/selectors/pending-breakpoints.js b/devtools/client/debugger/src/selectors/pending-breakpoints.js new file mode 100644 index 0000000000..a05c43477d --- /dev/null +++ b/devtools/client/debugger/src/selectors/pending-breakpoints.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getPendingBreakpoints(state) { + return state.pendingBreakpoints; +} + +export function getPendingBreakpointList(state) { + return Object.values(getPendingBreakpoints(state)); +} + +export function getPendingBreakpointsForSource(state, source) { + return getPendingBreakpointList(state).filter(pendingBreakpoint => { + return ( + pendingBreakpoint.location.sourceUrl === source.url || + pendingBreakpoint.generatedLocation.sourceUrl == source.url + ); + }); +} diff --git a/devtools/client/debugger/src/selectors/preview.js b/devtools/client/debugger/src/selectors/preview.js new file mode 100644 index 0000000000..adfee09002 --- /dev/null +++ b/devtools/client/debugger/src/selectors/preview.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getPreview(state) { + return state.preview.preview; +} + +export function getPreviewCount(state) { + return state.preview.previewCount; +} diff --git a/devtools/client/debugger/src/selectors/project-text-search.js b/devtools/client/debugger/src/selectors/project-text-search.js new file mode 100644 index 0000000000..3679fd931f --- /dev/null +++ b/devtools/client/debugger/src/selectors/project-text-search.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getProjectSearchOperation(state) { + return state.projectTextSearch.ongoingSearch; +} + +export function getProjectSearchResults(state) { + return state.projectTextSearch.results; +} + +export function getProjectSearchStatus(state) { + return state.projectTextSearch.status; +} + +export function getProjectSearchQuery(state) { + return state.projectTextSearch.query; +} diff --git a/devtools/client/debugger/src/selectors/quick-open.js b/devtools/client/debugger/src/selectors/quick-open.js new file mode 100644 index 0000000000..8364c7bbf3 --- /dev/null +++ b/devtools/client/debugger/src/selectors/quick-open.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 getQuickOpenEnabled(state) { + return state.quickOpen.enabled; +} + +export function getQuickOpenQuery(state) { + return state.quickOpen.query; +} + +export function getQuickOpenType(state) { + return state.quickOpen.searchType; +} diff --git a/devtools/client/debugger/src/selectors/source-actors.js b/devtools/client/debugger/src/selectors/source-actors.js new file mode 100644 index 0000000000..4d7f915da2 --- /dev/null +++ b/devtools/client/debugger/src/selectors/source-actors.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Tells if a given Source Actor is registered in the redux store + * + * @param {Object} state + * @param {String} sourceActorId + * Source Actor ID + * @return {Boolean} + */ +export function hasSourceActor(state, sourceActorId) { + return state.sourceActors.mutableSourceActors.has(sourceActorId); +} + +/** + * Get the Source Actor object. See create.js:createSourceActor() + * + * @param {Object} state + * @param {String} sourceActorId + * Source Actor ID + * @return {Object} + * The Source Actor object (if registered) + */ +export function getSourceActor(state, sourceActorId) { + return state.sourceActors.mutableSourceActors.get(sourceActorId); +} + +/** + * Reports if the Source Actor relates to a valid source map / original source. + * + * @param {Object} state + * @param {String} sourceActorId + * Source Actor ID + * @return {Boolean} + * True if it has a valid source map/original object. + */ +export function isSourceActorWithSourceMap(state, sourceActorId) { + return state.sourceActors.mutableSourceActorsWithSourceMap.has(sourceActorId); +} + +// Used by threads selectors +/** + * Get all Source Actor objects for a given thread. See create.js:createSourceActor() + * + * @param {Object} state + * @param {Array<String>} threadActorIDs + * List of Thread IDs + * @return {Array<Object>} + */ +export function getSourceActorsForThread(state, threadActorIDs) { + if (!Array.isArray(threadActorIDs)) { + threadActorIDs = [threadActorIDs]; + } + const actors = []; + for (const sourceActor of state.sourceActors.mutableSourceActors.values()) { + if (threadActorIDs.includes(sourceActor.thread)) { + actors.push(sourceActor); + } + } + return actors; +} + +/** + * Get the list of all breakable lines for a given source actor. + * + * @param {Object} state + * @param {String} sourceActorId + * Source Actor ID + * @return {AsyncValue<Array<Number>>} + * List of all the breakable lines. + */ +export function getSourceActorBreakableLines(state, sourceActorId) { + return state.sourceActors.mutableBreakableLines.get(sourceActorId); +} + +// Used by sources selectors +/** + * Get the list of all breakable lines for a set of source actors. + * + * This is typically used to fetch the breakable lines of HTML sources + * which are made of multiple source actors (one per inline script). + * + * @param {Object} state + * @param {Array<String>} sourceActors + * List of Source Actors + * @param {Boolean} isHTML + * True, if we are fetching the breakable lines for an HTML source. + * For them, we have to aggregate the lines of each source actors. + * Otherwise, we might still have many source actors, but one per thread. + * In this case, we simply return the first source actor to have the lines ready. + * @return {Array<Number>} + * List of all the breakable lines. + */ +export function getBreakableLinesForSourceActors(state, sourceActors, isHTML) { + const allBreakableLines = []; + for (const sourceActor of sourceActors) { + const breakableLines = state.sourceActors.mutableBreakableLines.get( + sourceActor.id + ); + if (breakableLines) { + if (isHTML) { + allBreakableLines.push(...breakableLines); + } else { + return breakableLines; + } + } + } + return allBreakableLines; +} diff --git a/devtools/client/debugger/src/selectors/source-blackbox.js b/devtools/client/debugger/src/selectors/source-blackbox.js new file mode 100644 index 0000000000..afd5695f18 --- /dev/null +++ b/devtools/client/debugger/src/selectors/source-blackbox.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getBlackBoxRanges(state) { + return state.sourceBlackBox.blackboxedRanges; +} + +export function isSourceBlackBoxed(state, source) { + // Only sources with a URL can be blackboxed. + if (!source.url) { + return false; + } + return state.sourceBlackBox.blackboxedSet.has(source.url); +} + +export function isSourceOnSourceMapIgnoreList(state, source) { + if (!source) { + return false; + } + return getIgnoreListSourceUrls(state).includes(source.url); +} + +export function getIgnoreListSourceUrls(state) { + return state.sourceBlackBox.sourceMapIgnoreListUrls; +} diff --git a/devtools/client/debugger/src/selectors/sources-content.js b/devtools/client/debugger/src/selectors/sources-content.js new file mode 100644 index 0000000000..b7442fb555 --- /dev/null +++ b/devtools/client/debugger/src/selectors/sources-content.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 { asSettled } from "../utils/async-value"; + +import { + getSelectedLocation, + getFirstSourceActorForGeneratedSource, +} from "../selectors/sources"; + +export function getSourceTextContent(state, location) { + if (location.source.isOriginal) { + return state.sourcesContent.mutableOriginalSourceTextContentMapBySourceId.get( + location.source.id + ); + } + + let { sourceActor } = location; + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + state, + location.source.id + ); + } + return state.sourcesContent.mutableGeneratedSourceTextContentMapBySourceActorId.get( + sourceActor.id + ); +} + +export function getSettledSourceTextContent(state, location) { + const content = getSourceTextContent(state, location); + return asSettled(content); +} + +export function getSelectedSourceTextContent(state) { + const location = getSelectedLocation(state); + + if (!location) { + return null; + } + + return getSourceTextContent(state, location); +} + +export function getSourcesEpoch(state) { + return state.sourcesContent.epoch; +} diff --git a/devtools/client/debugger/src/selectors/sources-tree.js b/devtools/client/debugger/src/selectors/sources-tree.js new file mode 100644 index 0000000000..8ef67c93ba --- /dev/null +++ b/devtools/client/debugger/src/selectors/sources-tree.js @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createSelector } from "reselect"; + +/** + * Main selector to build the SourceTree, + * but this is also the source of data for the QuickOpen dialog. + * + * If no project directory root is set, this will return the thread items. + * Otherwise this will return the items where we set the directory root. + */ +export const getSourcesTreeSources = createSelector( + getProjectDirectoryRoot, + state => state.sourcesTree.threadItems, + (projectDirectoryRoot, threadItems) => { + // Only accept thread which have their thread attribute set. + // This may come late, if we receive ADD_SOURCES before INSERT_THREAD. + // Also filter out threads which have no sources, in case we had + // INSERT_THREAD with no ADD_SOURCES. + threadItems = threadItems.filter( + item => !!item.thread && !!item.children.length + ); + + if (projectDirectoryRoot) { + const directory = getDirectoryForUniquePath( + projectDirectoryRoot, + threadItems + ); + if (directory) { + return directory.children; + } + return []; + } + + return threadItems; + } +); + +// This is used by QuickOpen UI +/** + * Main selector for the QuickOpen dialog. + * + * The returns the list of all the reducer's source objects + * that are possibly displayed in the Source Tree. + * This doesn't return Source Tree Items, but the source objects. + */ +export const getDisplayedSourcesList = createSelector( + getSourcesTreeSources, + roots => { + const sources = []; + function walk(item) { + if (item.type == "source") { + sources.push(item.source); + } else { + for (const child of item.children) { + walk(child); + } + } + } + for (const root of roots) { + walk(root); + } + return sources; + } +); + +export function getExpandedState(state) { + return state.sourcesTree.expanded; +} + +export function getFocusedSourceItem(state) { + return state.sourcesTree.focusedItem; +} + +export function getProjectDirectoryRoot(state) { + return state.sourcesTree.projectDirectoryRoot; +} + +export function getProjectDirectoryRootName(state) { + return state.sourcesTree.projectDirectoryRootName; +} + +/** + * Lookup for project root item, matching the given "unique path". + */ +function getDirectoryForUniquePath(projectRoot, threadItems) { + const sections = projectRoot.split("|"); + const thread = sections.shift(); + + const threadItem = threadItems.find(item => { + return ( + item.uniquePath == thread || + (thread == "top-level" && item.thread.isTopLevel) + ); + }); + if (!threadItem) { + dump( + `No thread item for: ${projectRoot} -- ${thread} -- ${Object.keys( + threadItems + )}\n` + ); + return null; + } + + // If we selected a thread, the project root is for a Thread Item + // and it only contains `${thread}` + if (!sections.length) { + return threadItem; + } + + const group = sections.shift(); + for (const child of threadItem.children) { + if (child.groupName != group) { + continue; + } + // In case we picked a group, return it... + // project root looked like this `${thread}|${group}` + if (!sections.length) { + return child; + } + // ..otherwise, we picked a directory, so look for it by traversing the tree + // project root looked like this `${thread}|${group}|${directoryPath}` + const path = sections.shift(); + return findPathInDirectory(child, path); + } + dump(` Unable to find group: ${group}\n`); + return null; + + function findPathInDirectory(directory, path) { + for (const child of directory.children) { + if (child.type == "directory") { + // `path` should be the absolute path from the group/domain + if (child.path == path) { + return child; + } + // Ignore folders which doesn't match the beginning of the lookup path + if (!path.startsWith(child.path)) { + continue; + } + const match = findPathInDirectory(child, path); + if (match) { + return match; + } + } + } + dump(`Unable to find directory: ${path}\n`); + return null; + } +} diff --git a/devtools/client/debugger/src/selectors/sources.js b/devtools/client/debugger/src/selectors/sources.js new file mode 100644 index 0000000000..4d36a75865 --- /dev/null +++ b/devtools/client/debugger/src/selectors/sources.js @@ -0,0 +1,358 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createSelector } from "reselect"; + +import { + getPrettySourceURL, + isGenerated, + isPretty, + isJavaScript, +} from "../utils/source"; + +import { findPosition } from "../utils/breakpoint/breakpointPositions"; +import { isFulfilled } from "../utils/async-value"; + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { prefs } from "../utils/prefs"; + +import { + hasSourceActor, + getSourceActor, + getBreakableLinesForSourceActors, + isSourceActorWithSourceMap, +} from "./source-actors"; +import { getSourceTextContent } from "./sources-content"; + +export function hasSource(state, id) { + return state.sources.mutableSources.has(id); +} + +export function getSource(state, id) { + return state.sources.mutableSources.get(id); +} + +export function getSourceFromId(state, id) { + const source = getSource(state, id); + if (!source) { + console.warn(`source ${id} does not exist`); + } + return source; +} + +export function getSourceByActorId(state, actorId) { + if (!hasSourceActor(state, actorId)) { + return null; + } + + return getSource(state, getSourceActor(state, actorId).source); +} + +function getSourcesByURL(state, url) { + return state.sources.mutableSourcesPerUrl.get(url) || []; +} + +export function getSourceByURL(state, url) { + const foundSources = getSourcesByURL(state, url); + return foundSources[0]; +} + +// This is used by tabs selectors +export function getSpecificSourceByURL(state, url, isOriginal) { + const foundSources = getSourcesByURL(state, url); + return foundSources.find(source => source.isOriginal == isOriginal); +} + +function getOriginalSourceByURL(state, url) { + return getSpecificSourceByURL(state, url, true); +} + +export function getGeneratedSourceByURL(state, url) { + return getSpecificSourceByURL(state, url, false); +} + +export function getGeneratedSource(state, source) { + if (!source) { + return null; + } + + if (isGenerated(source)) { + return source; + } + + return getSourceFromId(state, originalToGeneratedId(source.id)); +} + +export function getPendingSelectedLocation(state) { + return state.sources.pendingSelectedLocation; +} + +export function getPrettySource(state, id) { + if (!id) { + return null; + } + + const source = getSource(state, id); + if (!source) { + return null; + } + + return getOriginalSourceByURL(state, getPrettySourceURL(source.url)); +} + +// This is only used by Project Search and tests. +export function getSourceList(state) { + return [...state.sources.mutableSources.values()]; +} + +// This is only used by tests and create.js +export function getSourceCount(state) { + return state.sources.mutableSources.size; +} + +export function getSelectedLocation(state) { + return state.sources.selectedLocation; +} + +export const getSelectedSource = createSelector( + getSelectedLocation, + selectedLocation => { + if (!selectedLocation) { + return undefined; + } + + return selectedLocation.source; + } +); + +// This is used by tests and pause reducers +export function getSelectedSourceId(state) { + const source = getSelectedSource(state); + return source?.id; +} + +export function getShouldSelectOriginalLocation(state) { + return state.sources.shouldSelectOriginalLocation; +} + +/** + * Gets the first source actor for the source and/or thread + * provided. + * + * @param {Object} state + * @param {String} sourceId + * The source used + * @param {String} [threadId] + * The thread to check, this is optional. + * @param {Object} sourceActor + * + */ +export function getFirstSourceActorForGeneratedSource( + state, + sourceId, + threadId +) { + let source = getSource(state, sourceId); + // The source may have been removed if we are being called by async code + if (!source) { + return null; + } + if (source.isOriginal) { + source = getSource(state, originalToGeneratedId(source.id)); + } + const actors = getSourceActorsForSource(state, source.id); + if (threadId) { + return actors.find(actorInfo => actorInfo.thread == threadId) || null; + } + return actors[0] || null; +} + +/** + * Get the source actor of the source + * + * @param {Object} state + * @param {String} id + * The source id + * @return {Array<Object>} + * List of source actors + */ +export function getSourceActorsForSource(state, id) { + return state.sources.mutableSourceActors.get(id) || []; +} + +export function isSourceWithMap(state, id) { + const actors = getSourceActorsForSource(state, id); + return actors.some(actor => isSourceActorWithSourceMap(state, actor.id)); +} + +export function canPrettyPrintSource(state, location) { + const { sourceId } = location; + const source = getSource(state, sourceId); + if ( + !source || + isPretty(source) || + source.isOriginal || + (prefs.clientSourceMapsEnabled && isSourceWithMap(state, sourceId)) + ) { + return false; + } + + const content = getSourceTextContent(state, location); + const sourceContent = content && isFulfilled(content) ? content.value : null; + + if ( + !sourceContent || + (!isJavaScript(source, sourceContent) && !source.isHTML) + ) { + return false; + } + + return true; +} + +export function getPrettyPrintMessage(state, location) { + const source = location.source; + if (!source) { + return L10N.getStr("sourceTabs.prettyPrint"); + } + + if (isPretty(source)) { + return L10N.getStr("sourceFooter.prettyPrint.isPrettyPrintedMessage"); + } + + if (source.isOriginal) { + return L10N.getStr("sourceFooter.prettyPrint.isOriginalMessage"); + } + + if (prefs.clientSourceMapsEnabled && isSourceWithMap(state, source.id)) { + return L10N.getStr("sourceFooter.prettyPrint.hasSourceMapMessage"); + } + + const content = getSourceTextContent(state, location); + + const sourceContent = content && isFulfilled(content) ? content.value : null; + if (!sourceContent) { + return L10N.getStr("sourceFooter.prettyPrint.noContentMessage"); + } + + if (!isJavaScript(source, sourceContent) && !source.isHTML) { + return L10N.getStr("sourceFooter.prettyPrint.isNotJavascriptMessage"); + } + + return L10N.getStr("sourceTabs.prettyPrint"); +} + +export function getBreakpointPositionsForSource(state, sourceId) { + return state.sources.mutableBreakpointPositions.get(sourceId); +} + +// This is only used by one test +export function hasBreakpointPositions(state, sourceId) { + return !!getBreakpointPositionsForSource(state, sourceId); +} + +export function getBreakpointPositionsForLine(state, sourceId, line) { + const positions = getBreakpointPositionsForSource(state, sourceId); + return positions?.[line]; +} + +export function getBreakpointPositionsForLocation(state, location) { + const { sourceId } = location; + const positions = getBreakpointPositionsForSource(state, sourceId); + return findPosition(positions, location); +} + +export function getBreakableLines(state, sourceId) { + if (!sourceId) { + return null; + } + const source = getSource(state, sourceId); + if (!source) { + return null; + } + + if (source.isOriginal) { + return state.sources.mutableOriginalBreakableLines.get(sourceId); + } + + const sourceActors = getSourceActorsForSource(state, sourceId); + if (!sourceActors.length) { + return null; + } + + // We pull generated file breakable lines directly from the source actors + // so that breakable lines can be added as new source actors on HTML loads. + return getBreakableLinesForSourceActors(state, sourceActors, source.isHTML); +} + +export const getSelectedBreakableLines = createSelector( + state => { + const sourceId = getSelectedSourceId(state); + return sourceId && getBreakableLines(state, sourceId); + }, + breakableLines => new Set(breakableLines || []) +); + +export function isSourceOverridden(state, source) { + if (!source || !source.url) { + return false; + } + return state.sources.mutableOverrideSources.has(source.url); +} + +/** + * Compute the list of source actors and source objects to be removed + * when removing a given target/thread. + * + * @param {String} threadActorID + * The thread to be removed. + * @return {Object} + * An object with two arrays: + * - actors: list of source actor objects to remove + * - sources: list of source objects to remove + */ +export function getSourcesToRemoveForThread(state, threadActorID) { + const sourcesToRemove = []; + const actorsToRemove = []; + + for (const [ + sourceId, + actorsForSource, + ] of state.sources.mutableSourceActors.entries()) { + let removedActorsCount = 0; + // Find all actors for the current source which belongs to the given thread actor + for (const actor of actorsForSource) { + if (actor.thread == threadActorID) { + actorsToRemove.push(actor); + removedActorsCount++; + } + } + + // If we are about to remove all source actors for the current source, + // or if for some unexpected reason we have a source with no actors, + // notify the caller to also remove this source. + if ( + removedActorsCount == actorsForSource.length || + !actorsForSource.length + ) { + sourcesToRemove.push(state.sources.mutableSources.get(sourceId)); + + // Also remove any original sources related to this generated source + const originalSourceIds = + state.sources.mutableOriginalSources.get(sourceId); + if (originalSourceIds?.length > 0) { + for (const originalSourceId of originalSourceIds) { + sourcesToRemove.push( + state.sources.mutableSources.get(originalSourceId) + ); + } + } + } + } + + return { + actors: actorsToRemove, + sources: sourcesToRemove, + }; +} diff --git a/devtools/client/debugger/src/selectors/tabs.js b/devtools/client/debugger/src/selectors/tabs.js new file mode 100644 index 0000000000..de2655756e --- /dev/null +++ b/devtools/client/debugger/src/selectors/tabs.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createSelector } from "reselect"; +import { getPrettySourceURL } from "../utils/source"; + +import { getSpecificSourceByURL } from "./sources"; +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +import { isSimilarTab } from "../utils/tabs"; + +export const getTabs = state => state.tabs.tabs; + +// Return the list of tabs which relates to an active source +export const getSourceTabs = createSelector(getTabs, tabs => + tabs.filter(tab => tab.source) +); + +export const getSourcesForTabs = createSelector(getSourceTabs, sourceTabs => { + return sourceTabs.map(tab => tab.source); +}); + +export function tabExists(state, sourceId) { + return !!getSourceTabs(state).find(tab => tab.source.id == sourceId); +} + +export function hasPrettyTab(state, sourceUrl) { + const prettyUrl = getPrettySourceURL(sourceUrl); + return !!getSourceTabs(state).find(tab => tab.url === prettyUrl); +} + +/** + * Gets the next tab to select when a tab closes. Heuristics: + * 1. if the selected tab is available, it remains selected + * 2. if it is gone, the next available tab to the left should be active + * 3. if the first tab is active and closed, select the second tab + */ +export function getNewSelectedSource(state, tabList) { + const { selectedLocation } = state.sources; + const availableTabs = getTabs(state); + if (!selectedLocation) { + return null; + } + + const selectedSource = selectedLocation.source; + if (!selectedSource) { + return null; + } + + const matchingTab = availableTabs.find(tab => + isSimilarTab(tab, selectedSource.url, isOriginalId(selectedSource.id)) + ); + + if (matchingTab) { + const specificSelectedSource = getSpecificSourceByURL( + state, + selectedSource.url, + selectedSource.isOriginal + ); + + if (specificSelectedSource) { + return specificSelectedSource; + } + + return null; + } + + const tabUrls = tabList.map(tab => tab.url); + const leftNeighborIndex = Math.max( + tabUrls.indexOf(selectedSource.url) - 1, + 0 + ); + const lastAvailbleTabIndex = availableTabs.length - 1; + const newSelectedTabIndex = Math.min(leftNeighborIndex, lastAvailbleTabIndex); + const availableTab = availableTabs[newSelectedTabIndex]; + + if (availableTab) { + const tabSource = getSpecificSourceByURL( + state, + availableTab.url, + availableTab.isOriginal + ); + + if (tabSource) { + return tabSource; + } + } + + return null; +} diff --git a/devtools/client/debugger/src/selectors/test/__snapshots__/visibleColumnBreakpoints.spec.js.snap b/devtools/client/debugger/src/selectors/test/__snapshots__/visibleColumnBreakpoints.spec.js.snap new file mode 100644 index 0000000000..845d228d41 --- /dev/null +++ b/devtools/client/debugger/src/selectors/test/__snapshots__/visibleColumnBreakpoints.spec.js.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`visible column breakpoints doesnt show breakpoints to the right 1`] = ` +Array [ + Object { + "breakpoint": Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "id": "breakpoint", + "location": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "options": Object {}, + "originalText": "text", + "text": "text", + }, + "location": Object { + "column": 1, + "line": 1, + "sourceId": "foo", + }, + }, +] +`; + +exports[`visible column breakpoints ignores single breakpoints 1`] = ` +Array [ + Object { + "breakpoint": Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "id": "breakpoint", + "location": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "options": Object {}, + "originalText": "text", + "text": "text", + }, + "location": Object { + "column": 1, + "line": 1, + "sourceId": "foo", + }, + }, + Object { + "breakpoint": null, + "location": Object { + "column": 3, + "line": 1, + "sourceId": "foo", + }, + }, +] +`; + +exports[`visible column breakpoints only shows visible breakpoints 1`] = ` +Array [ + Object { + "breakpoint": Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "id": "breakpoint", + "location": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "options": Object {}, + "originalText": "text", + "text": "text", + }, + "location": Object { + "column": 1, + "line": 1, + "sourceId": "foo", + }, + }, + Object { + "breakpoint": null, + "location": Object { + "column": 3, + "line": 1, + "sourceId": "foo", + }, + }, +] +`; + +exports[`visible column breakpoints simple 1`] = ` +Array [ + Object { + "breakpoint": Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "id": "breakpoint", + "location": Object { + "column": 1, + "line": 1, + "source": Object { + "id": "foo", + }, + "sourceId": "foo", + }, + "options": Object {}, + "originalText": "text", + "text": "text", + }, + "location": Object { + "column": 1, + "line": 1, + "sourceId": "foo", + }, + }, + Object { + "breakpoint": null, + "location": Object { + "column": 5, + "line": 1, + "sourceId": "foo", + }, + }, +] +`; diff --git a/devtools/client/debugger/src/selectors/test/getCallStackFrames.spec.js b/devtools/client/debugger/src/selectors/test/getCallStackFrames.spec.js new file mode 100644 index 0000000000..7d31446e48 --- /dev/null +++ b/devtools/client/debugger/src/selectors/test/getCallStackFrames.spec.js @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getCallStackFrames } from "../getCallStackFrames"; + +describe("getCallStackFrames selector", () => { + describe("library annotation", () => { + it("annotates React frames", () => { + const source1 = { id: "source1", url: "webpack:///src/App.js" }; + const source2 = { + id: "source2", + url: "webpack:///foo/node_modules/react-dom/lib/ReactCompositeComponent.js", + }; + const state = { + frames: [ + { location: { sourceId: "source1", source: source1 } }, + { location: { sourceId: "source2", source: source2 } }, + { location: { sourceId: "source2", source: source2 } }, + ], + selectedSource: { + id: "sourceId-originalSource", + isOriginal: true, + }, + }; + + const frames = getCallStackFrames.resultFunc( + state.frames, + state.selectedSource, + {} + ); + + expect(frames[0]).not.toHaveProperty("library"); + expect(frames[1]).toHaveProperty("library", "React"); + expect(frames[2]).toHaveProperty("library", "React"); + }); + + // Multiple Babel async frame groups occur when you have an async function + // calling another async function (a common case). + // + // There are two possible frame groups that can occur depending on whether + // one sets a breakpoint before or after an await + it("annotates frames related to Babel async transforms", () => { + const appSource = { id: "app", url: "webpack///app.js" }; + const bundleSource = { id: "bundle", url: "https://foo.com/bundle.js" }; + const regeneratorSource = { + id: "regenerator", + url: "webpack:///foo/node_modules/regenerator-runtime/runtime.js", + }; + const microtaskSource = { + id: "microtask", + url: "webpack:///foo/node_modules/core-js/modules/_microtask.js", + }; + const promiseSource = { + id: "promise", + url: "webpack///foo/node_modules/core-js/modules/es6.promise.js", + }; + const preAwaitGroup = [ + { + displayName: "asyncAppFunction", + location: { source: bundleSource }, + }, + { + displayName: "tryCatch", + location: { source: regeneratorSource }, + }, + { + displayName: "invoke", + location: { source: regeneratorSource }, + }, + { + displayName: "defineIteratorMethods/</prototype[method]", + location: { source: regeneratorSource }, + }, + { + displayName: "step", + location: { source: bundleSource }, + }, + { + displayName: "_asyncToGenerator/</<", + location: { source: bundleSource }, + }, + { + displayName: "Promise", + location: { source: promiseSource }, + }, + { + displayName: "_asyncToGenerator/<", + location: { source: bundleSource }, + }, + { + displayName: "asyncAppFunction", + location: { source: appSource }, + }, + ]; + + const postAwaitGroup = [ + { + displayName: "asyncAppFunction", + location: { source: bundleSource }, + }, + { + displayName: "tryCatch", + location: { source: regeneratorSource }, + }, + { + displayName: "invoke", + location: { source: regeneratorSource }, + }, + { + displayName: "defineIteratorMethods/</prototype[method]", + location: { source: regeneratorSource }, + }, + { + displayName: "step", + location: { source: bundleSource }, + }, + { + displayName: "step/<", + location: { source: bundleSource }, + }, + { + displayName: "run", + location: { source: bundleSource }, + }, + { + displayName: "notify/<", + location: { source: bundleSource }, + }, + { + displayName: "flush", + location: { source: microtaskSource }, + }, + ]; + + const state = { + frames: [...preAwaitGroup, ...postAwaitGroup], + selectedSource: { + id: "sourceId-originalSource", + isOriginal: true, + }, + }; + + const frames = getCallStackFrames.resultFunc( + state.frames, + state.selectedSource, + {} + ); + + // frames from 1-8 and 10-17 are babel frames. + const babelFrames = [...frames.slice(1, 7), ...frames.slice(10, 7)]; + const otherFrames = frames.filter(frame => !babelFrames.includes(frame)); + + expect(babelFrames).toEqual( + Array(babelFrames.length).fill( + expect.objectContaining({ library: "Babel" }) + ) + ); + expect(otherFrames).not.toEqual( + Array(babelFrames.length).fill( + expect.objectContaining({ library: "Babel" }) + ) + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/selectors/test/visibleColumnBreakpoints.spec.js b/devtools/client/debugger/src/selectors/test/visibleColumnBreakpoints.spec.js new file mode 100644 index 0000000000..276851b1ef --- /dev/null +++ b/devtools/client/debugger/src/selectors/test/visibleColumnBreakpoints.spec.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { actions, createStore, makeSource } from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; + +import { + getColumnBreakpoints, + getFirstBreakpointPosition, +} from "../visibleColumnBreakpoints"; +import { + makeMockSource, + makeMockSourceWithContent, + makeMockBreakpoint, +} from "../../utils/test-mockup"; + +function pp(line, column) { + return { + location: { sourceId: "foo", line, column }, + generatedLocation: { sourceId: "foo", line, column }, + }; +} + +function defaultSource() { + return makeMockSource(undefined, "foo"); +} + +function bp(line, column) { + return makeMockBreakpoint(defaultSource(), line, column); +} + +const source = makeMockSourceWithContent( + undefined, + "foo.js", + undefined, + `function foo() { + console.log("hello"); +} +console.log('bye'); +` +); + +describe("visible column breakpoints", () => { + it("simple", () => { + const viewport = { + start: { line: 1, column: 0 }, + end: { line: 10, column: 10 }, + }; + const pausePoints = [pp(1, 1), pp(1, 5), pp(3, 1)]; + const breakpoints = [bp(1, 1), bp(4, 0), bp(4, 3)]; + + const columnBps = getColumnBreakpoints( + pausePoints, + breakpoints, + viewport, + source, + source.content + ); + expect(columnBps).toMatchSnapshot(); + }); + + it("ignores single breakpoints", () => { + const viewport = { + start: { line: 1, column: 0 }, + end: { line: 10, column: 10 }, + }; + const pausePoints = [pp(1, 1), pp(1, 3), pp(2, 1)]; + const breakpoints = [bp(1, 1)]; + const columnBps = getColumnBreakpoints( + pausePoints, + breakpoints, + viewport, + source, + source.content + ); + expect(columnBps).toMatchSnapshot(); + }); + + it("only shows visible breakpoints", () => { + const viewport = { + start: { line: 1, column: 0 }, + end: { line: 10, column: 10 }, + }; + const pausePoints = [pp(1, 1), pp(1, 3), pp(20, 1)]; + const breakpoints = [bp(1, 1)]; + + const columnBps = getColumnBreakpoints( + pausePoints, + breakpoints, + viewport, + source, + source.content + ); + expect(columnBps).toMatchSnapshot(); + }); + + it("doesnt show breakpoints to the right", () => { + const viewport = { + start: { line: 1, column: 0 }, + end: { line: 10, column: 10 }, + }; + const pausePoints = [pp(1, 1), pp(1, 15), pp(20, 1)]; + const breakpoints = [bp(1, 1), bp(1, 15)]; + + const columnBps = getColumnBreakpoints( + pausePoints, + breakpoints, + viewport, + source, + source.content + ); + expect(columnBps).toMatchSnapshot(); + }); +}); + +describe("getFirstBreakpointPosition", () => { + it("sorts the positions by column", async () => { + const store = createStore(); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + + dispatch({ + type: "ADD_BREAKPOINT_POSITIONS", + positions: [pp(1, 5), pp(1, 3)], + source: fooSource, + }); + + const position = getFirstBreakpointPosition( + getState(), + createLocation({ + line: 1, + source: fooSource, + }) + ); + + if (!position) { + throw new Error("There should be a position"); + } + expect(position.location.column).toEqual(3); + }); +}); diff --git a/devtools/client/debugger/src/selectors/threads.js b/devtools/client/debugger/src/selectors/threads.js new file mode 100644 index 0000000000..8e3054ce7a --- /dev/null +++ b/devtools/client/debugger/src/selectors/threads.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createSelector } from "reselect"; +import { parse } from "../utils/url"; + +export const getThreads = createSelector( + state => state.threads.threads, + threads => threads.filter(thread => !isMainThread(thread)) +); + +export const getAllThreads = createSelector( + getMainThread, + getThreads, + (mainThread, threads) => { + const orderedThreads = Array.from(threads).sort((threadA, threadB) => { + if (threadA.name === threadB.name) { + return 0; + } + return threadA.name < threadB.name ? -1 : 1; + }); + return [mainThread, ...orderedThreads].filter(Boolean); + } +); + +function isMainThread(thread) { + return thread.isTopLevel; +} + +export function getMainThread(state) { + return state.threads.threads.find(isMainThread); +} + +/* + * Gets domain from the main thread url (without www prefix) + */ +export function getMainThreadHost(state) { + const url = getMainThread(state)?.url; + if (!url) { + return null; + } + const { host } = parse(url); + if (!host) { + return null; + } + return host.startsWith("www.") ? host.substring("www.".length) : host; +} + +export function getThread(state, threadActor) { + return getAllThreads(state).find(thread => thread.actor === threadActor); +} + +export function getIsThreadCurrentlyTracing(state, thread) { + return state.threads.mutableTracingThreads.has(thread); +} diff --git a/devtools/client/debugger/src/selectors/ui.js b/devtools/client/debugger/src/selectors/ui.js new file mode 100644 index 0000000000..635a41d985 --- /dev/null +++ b/devtools/client/debugger/src/selectors/ui.js @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { getSelectedSource } from "./sources"; + +export function getSelectedPrimaryPaneTab(state) { + return state.ui.selectedPrimaryPaneTab; +} + +export function getActiveSearch(state) { + return state.ui.activeSearch; +} + +export function getFrameworkGroupingState(state) { + return state.ui.frameworkGroupingOn; +} + +export function getPaneCollapse(state, position) { + if (position == "start") { + return state.ui.startPanelCollapsed; + } + + return state.ui.endPanelCollapsed; +} + +export function getHighlightedLineRangeForSelectedSource(state) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + return null; + } + // Only return the highlighted line range if it matches the selected source + const highlightedLineRange = state.ui.highlightedLineRange; + if ( + highlightedLineRange && + selectedSource.id == highlightedLineRange.sourceId + ) { + return highlightedLineRange; + } + return null; +} + +export function getConditionalPanelLocation(state) { + return state.ui.conditionalPanelLocation; +} + +export function getLogPointStatus(state) { + return state.ui.isLogPoint; +} + +export function getOrientation(state) { + return state.ui.orientation; +} + +export function getViewport(state) { + return state.ui.viewport; +} + +export function getCursorPosition(state) { + return state.ui.cursorPosition; +} + +export function getInlinePreview(state) { + return state.ui.inlinePreviewEnabled; +} + +export function getEditorWrapping(state) { + return state.ui.editorWrappingEnabled; +} + +export function getJavascriptTracingLogMethod(state) { + return state.ui.javascriptTracingLogMethod; +} + +export function getSearchOptions(state, searchKey) { + return state.ui.mutableSearchOptions[searchKey]; +} + +export function getHideIgnoredSources(state) { + return state.ui.hideIgnoredSources; +} + +export function isSourceMapIgnoreListEnabled(state) { + return state.ui.sourceMapIgnoreListEnabled; +} diff --git a/devtools/client/debugger/src/selectors/visibleBreakpoints.js b/devtools/client/debugger/src/selectors/visibleBreakpoints.js new file mode 100644 index 0000000000..2e4a8f7212 --- /dev/null +++ b/devtools/client/debugger/src/selectors/visibleBreakpoints.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/>. */ + +import { createSelector } from "reselect"; + +import { getBreakpointsList } from "./breakpoints"; +import { getSelectedSource } from "./sources"; + +import { sortSelectedBreakpoints } from "../utils/breakpoint"; +import { getSelectedLocation } from "../utils/selected-location"; + +/* + * Finds the breakpoints, which appear in the selected source. + */ +export const getVisibleBreakpoints = createSelector( + getSelectedSource, + getBreakpointsList, + (selectedSource, breakpoints) => { + if (!selectedSource) { + return null; + } + + return breakpoints.filter( + bp => + selectedSource && + getSelectedLocation(bp, selectedSource).sourceId === selectedSource.id + ); + } +); + +/* + * Finds the first breakpoint per line, which appear in the selected source. + */ +export const getFirstVisibleBreakpoints = createSelector( + getVisibleBreakpoints, + getSelectedSource, + (breakpoints, selectedSource) => { + if (!breakpoints || !selectedSource) { + return []; + } + + // Filter the array so it only return the first breakpoint when there's multiple + // breakpoints on the same line. + const handledLines = new Set(); + return sortSelectedBreakpoints(breakpoints, selectedSource).filter(bp => { + const line = getSelectedLocation(bp, selectedSource).line; + if (handledLines.has(line)) { + return false; + } + handledLines.add(line); + return true; + }); + } +); diff --git a/devtools/client/debugger/src/selectors/visibleColumnBreakpoints.js b/devtools/client/debugger/src/selectors/visibleColumnBreakpoints.js new file mode 100644 index 0000000000..5ed391c7e4 --- /dev/null +++ b/devtools/client/debugger/src/selectors/visibleColumnBreakpoints.js @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { createSelector } from "reselect"; + +import { + getViewport, + getSelectedSource, + getSelectedSourceTextContent, + getBreakpointPositionsForSource, +} from "./index"; +import { getVisibleBreakpoints } from "./visibleBreakpoints"; +import { getSelectedLocation } from "../utils/selected-location"; +import { sortSelectedLocations } from "../utils/location"; +import { getLineText } from "../utils/source"; + +function contains(location, range) { + return ( + location.line >= range.start.line && + location.line <= range.end.line && + (!location.column || + (location.column >= range.start.column && + location.column <= range.end.column)) + ); +} + +function groupBreakpoints(breakpoints, selectedSource) { + const breakpointsMap = {}; + if (!breakpoints) { + return breakpointsMap; + } + + for (const breakpoint of breakpoints) { + if (breakpoint.options.hidden) { + continue; + } + const location = getSelectedLocation(breakpoint, selectedSource); + const { line, column } = location; + + if (!breakpointsMap[line]) { + breakpointsMap[line] = {}; + } + + if (!breakpointsMap[line][column]) { + breakpointsMap[line][column] = []; + } + + breakpointsMap[line][column].push(breakpoint); + } + + return breakpointsMap; +} + +function findBreakpoint(location, breakpointMap) { + const { line, column } = location; + const breakpoints = breakpointMap[line]?.[column]; + + if (!breakpoints) { + return null; + } + return breakpoints[0]; +} + +function filterByLineCount(positions, selectedSource) { + const lineCount = {}; + + for (const breakpoint of positions) { + const { line } = getSelectedLocation(breakpoint, selectedSource); + if (!lineCount[line]) { + lineCount[line] = 0; + } + lineCount[line] = lineCount[line] + 1; + } + + return positions.filter( + breakpoint => + lineCount[getSelectedLocation(breakpoint, selectedSource).line] > 1 + ); +} + +function filterVisible(positions, selectedSource, viewport) { + return positions.filter(columnBreakpoint => { + const location = getSelectedLocation(columnBreakpoint, selectedSource); + return viewport && contains(location, viewport); + }); +} + +function filterByBreakpoints(positions, selectedSource, breakpointMap) { + return positions.filter(position => { + const location = getSelectedLocation(position, selectedSource); + return breakpointMap[location.line]; + }); +} + +// Filters out breakpoints to the right of the line. (bug 1552039) +function filterInLine(positions, selectedSource, selectedContent) { + return positions.filter(position => { + const location = getSelectedLocation(position, selectedSource); + const lineText = getLineText( + selectedSource.id, + selectedContent, + location.line + ); + + return lineText.length >= (location.column || 0); + }); +} + +function formatPositions(positions, selectedSource, breakpointMap) { + return positions.map(position => { + const location = getSelectedLocation(position, selectedSource); + return { + location, + breakpoint: findBreakpoint(location, breakpointMap), + }; + }); +} + +function convertToList(breakpointPositions) { + return [].concat(...Object.values(breakpointPositions)); +} + +export function getColumnBreakpoints( + positions, + breakpoints, + viewport, + selectedSource, + selectedSourceTextContent +) { + if (!positions || !selectedSource) { + return []; + } + + // We only want to show a column breakpoint if several conditions are matched + // - it is the first breakpoint to appear at an the original location + // - the position is in the current viewport + // - there is atleast one other breakpoint on that line + // - there is a breakpoint on that line + const breakpointMap = groupBreakpoints(breakpoints, selectedSource); + positions = filterByLineCount(positions, selectedSource); + positions = filterVisible(positions, selectedSource, viewport); + positions = filterInLine( + positions, + selectedSource, + selectedSourceTextContent + ); + positions = filterByBreakpoints(positions, selectedSource, breakpointMap); + + return formatPositions(positions, selectedSource, breakpointMap); +} + +const getVisibleBreakpointPositions = createSelector( + state => { + const source = getSelectedSource(state); + if (!source) { + return null; + } + return getBreakpointPositionsForSource(state, source.id); + }, + sourcePositions => { + return convertToList(sourcePositions || []); + } +); + +export const visibleColumnBreakpoints = createSelector( + getVisibleBreakpointPositions, + getVisibleBreakpoints, + getViewport, + getSelectedSource, + getSelectedSourceTextContent, + getColumnBreakpoints +); + +export function getFirstBreakpointPosition(state, location) { + const positions = getBreakpointPositionsForSource(state, location.sourceId); + if (!positions) { + return null; + } + + return sortSelectedLocations(convertToList(positions), location.source).find( + position => + getSelectedLocation(position, location.source).line == location.line + ); +} diff --git a/devtools/client/debugger/src/test/__mocks__/request-animation-frame.js b/devtools/client/debugger/src/test/__mocks__/request-animation-frame.js new file mode 100644 index 0000000000..d4441616ef --- /dev/null +++ b/devtools/client/debugger/src/test/__mocks__/request-animation-frame.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +global.requestAnimationFrame = function (cb) { + cb(); + return null; +}; diff --git a/devtools/client/debugger/src/test/fixtures/README.md b/devtools/client/debugger/src/test/fixtures/README.md new file mode 100644 index 0000000000..ea907643e3 --- /dev/null +++ b/devtools/client/debugger/src/test/fixtures/README.md @@ -0,0 +1,3 @@ +## Fixtures + +Fixtures used for unit tests diff --git a/devtools/client/debugger/src/test/fixtures/foobar.json b/devtools/client/debugger/src/test/fixtures/foobar.json new file mode 100644 index 0000000000..f225bf1f7a --- /dev/null +++ b/devtools/client/debugger/src/test/fixtures/foobar.json @@ -0,0 +1,56 @@ +{ + "sources": { + "sources": { + "fooSourceActor": { + "id": "fooSourceActor", + "url": "http://example.com/foo/foo.js", + "filename": "foo.js", + "pathname": "foo/foo.js" + }, + "barSourceActor": { + "id": "barSourceActor", + "url": "http://example.com/bar/bar.js", + "filename": "bar.js", + "pathname": "bar/bar.js" + }, + "bazzSourceActor": { + "id": "bazzSourceActor", + "url": "http://example.com/bazz/bazz.js", + "filename": "bazz.js", + "pathname": "bazz/bazz.js" + } + }, + "sourcesText": { + "fooSourceActor": { + "contentType": "text/javascript", + "text": "function() {\n return foo;\n}" + }, + "barSourceActor": { + "contentType": "text/javascript", + "text": "function() {\n return bar;\n}" + }, + "bazzSourceActor": { + "contentType": "text/javascript", + "text": "function() {\n return bazz;\n}" + } + } + }, + "breakpoints": { + "breakpoints": { + "fooBreakpointActor": { + "id": "fooBreakpointActor", + "location": { + "sourceId": "fooSourceActor", + "line": 16 + } + }, + "barBreakpointActor": { + "id": "barBreakpointActor", + "location": { + "sourceId": "barSourceActor", + "line": 18 + } + } + } + } +} diff --git a/devtools/client/debugger/src/test/fixtures/index.js b/devtools/client/debugger/src/test/fixtures/index.js new file mode 100644 index 0000000000..a0efc568b0 --- /dev/null +++ b/devtools/client/debugger/src/test/fixtures/index.js @@ -0,0 +1,3 @@ +import foobarJson from "./foobar.json"; + +export const foobar = foobarJson; diff --git a/devtools/client/debugger/src/test/shim.js b/devtools/client/debugger/src/test/shim.js new file mode 100644 index 0000000000..d1ac2f549f --- /dev/null +++ b/devtools/client/debugger/src/test/shim.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const { + setMocksInGlobal, +} = require("devtools/client/shared/test-helpers/shared-node-helpers"); +setMocksInGlobal(); + +const { LocalizationHelper } = require("devtools/shared/l10n"); +global.L10N = new LocalizationHelper( + "devtools/client/locales/debugger.properties" +); + +const { URL } = require("url"); +global.URL = URL; + +// JSDOM doesn't seem to have those functions that are used by codeMirror. +// See https://github.com/jsdom/jsdom/issues/3002 +document.createRange = () => { + const range = new Range(); + + range.getBoundingClientRect = jest.fn(); + + range.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + })); + + return range; +}; diff --git a/devtools/client/debugger/src/test/tests-setup.js b/devtools/client/debugger/src/test/tests-setup.js new file mode 100644 index 0000000000..ad9beecd49 --- /dev/null +++ b/devtools/client/debugger/src/test/tests-setup.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +global.Worker = require("workerjs"); + +import path from "path"; +import Enzyme from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; +import { setupHelper } from "../utils/dbg"; +import { prefs } from "../utils/prefs"; + +import { PrettyPrintDispatcher } from "../workers/pretty-print"; +import { ParserDispatcher } from "../workers/parser"; +import { SearchDispatcher } from "../workers/search"; + +import { clearDocuments } from "../utils/editor"; + +const rootPath = path.join(__dirname, "../../"); + +Enzyme.configure({ adapter: new Adapter() }); + +jest.setTimeout(20000); + +function formatException(reason, p) { + console && console.log("Unhandled Rejection at:", p, "reason:", reason); +} + +export const parserWorker = new ParserDispatcher( + path.join(rootPath, "src/workers/parser/worker.js") +); +export const prettyPrintWorker = new PrettyPrintDispatcher( + path.join(rootPath, "src/workers/pretty-print/worker.js") +); +export const searchWorker = new SearchDispatcher( + path.join(rootPath, "src/workers/search/worker.js") +); + +beforeAll(() => { + process.on("unhandledRejection", formatException); +}); + +afterAll(() => { + parserWorker.stop(); + prettyPrintWorker.stop(); + searchWorker.stop(); + + process.removeListener("unhandledRejection", formatException); +}); + +afterEach(() => {}); + +beforeEach(async () => { + parserWorker.clear(); + + clearDocuments(); + prefs.projectDirectoryRoot = ""; + prefs.projectDirectoryRootName = ""; + prefs.expressions = []; + + // Ensures window.dbg is there to track telemetry + setupHelper({ selectors: {} }); +}); diff --git a/devtools/client/debugger/src/utils/DevToolsUtils.js b/devtools/client/debugger/src/utils/DevToolsUtils.js new file mode 100644 index 0000000000..b2e01f5502 --- /dev/null +++ b/devtools/client/debugger/src/utils/DevToolsUtils.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import assert from "./assert"; + +export function reportException(who, exception) { + const msg = `${who} threw an exception: `; + console.error(msg, exception); +} + +export function executeSoon(fn) { + setTimeout(fn, 0); +} + +export default assert; diff --git a/devtools/client/debugger/src/utils/assert.js b/devtools/client/debugger/src/utils/assert.js new file mode 100644 index 0000000000..2be4f3c7f1 --- /dev/null +++ b/devtools/client/debugger/src/utils/assert.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isNodeTest } from "./environment"; + +let assert; +// TODO: try to enable these assertions on mochitest by also enabling it on: +// import flags from "devtools/shared/flags"; +// if (flags.testing) +// Unfortunately it throws a lot on mochitests... + +if (isNodeTest()) { + assert = function (condition, message) { + if (!condition) { + throw new Error(`Assertion failure: ${message}`); + } + }; +} else { + assert = function () {}; +} +export default assert; diff --git a/devtools/client/debugger/src/utils/ast.js b/devtools/client/debugger/src/utils/ast.js new file mode 100644 index 0000000000..cbdd13aa83 --- /dev/null +++ b/devtools/client/debugger/src/utils/ast.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function findBestMatchExpression(symbols, tokenPos) { + if (!symbols) { + return null; + } + + const { line, column } = tokenPos; + const { memberExpressions, identifiers, literals } = symbols; + const members = memberExpressions.filter(({ computed }) => !computed); + + return [] + .concat(identifiers, members, literals) + .reduce((found, expression) => { + const overlaps = + expression.location.start.line == line && + expression.location.start.column <= column && + expression.location.end.column >= column; + + if (overlaps) { + return expression; + } + + return found; + }, null); +} + +// Check whether location A starts after location B +export function positionAfter(a, b) { + return ( + a.start.line > b.start.line || + (a.start.line === b.start.line && a.start.column > b.start.column) + ); +} + +export function containsPosition(a, b) { + const bColumn = b.column || 0; + const startsBefore = + a.start.line < b.line || + (a.start.line === b.line && a.start.column <= bColumn); + const endsAfter = + a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn); + + return startsBefore && endsAfter; +} + +function findClosestofSymbol(declarations, location) { + if (!declarations) { + return null; + } + + return declarations.reduce((found, currNode) => { + if ( + currNode.name === "anonymous" || + !containsPosition(currNode.location, { + line: location.line, + column: location.column || 0, + }) + ) { + return found; + } + + if (!found) { + return currNode; + } + + if (found.location.start.line > currNode.location.start.line) { + return found; + } + if ( + found.location.start.line === currNode.location.start.line && + found.location.start.column > currNode.location.start.column + ) { + return found; + } + + return currNode; + }, null); +} + +export function findClosestFunction(symbols, location) { + if (!symbols) { + return null; + } + + return findClosestofSymbol(symbols.functions, location); +} + +export function findClosestClass(symbols, location) { + if (!symbols) { + return null; + } + + return findClosestofSymbol(symbols.classes, location); +} diff --git a/devtools/client/debugger/src/utils/async-value.js b/devtools/client/debugger/src/utils/async-value.js new file mode 100644 index 0000000000..e1467d2401 --- /dev/null +++ b/devtools/client/debugger/src/utils/async-value.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function pending() { + return { state: "pending" }; +} +export function fulfilled(value) { + return { state: "fulfilled", value }; +} +export function rejected(value) { + return { state: "rejected", value }; +} + +export function asSettled(value) { + return value && value.state !== "pending" ? value : null; +} + +export function isPending(value) { + return value.state === "pending"; +} +export function isFulfilled(value) { + return value.state === "fulfilled"; +} +export function isRejected(value) { + return value.state === "rejected"; +} diff --git a/devtools/client/debugger/src/utils/bootstrap.js b/devtools/client/debugger/src/utils/bootstrap.js new file mode 100644 index 0000000000..2bd38851e9 --- /dev/null +++ b/devtools/client/debugger/src/utils/bootstrap.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { bindActionCreators, combineReducers } from "redux"; +import ReactDOM from "react-dom"; +const { Provider } = require("react-redux"); + +import ToolboxProvider from "devtools/client/framework/store-provider"; +import flags from "devtools/shared/flags"; +const { + registerStoreObserver, +} = require("devtools/client/shared/redux/subscriber"); + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +import { SearchDispatcher } from "../workers/search"; +import { PrettyPrintDispatcher } from "../workers/pretty-print"; + +import configureStore from "../actions/utils/create-store"; +import reducers from "../reducers"; +import * as selectors from "../selectors"; +import App from "../components/App"; +import { asyncStore, prefs } from "./prefs"; +import { persistTabs } from "../utils/tabs"; +const { sanitizeBreakpoints } = require("devtools/client/shared/thread-utils"); + +let gWorkers; + +export function bootstrapStore(client, workers, panel, initialState) { + const debugJsModules = AppConstants.DEBUG_JS_MODULES == "1"; + const createStore = configureStore({ + log: prefs.logging || flags.testing, + timing: debugJsModules, + makeThunkArgs: (args, state) => { + return { ...args, client, ...workers, panel }; + }, + }); + + const store = createStore(combineReducers(reducers), initialState); + registerStoreObserver(store, updatePrefs); + + const actions = bindActionCreators( + require("../actions").default, + store.dispatch + ); + + return { store, actions, selectors }; +} + +export function bootstrapWorkers(panelWorkers) { + // The panel worker will typically be the source map and parser workers. + // Both will be managed by the toolbox. + gWorkers = { + prettyPrintWorker: new PrettyPrintDispatcher(), + searchWorker: new SearchDispatcher(), + }; + return { ...panelWorkers, ...gWorkers }; +} + +export function teardownWorkers() { + gWorkers.prettyPrintWorker.stop(); + gWorkers.searchWorker.stop(); +} + +/** + * Create and mount the root App component. + * + * @param {ReduxStore} store + * @param {ReduxStore} toolboxStore + * @param {Object} appComponentAttributes + * @param {Array} appComponentAttributes.fluentBundles + * @param {Document} appComponentAttributes.toolboxDoc + */ +export function bootstrapApp(store, toolboxStore, appComponentAttributes = {}) { + const mount = getMountElement(); + if (!mount) { + return; + } + + ReactDOM.render( + React.createElement( + Provider, + { store }, + React.createElement( + ToolboxProvider, + { store: toolboxStore }, + React.createElement(App, appComponentAttributes) + ) + ), + mount + ); +} + +function getMountElement() { + return document.querySelector("#mount"); +} + +// This is the opposite of bootstrapApp +export function unmountRoot() { + ReactDOM.unmountComponentAtNode(getMountElement()); +} + +function updatePrefs(state, oldState) { + const hasChanged = selector => + selector(oldState) && selector(oldState) !== selector(state); + + if (hasChanged(selectors.getPendingBreakpoints)) { + asyncStore.pendingBreakpoints = sanitizeBreakpoints( + selectors.getPendingBreakpoints(state) + ); + } + + if ( + oldState.eventListenerBreakpoints && + oldState.eventListenerBreakpoints !== state.eventListenerBreakpoints + ) { + asyncStore.eventListenerBreakpoints = state.eventListenerBreakpoints; + } + + if (hasChanged(selectors.getTabs)) { + asyncStore.tabs = persistTabs(selectors.getTabs(state)); + } + + if (hasChanged(selectors.getXHRBreakpoints)) { + asyncStore.xhrBreakpoints = selectors.getXHRBreakpoints(state); + } + + if (hasChanged(selectors.getBlackBoxRanges)) { + asyncStore.blackboxedRanges = selectors.getBlackBoxRanges(state); + } +} diff --git a/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js new file mode 100644 index 0000000000..49b8523284 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/breakpointPositions.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { comparePosition } from "../location"; +import { getSelectedLocation } from "../selected-location"; + +export function findPosition(positions, location) { + if (!positions) { + return null; + } + + const lineBps = positions[location.line]; + if (!lineBps) { + return null; + } + return lineBps.find(pos => + comparePosition(getSelectedLocation(pos, location), location) + ); +} diff --git a/devtools/client/debugger/src/utils/breakpoint/index.js b/devtools/client/debugger/src/utils/breakpoint/index.js new file mode 100644 index 0000000000..5b0ce06533 --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/index.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSourceActorsForSource } from "../../selectors"; +import { isGenerated } from "../source"; +import { sortSelectedLocations } from "../location"; +export * from "./breakpointPositions"; + +// The ID for a Breakpoint is derived from its location in its Source. +export function makeBreakpointId(location) { + const { sourceId, line, column } = location; + const columnString = column || ""; + return `${sourceId}:${line}:${columnString}`; +} + +export function makeBreakpointServerLocationId(breakpointServerLocation) { + const { sourceUrl, sourceId, line, column } = breakpointServerLocation; + const sourceUrlOrId = sourceUrl || sourceId; + const columnString = column || ""; + + return `${sourceUrlOrId}:${line}:${columnString}`; +} + +/** + * Create a location object to set a breakpoint on the server. + * + * Debugger location objects includes a source and sourceActor attributes + * whereas the server don't need them and instead only need either + * the source URL -or- a precise source actor ID. + */ +export function makeBreakpointServerLocation(state, location) { + const source = location.source; + if (!source) { + throw new Error("Missing 'source' attribute on location object"); + } + const breakpointLocation = { + line: location.line, + column: location.column, + }; + if (source.url) { + breakpointLocation.sourceUrl = source.url; + } else { + breakpointLocation.sourceId = getSourceActorsForSource( + state, + source.id + )[0].id; + } + return breakpointLocation; +} + +export function createXHRBreakpoint(path, method, overrides = {}) { + const properties = { + path, + method, + disabled: false, + loading: false, + text: L10N.getFormatStr("xhrBreakpoints.item.label", path), + }; + + return { ...properties, ...overrides }; +} + +export function getSelectedText(breakpoint, selectedSource) { + return !!selectedSource && isGenerated(selectedSource) + ? breakpoint.text + : breakpoint.originalText; +} + +export function sortSelectedBreakpoints(breakpoints, selectedSource) { + return sortSelectedLocations(breakpoints, selectedSource); +} diff --git a/devtools/client/debugger/src/utils/breakpoint/moz.build b/devtools/client/debugger/src/utils/breakpoint/moz.build new file mode 100644 index 0000000000..02c5302a6c --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/moz.build @@ -0,0 +1,11 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "breakpointPositions.js", + "index.js", +) diff --git a/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js new file mode 100644 index 0000000000..7ff0a1e75c --- /dev/null +++ b/devtools/client/debugger/src/utils/breakpoint/tests/index.spec.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { sortSelectedBreakpoints } from "../index"; + +import { makeMockBreakpoint, makeMockSource } from "../../test-mockup"; + +describe("breakpoint sorting", () => { + it("sortSelectedBreakpoints should sort by line number and column ", () => { + const sorted = sortSelectedBreakpoints( + [ + makeMockBreakpoint(undefined, 100, 2), + makeMockBreakpoint(undefined, 9, 2), + makeMockBreakpoint(undefined, 2), + makeMockBreakpoint(undefined, 2, 7), + ], + makeMockSource() + ); + + expect(sorted[0].location.line).toBe(2); + expect(sorted[0].location.column).toBe(undefined); + expect(sorted[1].location.line).toBe(2); + expect(sorted[1].location.column).toBe(7); + expect(sorted[2].location.line).toBe(9); + expect(sorted[3].location.line).toBe(100); + }); +}); diff --git a/devtools/client/debugger/src/utils/build-query.js b/devtools/client/debugger/src/utils/build-query.js new file mode 100644 index 0000000000..f10999aee4 --- /dev/null +++ b/devtools/client/debugger/src/utils/build-query.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function escapeRegExp(str) { + const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; + return str.replace(reRegExpChar, "\\$&"); +} + +/** + * Ignore doing outline matches for less than 3 whitespaces + * + * @memberof utils/source-search + * @static + */ +function ignoreWhiteSpace(str) { + return /^\s{0,2}$/.test(str) ? "(?!\\s*.*)" : str; +} + +function wholeMatch(query, wholeWord) { + if (query === "" || !wholeWord) { + return query; + } + + return `\\b${query}\\b`; +} + +function buildFlags(caseSensitive, isGlobal) { + if (caseSensitive && isGlobal) { + return "g"; + } + + if (!caseSensitive && isGlobal) { + return "gi"; + } + + if (!caseSensitive && !isGlobal) { + return "i"; + } + + return null; +} + +export default function buildQuery( + originalQuery, + modifiers, + { isGlobal = false, ignoreSpaces = false } +) { + const { caseSensitive, regexMatch, wholeWord } = modifiers; + + if (originalQuery === "") { + return new RegExp(originalQuery); + } + + // Remove the backslashes at the end of the query as it + // breaks the RegExp + let query = originalQuery.replace(/\\$/, ""); + + // If we don't want to do a regexMatch, we need to escape all regex related characters + // so they would actually match. + if (!regexMatch) { + query = escapeRegExp(query); + } + + // ignoreWhiteSpace might return a negative lookbehind, and in such case, we want it + // to be consumed as a RegExp part by the callsite, so this needs to be called after + // the regexp is escaped. + if (ignoreSpaces) { + query = ignoreWhiteSpace(query); + } + + query = wholeMatch(query, wholeWord); + const flags = buildFlags(caseSensitive, isGlobal); + + if (flags) { + return new RegExp(query, flags); + } + + return new RegExp(query); +} diff --git a/devtools/client/debugger/src/utils/clipboard.js b/devtools/client/debugger/src/utils/clipboard.js new file mode 100644 index 0000000000..66c0d297cc --- /dev/null +++ b/devtools/client/debugger/src/utils/clipboard.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Clipboard function taken from + * https://searchfox.org/mozilla-central/source/devtools/shared/platform/clipboard.js + */ + +export function copyToTheClipboard(string) { + const doCopy = function (e) { + e.clipboardData.setData("text/plain", string); + e.preventDefault(); + }; + + document.addEventListener("copy", doCopy); + document.execCommand("copy", false, null); + document.removeEventListener("copy", doCopy); +} diff --git a/devtools/client/debugger/src/utils/connect.js b/devtools/client/debugger/src/utils/connect.js new file mode 100644 index 0000000000..023ae7497d --- /dev/null +++ b/devtools/client/debugger/src/utils/connect.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { connect as reduxConnect } from "react-redux"; + +export const connect = reduxConnect; diff --git a/devtools/client/debugger/src/utils/context.js b/devtools/client/debugger/src/utils/context.js new file mode 100644 index 0000000000..ef7fd23bb6 --- /dev/null +++ b/devtools/client/debugger/src/utils/context.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getThreadContext } from "../selectors"; + +// Context encapsulates the main parameters of the current redux state, which +// impact most other information tracked by the debugger. +// +// The main use of Context is to control when asynchronous operations are +// allowed to make changes to the program state. Such operations might be +// invalidated as the state changes from the time the operation was originally +// initiated. For example, operations on pause state might still continue even +// after the thread unpauses. +// +// The methods below can be used to compare an old context with the current one +// and see if the operation is now invalid and should be abandoned. Actions can +// also include a 'cx' Context property, which will be checked by the context +// middleware. If the action fails validateContextAction() then it will not be +// dispatched. +// +// Context can additionally be used as a shortcut to access the main properties +// of the pause state. + +// A normal Context is invalidated if the target navigates. + +// A ThreadContext is invalidated if the target navigates, or if the current +// thread changes, pauses, or resumes. + +export class ContextError extends Error {} + +export function validateNavigateContext(state, cx) { + const newcx = getThreadContext(state); + + if (newcx.navigateCounter != cx.navigateCounter) { + throw new ContextError("Page has navigated"); + } +} + +export function validateThreadContext(state, cx) { + const newcx = getThreadContext(state); + + if (cx.thread != newcx.thread) { + throw new ContextError("Current thread has changed"); + } + + if (cx.pauseCounter != newcx.pauseCounter) { + throw new ContextError("Current thread has paused or resumed"); + } +} + +export function validateContext(state, cx) { + validateNavigateContext(state, cx); + + if ("thread" in cx) { + validateThreadContext(state, cx); + } +} + +export function isValidThreadContext(state, cx) { + const newcx = getThreadContext(state); + return cx.thread == newcx.thread && cx.pauseCounter == newcx.pauseCounter; +} diff --git a/devtools/client/debugger/src/utils/dbg.js b/devtools/client/debugger/src/utils/dbg.js new file mode 100644 index 0000000000..0d7dff72e1 --- /dev/null +++ b/devtools/client/debugger/src/utils/dbg.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs, asyncStore, features } from "./prefs"; +import { getDocument } from "./editor/source-documents"; +import { wasmOffsetToLine } from "./wasm"; + +function getThreadFront(dbg) { + return dbg.targetCommand.targetFront.threadFront; +} + +function findSource(dbg, url) { + const sources = dbg.selectors.getSourceList(); + return sources.find(s => (s.url || "").includes(url)); +} + +function findSources(dbg, url) { + const sources = dbg.selectors.getSourceList(); + return sources.filter(s => (s.url || "").includes(url)); +} + +function evaluate(dbg, expression) { + return dbg.client.evaluate(expression); +} + +function bindSelectors(obj) { + return Object.keys(obj.selectors).reduce((bound, selector) => { + bound[selector] = (a, b, c) => + obj.selectors[selector](obj.store.getState(), a, b, c); + return bound; + }, {}); +} + +function getCM() { + const cm = document.querySelector(".CodeMirror"); + return cm?.CodeMirror; +} + +function formatMappedLocation(mappedLocation) { + const { location, generatedLocation } = mappedLocation; + return { + original: `(${location.line}, ${location.column})`, + generated: `(${generatedLocation.line}, ${generatedLocation.column})`, + }; +} + +function formatMappedLocations(locations) { + return console.table(locations.map(loc => formatMappedLocation(loc))); +} + +function formatSelectedColumnBreakpoints(dbg) { + const positions = dbg.selectors.getBreakpointPositionsForSource( + dbg.selectors.getSelectedSource().id + ); + + return formatMappedLocations(positions); +} + +function getDocumentForUrl(dbg, url) { + const source = findSource(dbg, url); + return getDocument(source.id); +} + +const diff = (a, b) => Object.keys(a).filter(key => !Object.is(a[key], b[key])); + +export function setupHelper(obj) { + const selectors = bindSelectors(obj); + const dbg = { + ...obj, + selectors, + prefs, + asyncStore, + features, + getCM, + + // Expose this to tests as they don't have access to debugger's browser loader require + // and so can't load utils/wasm.js + wasmOffsetToLine: (sourceId, offset) => wasmOffsetToLine(sourceId, offset), + + helpers: { + findSource: url => findSource(dbg, url), + findSources: url => findSources(dbg, url), + evaluate: expression => evaluate(dbg, expression), + dumpThread: () => getThreadFront(dbg).dumpThread(), + getDocument: url => getDocumentForUrl(dbg, url), + }, + formatters: { + mappedLocations: locations => formatMappedLocations(locations), + mappedLocation: location => formatMappedLocation(location), + selectedColumnBreakpoints: () => formatSelectedColumnBreakpoints(dbg), + }, + _telemetry: { + events: {}, + }, + diff, + }; + + window.dbg = dbg; +} diff --git a/devtools/client/debugger/src/utils/editor/create-editor.js b/devtools/client/debugger/src/utils/editor/create-editor.js new file mode 100644 index 0000000000..3f44ddaa67 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/create-editor.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import SourceEditor from "./source-editor"; +import { features, prefs } from "../prefs"; + +export function createEditor() { + const gutters = ["breakpoints", "hit-markers", "CodeMirror-linenumbers"]; + + if (features.codeFolding) { + gutters.push("CodeMirror-foldgutter"); + } + + return new SourceEditor({ + mode: "javascript", + foldGutter: features.codeFolding, + enableCodeFolding: features.codeFolding, + readOnly: true, + lineNumbers: true, + theme: "mozilla", + styleActiveLine: false, + lineWrapping: prefs.editorWrapping, + matchBrackets: true, + showAnnotationRuler: true, + gutters, + value: " ", + extraKeys: { + // Override code mirror keymap to avoid conflicts with split console. + Esc: false, + "Cmd-F": false, + "Ctrl-F": false, + "Cmd-G": false, + "Ctrl-G": false, + }, + cursorBlinkRate: prefs.cursorBlinkRate, + }); +} + +export function createHeadlessEditor() { + const editor = createEditor(); + editor.appendToLocalElement(document.createElement("div")); + return editor; +} diff --git a/devtools/client/debugger/src/utils/editor/get-expression.js b/devtools/client/debugger/src/utils/editor/get-expression.js new file mode 100644 index 0000000000..c664f163c3 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-expression.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function tokenAtTextPosition(cm, { line, column }) { + if (line < 0 || line >= cm.lineCount()) { + return null; + } + + const token = cm.getTokenAt({ line: line - 1, ch: column }); + if (!token) { + return null; + } + + return { startColumn: token.start, endColumn: token.end, type: token.type }; +} + +// The strategy of querying codeMirror tokens was borrowed +// from Chrome's inital implementation in JavaScriptSourceFrame.js#L414 +export function getExpressionFromCoords(cm, coord) { + const token = tokenAtTextPosition(cm, coord); + if (!token) { + return null; + } + + let startHighlight = token.startColumn; + const endHighlight = token.endColumn; + const lineNumber = coord.line; + const line = cm.doc.getLine(coord.line - 1); + while (startHighlight > 1 && line.charAt(startHighlight - 1) === ".") { + const tokenBefore = tokenAtTextPosition(cm, { + line: coord.line, + column: startHighlight - 2, + }); + + if (!tokenBefore || !tokenBefore.type) { + return null; + } + + startHighlight = tokenBefore.startColumn; + } + + const expression = line.substring(startHighlight, endHighlight) || ""; + + if (!expression) { + return null; + } + + const location = { + start: { line: lineNumber, column: startHighlight }, + end: { line: lineNumber, column: endHighlight }, + }; + return { expression, location }; +} diff --git a/devtools/client/debugger/src/utils/editor/get-token-location.js b/devtools/client/debugger/src/utils/editor/get-token-location.js new file mode 100644 index 0000000000..2642066051 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-token-location.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getTokenLocation(codeMirror, tokenEl) { + const { left, top, width, height } = tokenEl.getBoundingClientRect(); + const { line, ch } = codeMirror.coordsChar({ + left: left + width / 2, + top: top + height / 2, + }); + + return { + line: line + 1, + column: ch, + }; +} diff --git a/devtools/client/debugger/src/utils/editor/index.js b/devtools/client/debugger/src/utils/editor/index.js new file mode 100644 index 0000000000..ce1875bd35 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/index.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./source-documents"; +export * from "./get-token-location"; +export * from "./source-search"; +export * from "../ui"; +export { onMouseOver } from "./token-events"; + +import { createEditor } from "./create-editor"; + +import { isWasm, lineToWasmOffset, wasmOffsetToLine } from "../wasm"; +import { createLocation } from "../location"; + +let editor; + +export function getEditor() { + if (editor) { + return editor; + } + + editor = createEditor(); + return editor; +} + +export function removeEditor() { + editor = null; +} + +function getCodeMirror() { + return editor && editor.hasCodeMirror ? editor.codeMirror : null; +} + +export function startOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.startOperation(); +} + +export function endOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.endOperation(); +} + +export function toEditorLine(sourceId, lineOrOffset) { + if (isWasm(sourceId)) { + // TODO ensure offset is always "mappable" to edit line. + return wasmOffsetToLine(sourceId, lineOrOffset) || 0; + } + + return lineOrOffset ? lineOrOffset - 1 : 1; +} + +export function fromEditorLine(sourceId, line, sourceIsWasm) { + if (sourceIsWasm) { + return lineToWasmOffset(sourceId, line) || 0; + } + + return line + 1; +} + +export function toEditorPosition(location) { + return { + line: toEditorLine(location.sourceId, location.line), + column: isWasm(location.sourceId) || !location.column ? 0 : location.column, + }; +} + +export function toEditorRange(sourceId, location) { + const { start, end } = location; + return { + start: toEditorPosition({ ...start, sourceId }), + end: toEditorPosition({ ...end, sourceId }), + }; +} + +export function toSourceLine(sourceId, line) { + return isWasm(sourceId) ? lineToWasmOffset(sourceId, line) : line + 1; +} + +export function scrollToColumn(codeMirror, line, column) { + const { top, left } = codeMirror.charCoords({ line, ch: column }, "local"); + + if (!isVisible(codeMirror, top, left)) { + const scroller = codeMirror.getScrollerElement(); + const centeredX = Math.max(left - scroller.offsetWidth / 2, 0); + const centeredY = Math.max(top - scroller.offsetHeight / 2, 0); + + codeMirror.scrollTo(centeredX, centeredY); + } +} + +function isVisible(codeMirror, top, left) { + function withinBounds(x, min, max) { + return x >= min && x <= max; + } + + const scrollArea = codeMirror.getScrollInfo(); + const charWidth = codeMirror.defaultCharWidth(); + const fontHeight = codeMirror.defaultTextHeight(); + const { scrollTop, scrollLeft } = codeMirror.doc; + + const inXView = withinBounds( + left, + scrollLeft, + scrollLeft + (scrollArea.clientWidth - 30) - charWidth + ); + + const inYView = withinBounds( + top, + scrollTop, + scrollTop + scrollArea.clientHeight - fontHeight + ); + + return inXView && inYView; +} + +export function getLocationsInViewport( + { codeMirror }, + // Offset represents an allowance of characters or lines offscreen to improve + // perceived performance of column breakpoint rendering + offsetHorizontalCharacters = 100, + offsetVerticalLines = 20 +) { + // Get scroll position + if (!codeMirror) { + return { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }; + } + const charWidth = codeMirror.defaultCharWidth(); + const scrollArea = codeMirror.getScrollInfo(); + const { scrollLeft } = codeMirror.doc; + const rect = codeMirror.getWrapperElement().getBoundingClientRect(); + const topVisibleLine = + codeMirror.lineAtHeight(rect.top, "window") - offsetVerticalLines; + const bottomVisibleLine = + codeMirror.lineAtHeight(rect.bottom, "window") + offsetVerticalLines; + + const leftColumn = Math.floor( + scrollLeft > 0 ? scrollLeft / charWidth - offsetHorizontalCharacters : 0 + ); + const rightPosition = scrollLeft + (scrollArea.clientWidth - 30); + const rightCharacter = + Math.floor(rightPosition / charWidth) + offsetHorizontalCharacters; + + return { + start: { + line: topVisibleLine || 0, + column: leftColumn || 0, + }, + end: { + line: bottomVisibleLine || 0, + column: rightCharacter, + }, + }; +} + +export function markText({ codeMirror }, className, { start, end }) { + return codeMirror.markText( + { ch: start.column, line: start.line }, + { ch: end.column, line: end.line }, + { className } + ); +} + +export function lineAtHeight({ codeMirror }, sourceId, event) { + const _editorLine = codeMirror.lineAtHeight(event.clientY); + return toSourceLine(sourceId, _editorLine); +} + +export function getSourceLocationFromMouseEvent({ codeMirror }, source, e) { + const { line, ch } = codeMirror.coordsChar({ + left: e.clientX, + top: e.clientY, + }); + + return createLocation({ + source, + line: fromEditorLine(source.id, line, isWasm(source.id)), + column: isWasm(source.id) ? 0 : ch + 1, + }); +} + +export function forEachLine(codeMirror, iter) { + codeMirror.operation(() => { + codeMirror.doc.iter(0, codeMirror.lineCount(), iter); + }); +} + +export function removeLineClass(codeMirror, line, className) { + codeMirror.removeLineClass(line, "wrap", className); +} + +export function clearLineClass(codeMirror, className) { + forEachLine(codeMirror, line => { + removeLineClass(codeMirror, line, className); + }); +} + +export function getTextForLine(codeMirror, line) { + return codeMirror.getLine(line - 1).trim(); +} + +export function getCursorLine(codeMirror) { + return codeMirror.getCursor().line; +} + +export function getCursorColumn(codeMirror) { + return codeMirror.getCursor().ch; +} + +export function getTokenEnd(codeMirror, line, column) { + const token = codeMirror.getTokenAt({ + line, + ch: column + 1, + }); + const tokenString = token.string; + + return tokenString === "{" || tokenString === "[" ? null : token.end; +} diff --git a/devtools/client/debugger/src/utils/editor/moz.build b/devtools/client/debugger/src/utils/editor/moz.build new file mode 100644 index 0000000000..655c0dae43 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "create-editor.js", + "get-expression.js", + "get-token-location.js", + "index.js", + "source-documents.js", + "source-editor.js", + "source-search.js", + "token-events.js", +) diff --git a/devtools/client/debugger/src/utils/editor/source-documents.js b/devtools/client/debugger/src/utils/editor/source-documents.js new file mode 100644 index 0000000000..25040eb7cd --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-documents.js @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isWasm, getWasmLineNumberFormatter, renderWasmText } from "../wasm"; +import { isMinified } from "../isMinified"; +import { resizeBreakpointGutter, resizeToggleButton } from "../ui"; +import { javascriptLikeExtensions } from "../source"; + +let sourceDocs = {}; + +export function getDocument(key) { + return sourceDocs[key]; +} + +export function hasDocument(key) { + return !!getDocument(key); +} + +export function setDocument(key, doc) { + sourceDocs[key] = doc; +} + +export function removeDocument(key) { + delete sourceDocs[key]; +} + +export function clearDocuments() { + sourceDocs = {}; +} + +function resetLineNumberFormat(editor) { + const cm = editor.codeMirror; + cm.setOption("lineNumberFormatter", number => number); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +function updateLineNumberFormat(editor, sourceId) { + if (!isWasm(sourceId)) { + resetLineNumberFormat(editor); + return; + } + const cm = editor.codeMirror; + const lineNumberFormatter = getWasmLineNumberFormatter(sourceId); + cm.setOption("lineNumberFormatter", lineNumberFormatter); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +export function updateDocument(editor, source) { + if (!source) { + return; + } + + const sourceId = source.id; + const doc = getDocument(sourceId) || editor.createDocument(); + editor.replaceDocument(doc); + + updateLineNumberFormat(editor, sourceId); +} + +/* used to apply the context menu wrap line option change to all the docs */ +export function updateDocuments(updater) { + for (const key in sourceDocs) { + if (sourceDocs[key].cm == null) { + continue; + } else { + updater(sourceDocs[key]); + } + } +} + +export function clearEditor(editor) { + const doc = editor.createDocument("", { name: "text" }); + editor.replaceDocument(doc); + resetLineNumberFormat(editor); +} + +export function showLoading(editor) { + let doc = getDocument("loading"); + + if (doc) { + editor.replaceDocument(doc); + } else { + doc = editor.createDocument(L10N.getStr("loadingText"), { name: "text" }); + setDocument("loading", doc); + } +} + +export function showErrorMessage(editor, msg) { + let error; + if (msg.includes("WebAssembly binary source is not available")) { + error = L10N.getStr("wasmIsNotAvailable"); + } else { + error = L10N.getFormatStr("errorLoadingText3", msg); + } + const doc = editor.createDocument(error, { name: "text" }); + editor.replaceDocument(doc); + resetLineNumberFormat(editor); +} + +const contentTypeModeMap = new Map([ + ["text/javascript", { name: "javascript" }], + ["text/typescript", { name: "javascript", typescript: true }], + ["text/coffeescript", { name: "coffeescript" }], + [ + "text/typescript-jsx", + { + name: "jsx", + base: { name: "javascript", typescript: true }, + }, + ], + ["text/jsx", { name: "jsx" }], + ["text/x-elm", { name: "elm" }], + ["text/x-clojure", { name: "clojure" }], + ["text/x-clojurescript", { name: "clojure" }], + ["text/wasm", { name: "text" }], + ["text/html", { name: "htmlmixed" }], + ["text/plain", { name: "text" }], +]); + +const nonJSLanguageExtensionMap = new Map([ + ["c", { name: "text/x-csrc" }], + ["kt", { name: "text/x-kotlin" }], + ["cpp", { name: "text/x-c++src" }], + ["m", { name: "text/x-objectivec" }], + ["rs", { name: "text/x-rustsrc" }], + ["hx", { name: "text/x-haxe" }], +]); + +/** + * Returns Code Mirror mode for source content type + */ +// eslint-disable-next-line complexity +export function getMode(source, sourceTextContent, symbols) { + const content = sourceTextContent.value; + // Disable modes for minified files with 1+ million characters (See Bug 1569829). + if ( + content.type === "text" && + isMinified(source, sourceTextContent) && + content.value.length > 1000000 + ) { + return contentTypeModeMap.get("text/plain"); + } + + if (content.type !== "text") { + return contentTypeModeMap.get("text/plain"); + } + + const extension = source.displayURL.fileExtension; + if (extension === "jsx" || (symbols && symbols.hasJsx)) { + if (symbols && symbols.hasTypes) { + return contentTypeModeMap.get("text/typescript-jsx"); + } + return contentTypeModeMap.get("text/jsx"); + } + + if (symbols && symbols.hasTypes) { + if (symbols.hasJsx) { + return contentTypeModeMap.get("text/typescript-jsx"); + } + + return contentTypeModeMap.get("text/typescript"); + } + + // check for C and other non JS languages + if (nonJSLanguageExtensionMap.has(extension)) { + return nonJSLanguageExtensionMap.get(extension); + } + + // if the url ends with a known Javascript-like URL, provide JavaScript mode. + if (javascriptLikeExtensions.has(extension)) { + return contentTypeModeMap.get("text/javascript"); + } + + const { contentType, value: text } = content; + // Use HTML mode for files in which the first non whitespace + // character is `<` regardless of extension. + const isHTMLLike = () => text.match(/^\s*</); + if (!contentType) { + if (isHTMLLike()) { + return contentTypeModeMap.get("text/html"); + } + return contentTypeModeMap.get("text/plain"); + } + + // // @flow or /* @flow */ + if (text.match(/^\s*(\/\/ @flow|\/\* @flow \*\/)/)) { + return contentTypeModeMap.get("text/typescript"); + } + + if (contentTypeModeMap.has(contentType)) { + return contentTypeModeMap.get(contentType); + } + + if (isHTMLLike()) { + return contentTypeModeMap.get("text/html"); + } + + return contentTypeModeMap.get("text/plain"); +} + +function setMode(editor, source, sourceTextContent, symbols) { + const mode = getMode(source, sourceTextContent, symbols); + const currentMode = editor.codeMirror.getOption("mode"); + if (!currentMode || currentMode.name != mode.name) { + editor.setMode(mode); + } +} + +/** + * Handle getting the source document or creating a new + * document with the correct mode and text. + */ +export function showSourceText(editor, source, sourceTextContent, symbols) { + if (hasDocument(source.id)) { + const doc = getDocument(source.id); + if (editor.codeMirror.doc === doc) { + setMode(editor, source, sourceTextContent, symbols); + return; + } + + editor.replaceDocument(doc); + updateLineNumberFormat(editor, source.id); + setMode(editor, source, sourceTextContent, symbols); + return; + } + + const content = sourceTextContent.value; + + const doc = editor.createDocument( + // We can set wasm text content directly from the constructor, so we pass an empty string + // here, and set the text after replacing the document. + content.type !== "wasm" ? content.value : "", + getMode(source, sourceTextContent, symbols) + ); + + setDocument(source.id, doc); + editor.replaceDocument(doc); + + if (content.type === "wasm") { + const wasmLines = renderWasmText(source.id, content); + // cm will try to split into lines anyway, saving memory + editor.setText({ split: () => wasmLines, match: () => false }); + } + + updateLineNumberFormat(editor, source.id); +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.css b/devtools/client/debugger/src/utils/editor/source-editor.css new file mode 100644 index 0000000000..b2ae305657 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.css @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --breakpoint-active-color: rgba(44, 187, 15, 0.2); + --breakpoint-active-color-hover: rgba(44, 187, 15, 0.5); + --debug-line-background: rgba(226, 236, 247, 0.5); + --debug-line-border: rgb(145, 188, 219); +} + +.theme-dark:root { + --debug-line-background: rgb(73, 82, 103); + --debug-line-border: rgb(119, 134, 162); + --breakpoint-active-color: rgba(45, 210, 158, 0.5); + --breakpoint-active-color-hover: rgba(0, 255, 175, 0.7); +} + +.CodeMirror .errors { + width: 16px; +} + +.CodeMirror .error { + display: inline-block; + margin-left: 5px; + width: 12px; + height: 12px; + opacity: 0.75; +} + +.CodeMirror .hit-counts { + width: 6px; +} + +.CodeMirror .hit-count { + display: inline-block; + height: 12px; + border: solid rgba(0, 0, 0, 0.2); + border-width: 1px 1px 1px 0; + border-radius: 0 3px 3px 0; + padding: 0 3px; + font-size: 10px; + pointer-events: none; +} + +.theme-dark .debug-line .CodeMirror-linenumber { + color: #c0c0c0; +} + +.debug-line .CodeMirror-line { + background-color: var(--debug-line-background) !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.debug-line .CodeMirror-activeline-background { + display: none; +} + +.CodeMirror { + cursor: text; + height: 100%; +} + +.CodeMirror-gutters { + cursor: default; +} + +/* This is to avoid the fake horizontal scrollbar div of codemirror to go 0 +height when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-height` specific to the `scrollbar[orient="horizontal"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-hscrollbar { + min-height: 10px; +} + +/* This is to avoid the fake vertical scrollbar div of codemirror to go 0 +width when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-width` specific to the `scrollbar[orient="vertical"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-vscrollbar { + min-width: 10px; +} + +.cm-trailingspace { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg=="); + opacity: 0.75; + background-position: left bottom; + background-repeat: repeat-x; +} + +/* CodeMirror dialogs styling */ + +.CodeMirror-dialog { + padding: 4px 3px; +} + +.CodeMirror-dialog, +.CodeMirror-dialog input { + font: message-box; +} + +/* Fold addon */ + +.CodeMirror-foldmarker { + color: blue; + text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, + #b9f -1px 1px 2px; + font-family: sans-serif; + line-height: 0.3; + cursor: pointer; +} + +.CodeMirror-foldgutter { + width: 10px; +} + +.CodeMirror-foldgutter-open, +.CodeMirror-foldgutter-folded { + color: #555; + cursor: pointer; + line-height: 1; + padding: 0 1px; +} + +.CodeMirror-foldgutter-open::after, +.CodeMirror-foldgutter-open::before, +.CodeMirror-foldgutter-folded::after, +.CodeMirror-foldgutter-folded::before { + content: ""; + height: 0; + width: 0; + position: absolute; + border: 4px solid transparent; +} + +.CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-codemirror-gutter-background); + top: 4px; +} + +.CodeMirror-foldgutter-open::before { + border-top-color: var(--theme-body-color); + top: 5px; +} + +.new-breakpoint .CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-open::before { + border-top-color: white; +} + +.CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-codemirror-gutter-background); + left: 3px; + top: 3px; +} + +.CodeMirror-foldgutter-folded::before { + border-left-color: var(--theme-body-color); + left: 4px; + top: 3px; +} + +.new-breakpoint .CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-folded::before { + border-left-color: white; +} + +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 2px; + border-radius: 3px; + font-size: 90%; + max-height: 20em; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 0 4px; + border-radius: 2px; + max-width: 19em; + overflow: hidden; + white-space: pre; + cursor: pointer; +} + +.CodeMirror-Tern-completion { + padding-inline-start: 22px; + position: relative; + line-height: 18px; +} + +.CodeMirror-Tern-completion:before { + position: absolute; + left: 2px; + bottom: 2px; + border-radius: 50%; + font-size: 12px; + font-weight: bold; + height: 15px; + width: 15px; + line-height: 16px; + text-align: center; + color: #ffffff; + box-sizing: border-box; +} + +.CodeMirror-Tern-completion-unknown:before { + content: "?"; +} + +.CodeMirror-Tern-completion-object:before { + content: "O"; +} + +.CodeMirror-Tern-completion-fn:before { + content: "F"; +} + +.CodeMirror-Tern-completion-array:before { + content: "A"; +} + +.CodeMirror-Tern-completion-number:before { + content: "N"; +} + +.CodeMirror-Tern-completion-string:before { + content: "S"; +} + +.CodeMirror-Tern-completion-bool:before { + content: "B"; +} + +.CodeMirror-Tern-completion-guess { + color: #999; +} + +.CodeMirror-Tern-tooltip { + border-radius: 3px; + padding: 2px 5px; + white-space: pre-wrap; + max-width: 40em; + position: absolute; + z-index: 10; +} + +.CodeMirror-Tern-hint-doc { + max-width: 25em; +} + +.CodeMirror-Tern-farg-current { + text-decoration: underline; +} + +.CodeMirror-Tern-fhint-guess { + opacity: 0.7; +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.js b/devtools/client/debugger/src/utils/editor/source-editor.js new file mode 100644 index 0000000000..8501e22a55 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * CodeMirror source editor utils + * @module utils/source-editor + */ + +const CodeMirror = require("codemirror"); + +require("raw!chrome://devtools/content/shared/sourceeditor/codemirror/lib/codemirror.css"); +require("codemirror/mode/javascript/javascript"); +require("codemirror/mode/htmlmixed/htmlmixed"); +require("codemirror/mode/coffeescript/coffeescript"); +require("codemirror/mode/jsx/jsx"); +require("codemirror/mode/elm/elm"); +require("codemirror/mode/clojure/clojure"); +require("codemirror/mode/haxe/haxe"); +require("codemirror/addon/search/searchcursor"); +require("codemirror/addon/fold/foldcode"); +require("codemirror/addon/fold/brace-fold"); +require("codemirror/addon/fold/indent-fold"); +require("codemirror/addon/fold/foldgutter"); +require("codemirror/addon/runmode/runmode"); +require("codemirror/addon/selection/active-line"); +require("codemirror/addon/edit/matchbrackets"); +require("codemirror/addon/display/placeholder"); +require("codemirror/mode/clike/clike"); +require("codemirror/mode/rust/rust"); + +require("raw!chrome://devtools/content/debugger/src/utils/editor/source-editor.css"); + +// NOTE: we should eventually use debugger-html context type mode + +// Maximum allowed margin (in number of lines) from top or bottom of the editor +// while shifting to a line which was initially out of view. +const MAX_VERTICAL_OFFSET = 3; + +export default class SourceEditor { + opts; + editor; + + constructor(opts) { + this.opts = opts; + } + + appendToLocalElement(node) { + this.editor = CodeMirror(node, this.opts); + } + + destroy() { + // Unlink the current document. + if (this.editor.doc) { + this.editor.doc.cm = null; + } + } + + get codeMirror() { + return this.editor; + } + + get CodeMirror() { + return CodeMirror; + } + + setText(str) { + this.editor.setValue(str); + } + + getText() { + return this.editor.getValue(); + } + + setMode(value) { + this.editor.setOption("mode", value); + } + + /** + * Replaces the current document with a new source document + * @memberof utils/source-editor + */ + replaceDocument(doc) { + this.editor.swapDoc(doc); + } + + /** + * Creates a CodeMirror Document + * @returns CodeMirror.Doc + * @memberof utils/source-editor + */ + createDocument() { + return new CodeMirror.Doc(""); + } + + /** + * Aligns the provided line to either "top", "center" or "bottom" of the + * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or + * bottom. + * @memberof utils/source-editor + */ + alignLine(line, align = "top") { + const cm = this.editor; + const editorClientRect = cm.getWrapperElement().getBoundingClientRect(); + + const from = cm.lineAtHeight(editorClientRect.top, "page"); + const to = cm.lineAtHeight( + editorClientRect.height + editorClientRect.top, + "page" + ); + + const linesVisible = to - from; + const halfVisible = Math.round(linesVisible / 2); + + // If the target line is in view, skip the vertical alignment part. + if (line <= to && line >= from) { + return; + } + + // Setting the offset so that the line always falls in the upper half + // of visible lines (lower half for bottom aligned). + // MAX_VERTICAL_OFFSET is the maximum allowed value. + const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); + + let topLine = + { + center: Math.max(line - halfVisible, 0), + bottom: Math.max(line - linesVisible + offset, 0), + top: Math.max(line - offset, 0), + }[align || "top"] || offset; + + // Bringing down the topLine to total lines in the editor if exceeding. + topLine = Math.min(topLine, cm.lineCount()); + this.setFirstVisibleLine(topLine); + } + + /** + * Scrolls the view such that the given line number is the first visible line. + * @memberof utils/source-editor + */ + setFirstVisibleLine(line) { + const { top } = this.editor.charCoords({ line, ch: 0 }, "local"); + this.editor.scrollTo(0, top); + } +} diff --git a/devtools/client/debugger/src/utils/editor/source-search.js b/devtools/client/debugger/src/utils/editor/source-search.js new file mode 100644 index 0000000000..92097377ba --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-search.js @@ -0,0 +1,327 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import buildQuery from "../build-query"; + +/** + * @memberof utils/source-search + * @static + */ +function getSearchCursor(cm, query, pos, modifiers) { + const regexQuery = buildQuery(query, modifiers, { isGlobal: true }); + return cm.getSearchCursor(regexQuery, pos); +} + +/** + * @memberof utils/source-search + * @static + */ +function SearchState() { + this.posFrom = this.posTo = this.query = null; + this.overlay = null; + this.results = []; +} + +/** + * @memberof utils/source-search + * @static + */ +function getSearchState(cm, query) { + const state = cm.state.search || (cm.state.search = new SearchState()); + return state; +} + +function isWhitespace(query) { + return !query.match(/\S/); +} + +/** + * This returns a mode object used by CodeMirror's addOverlay function + * to parse and style tokens in the file. + * The mode object contains a tokenizer function (token) which takes + * a character stream as input, advances it a character at a time, + * and returns style(s) for that token. For more details see + * https://codemirror.net/5/doc/manual.html#modeapi + * + * @memberof utils/source-search + * @static + */ +function searchOverlay(query, modifiers) { + const regexQuery = buildQuery(query, modifiers, { + ignoreSpaces: true, + // regex must be global for the overlay + isGlobal: true, + }); + + return { + token: function (stream, state) { + // set the last index to be the current stream position + // this acts as an offset + regexQuery.lastIndex = stream.pos; + const match = regexQuery.exec(stream.string); + if (match && match.index === stream.pos) { + // if we have a match at the current stream position + // set the class for a match + stream.pos += match[0].length || 1; + return "highlight highlight-full"; + } + + if (match) { + // if we have a match somewhere in the line, go to that point in the + // stream + stream.pos = match.index; + } else { + // if we have no matches in this line, skip to the end of the line + stream.skipToEnd(); + } + + return null; + }, + }; +} + +/** + * @memberof utils/source-search + * @static + */ +function updateOverlay(cm, state, query, modifiers) { + cm.removeOverlay(state.overlay); + state.overlay = searchOverlay(query, modifiers); + cm.addOverlay(state.overlay, { opaque: false }); +} + +function updateCursor(cm, state, keepSelection) { + state.posTo = cm.getCursor("anchor"); + state.posFrom = cm.getCursor("head"); + + if (!keepSelection) { + state.posTo = { line: 0, ch: 0 }; + state.posFrom = { line: 0, ch: 0 }; + } +} + +export function getMatchIndex(count, currentIndex, rev) { + if (!rev) { + if (currentIndex == count - 1) { + return 0; + } + + return currentIndex + 1; + } + + if (currentIndex == 0) { + return count - 1; + } + + return currentIndex - 1; +} + +/** + * If there's a saved search, selects the next results. + * Otherwise, creates a new search and selects the first + * result. + * + * @memberof utils/source-search + * @static + */ +function doSearch( + ctx, + rev, + query, + keepSelection, + modifiers, + focusFirstResult = true +) { + const { cm, ed } = ctx; + if (!cm) { + return null; + } + const defaultIndex = { line: -1, ch: -1 }; + + return cm.operation(function () { + if (!query || isWhitespace(query)) { + clearSearch(cm, query); + return null; + } + + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + const searchLocation = searchNext(ctx, rev, query, isNewQuery, modifiers); + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText && searchLocation && focusFirstResult) { + ed.alignLine(searchLocation.from.line, "center"); + cm.setSelection(searchLocation.from, searchLocation.to); + } + + return searchLocation ? searchLocation.from : defaultIndex; + }); +} + +export function searchSourceForHighlight( + ctx, + rev, + query, + keepSelection, + modifiers, + line, + ch +) { + const { cm } = ctx; + if (!cm) { + return; + } + + cm.operation(function () { + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + findNextOnLine(ctx, rev, query, isNewQuery, modifiers, line, ch); + }); +} + +function getCursorPos(newQuery, rev, state) { + if (newQuery) { + return rev ? state.posFrom : state.posTo; + } + + return rev ? state.posTo : state.posFrom; +} + +/** + * Selects the next result of a saved search. + * + * @memberof utils/source-search + * @static + */ +function searchNext(ctx, rev, query, newQuery, modifiers) { + const { cm } = ctx; + let nextMatch; + cm.operation(function () { + const state = getSearchState(cm, query); + const pos = getCursorPos(newQuery, rev, state); + + if (!state.query) { + return; + } + + let cursor = getSearchCursor(cm, state.query, pos, modifiers); + + const location = rev + ? { line: cm.lastLine(), ch: null } + : { line: cm.firstLine(), ch: 0 }; + + if (!cursor.find(rev) && state.query) { + cursor = getSearchCursor(cm, state.query, location, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + nextMatch = { from: cursor.from(), to: cursor.to() }; + }); + + return nextMatch; +} + +function findNextOnLine(ctx, rev, query, newQuery, modifiers, line, ch) { + const { cm, ed } = ctx; + cm.operation(function () { + const pos = { line: line - 1, ch }; + let cursor = getSearchCursor(cm, query, pos, modifiers); + + if (!cursor.find(rev) && query) { + cursor = getSearchCursor(cm, query, pos, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText) { + ed.alignLine(cursor.from().line, "center"); + cm.setSelection(cursor.from(), cursor.to()); + } + }); +} + +/** + * Remove overlay. + * + * @memberof utils/source-search + * @static + */ +export function removeOverlay(ctx, query) { + const state = getSearchState(ctx.cm, query); + ctx.cm.removeOverlay(state.overlay); + const { line, ch } = ctx.cm.getCursor(); + ctx.cm.doc.setSelection({ line, ch }, { line, ch }, { scroll: false }); +} + +/** + * Clears the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function clearSearch(cm, query) { + const state = getSearchState(cm, query); + + state.results = []; + + if (!state.query) { + return; + } + cm.removeOverlay(state.overlay); + state.query = null; +} + +/** + * Starts a new search. + * + * @memberof utils/source-search + * @static + */ +export function find(ctx, query, keepSelection, modifiers, focusFirstResult) { + clearSearch(ctx.cm, query); + return doSearch( + ctx, + false, + query, + keepSelection, + modifiers, + focusFirstResult + ); +} + +/** + * Finds the next item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findNext(ctx, query, keepSelection, modifiers) { + return doSearch(ctx, false, query, keepSelection, modifiers); +} + +/** + * Finds the previous item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findPrev(ctx, query, keepSelection, modifiers) { + return doSearch(ctx, true, query, keepSelection, modifiers); +} + +export { buildQuery }; diff --git a/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap new file mode 100644 index 0000000000..f5bba6cd3e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createEditor Adds codeFolding 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": true, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": true, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + "CodeMirror-foldgutter", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; + +exports[`createEditor Returns a SourceEditor 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": false, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": false, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; diff --git a/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js new file mode 100644 index 0000000000..38e7241b2e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { createEditor } from "../create-editor"; +import SourceEditor from "../source-editor"; + +import { features } from "../../prefs"; + +describe("createEditor", () => { + test("Returns a SourceEditor", () => { + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).not.toContain("CodeMirror-foldgutter"); + }); + + test("Adds codeFolding", () => { + features.codeFolding = true; + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).toContain("CodeMirror-foldgutter"); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js new file mode 100644 index 0000000000..d657437b19 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + toEditorLine, + toEditorPosition, + toEditorRange, + toSourceLine, + scrollToColumn, + markText, + lineAtHeight, + getSourceLocationFromMouseEvent, + forEachLine, + removeLineClass, + clearLineClass, + getTextForLine, + getCursorLine, +} from "../index"; + +import { makeMockSource } from "../../test-mockup"; + +describe("toEditorLine", () => { + it("returns an editor line", () => { + const testId = "test-123"; + const line = 30; + expect(toEditorLine(testId, line)).toEqual(29); + }); +}); + +describe("toEditorPosition", () => { + it("returns an editor position", () => { + const loc = { source: { id: "source" }, line: 100, column: 25 }; + expect(toEditorPosition(loc)).toEqual({ + line: 99, + column: 25, + }); + }); +}); + +describe("toEditorRange", () => { + it("returns an editor range", () => { + const testId = "test-123"; + const loc = { + start: { source: { id: testId }, line: 100, column: 25 }, + end: { source: { id: testId }, line: 200, column: 0 }, + }; + expect(toEditorRange(testId, loc)).toEqual({ + start: { line: 99, column: 25 }, + end: { line: 199, column: 0 }, + }); + }); +}); + +describe("toSourceLine", () => { + it("returns a source line", () => { + const testId = "test-123"; + const line = 30; + expect(toSourceLine(testId, line)).toEqual(31); + }); +}); + +const codeMirror = { + doc: { + iter: jest.fn((_, __, cb) => cb()), + }, + lineCount: jest.fn(() => 100), + getLine: jest.fn(() => "something"), + getCursor: jest.fn(() => ({ line: 3 })), + getScrollerElement: jest.fn(() => ({ + offsetWidth: 100, + offsetHeight: 100, + })), + getScrollInfo: () => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + clientHeight: 100, + clientWidth: 100, + }), + removeLineClass: jest.fn(), + operation: jest.fn(cb => cb()), + charCoords: jest.fn(() => ({ + top: 100, + right: 50, + bottom: 100, + left: 50, + })), + coordsChar: jest.fn(() => ({ line: 6, ch: 30 })), + lineAtHeight: jest.fn(() => 300), + markText: jest.fn(), + scrollTo: jest.fn(), + defaultCharWidth: jest.fn(() => 8), + defaultTextHeight: jest.fn(() => 16), +}; + +const editor = { codeMirror }; + +describe("scrollToColumn", () => { + it("calls codemirror APIs charCoords, getScrollerElement, scrollTo", () => { + scrollToColumn(codeMirror, 60, 123); + expect(codeMirror.charCoords).toHaveBeenCalledWith( + { line: 60, ch: 123 }, + "local" + ); + expect(codeMirror.scrollTo).toHaveBeenCalledWith(0, 50); + }); +}); + +describe("markText", () => { + it("calls codemirror API markText & returns marker", () => { + const loc = { + start: { line: 10, column: 0 }, + end: { line: 30, column: 50 }, + }; + markText(editor, "test-123", loc); + expect(codeMirror.markText).toHaveBeenCalledWith( + { ch: loc.start.column, line: loc.start.line }, + { ch: loc.end.column, line: loc.end.line }, + { className: "test-123" } + ); + }); +}); + +describe("lineAtHeight", () => { + it("calls codemirror API lineAtHeight", () => { + const e = { clientX: 30, clientY: 60 }; + expect(lineAtHeight(editor, "test-123", e)).toEqual(301); + expect(editor.codeMirror.lineAtHeight).toHaveBeenCalledWith(e.clientY); + }); +}); + +describe("getSourceLocationFromMouseEvent", () => { + it("calls codemirror API coordsChar & returns location", () => { + const source = makeMockSource(undefined, "test-123"); + const e = { clientX: 30, clientY: 60 }; + expect(getSourceLocationFromMouseEvent(editor, source, e)).toEqual({ + source, + sourceId: source.id, + line: 7, + column: 31, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(editor.codeMirror.coordsChar).toHaveBeenCalledWith({ + left: 30, + top: 60, + }); + }); +}); + +describe("forEachLine", () => { + it("calls codemirror API operation && doc.iter across a doc", () => { + const test = jest.fn(); + forEachLine(codeMirror, test); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith(0, 100, test); + }); +}); + +describe("removeLineClass", () => { + it("calls codemirror API removeLineClass", () => { + const line = 3; + const className = "test-class"; + removeLineClass(codeMirror, line, className); + expect(codeMirror.removeLineClass).toHaveBeenCalledWith( + line, + "wrap", + className + ); + }); +}); + +describe("clearLineClass", () => { + it("Uses forEachLine & removeLineClass to clear class on all lines", () => { + codeMirror.operation.mockClear(); + codeMirror.doc.iter.mockClear(); + codeMirror.removeLineClass.mockClear(); + clearLineClass(codeMirror, "test-class"); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith( + 0, + 100, + expect.any(Function) + ); + expect(codeMirror.removeLineClass).toHaveBeenCalled(); + }); +}); + +describe("getTextForLine", () => { + it("calls codemirror API getLine & returns line text", () => { + getTextForLine(codeMirror, 3); + expect(codeMirror.getLine).toHaveBeenCalledWith(2); + }); +}); +describe("getCursorLine", () => { + it("calls codemirror API getCursor & returns line number", () => { + getCursorLine(codeMirror); + expect(codeMirror.getCursor).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js new file mode 100644 index 0000000000..65ab5152f6 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js @@ -0,0 +1,160 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import CodeMirror from "codemirror"; +import { getExpressionFromCoords } from "../get-expression"; + +describe("get-expression", () => { + let isCreateTextRangeDefined; + + beforeAll(() => { + if (document.body.createTextRange) { + isCreateTextRangeDefined = true; + } else { + isCreateTextRangeDefined = false; + // CodeMirror needs createTextRange + // https://discuss.codemirror.net/t/working-in-jsdom-or-node-js-natively/138/5 + document.body.createTextRange = () => ({ + getBoundingClientRect: jest.fn(), + getClientRects: () => ({}), + }); + } + }); + + afterAll(() => { + if (!isCreateTextRangeDefined) { + delete document.body.createTextRange; + } + }); + + describe("getExpressionFromCoords", () => { + it("returns null when location.line is greater than the lineCount", () => { + const cm = CodeMirror(document.body, { + value: "let Line1;\n" + "let Line2;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 3, + column: 1, + }); + expect(result).toBeNull(); + }); + + it("gets the expression using CodeMirror.getTokenAt", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 0 })), + doc: { + getLine: () => "", + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 1, column: 1 }); + expect(codemirrorMock.getTokenAt).toHaveBeenCalled(); + }); + + it("requests the correct line and column from codeMirror", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 1 })), + doc: { + getLine: jest.fn(() => ""), + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 20, column: 5 }); + // getExpressionsFromCoords uses one based line indexing + // CodeMirror uses zero based line indexing + expect(codemirrorMock.getTokenAt).toHaveBeenCalledWith({ + line: 19, + ch: 5, + }); + expect(codemirrorMock.doc.getLine).toHaveBeenCalledWith(19); + }); + + it("when called with column 0 returns null", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 0, + }); + expect(result).toBeNull(); + }); + + it("gets the expression when first token on the line", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 1, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 3 }); + }); + + it("includes previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 5, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 7 }); + }); + + it("includes multiple previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + + it("does not include tokens not part of the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 4 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js new file mode 100644 index 0000000000..c4aa277f26 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getTokenLocation } from "../get-token-location"; + +describe("getTokenLocation", () => { + const codemirror = { + coordsChar: jest.fn(() => ({ + line: 1, + ch: "C", + })), + }; + const token = { + getBoundingClientRect() { + return { + left: 10, + top: 20, + width: 10, + height: 10, + }; + }, + }; + it("calls into codeMirror", () => { + getTokenLocation(codemirror, token); + expect(codemirror.coordsChar).toHaveBeenCalledWith({ + left: 15, + top: 25, + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js new file mode 100644 index 0000000000..9d4b42e263 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getMode } from "../source-documents.js"; + +import { + makeMockSourceWithContent, + makeMockWasmSourceWithContent, +} from "../../test-mockup"; + +const defaultSymbolDeclarations = { + classes: [], + functions: [], + memberExpressions: [], + callExpressions: [], + objectProperties: [], + identifiers: [], + imports: [], + comments: [], + literals: [], + hasJsx: false, + hasTypes: false, + framework: undefined, +}; + +describe("source-documents", () => { + describe("getMode", () => { + it("// ", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + "// @flow" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("/* @flow */", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + " /* @flow */" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("mixed html", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "", + " <html" + ); + expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" }); + }); + + it("elm", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/x-elm", + 'main = text "Hello, World!"' + ); + expect(getMode(source, source.content)).toEqual({ name: "elm" }); + }); + + it("returns jsx if contentType jsx is given", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns jsx if sourceMetaData says it's a react component", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "", + "<h1></h1>" + ); + expect( + getMode(source, source.content, { + ...defaultSymbolDeclarations, + hasJsx: true, + }) + ).toEqual({ name: "jsx" }); + }); + + it("returns jsx if the fileExtension is .jsx", () => { + const source = makeMockSourceWithContent( + "myComponent.jsx", + undefined, + "", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns text/x-haxe if the file extension is .hx", () => { + const source = makeMockSourceWithContent( + "myComponent.hx", + undefined, + "", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" }); + }); + + it("typescript", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/typescript", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("typescript-jsx", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/typescript-jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content).base).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("cross-platform clojure(script) with reader conditionals", () => { + const source = makeMockSourceWithContent( + "my-clojurescript-source-with-reader-conditionals.cljc", + undefined, + "text/x-clojure", + "(defn str->int [s] " + + " #?(:clj (java.lang.Integer/parseInt s) " + + " :cljs (js/parseInt s)))" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("clojurescript", () => { + const source = makeMockSourceWithContent( + "my-clojurescript-source.cljs", + undefined, + "text/x-clojurescript", + "(+ 1 2 3)" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("coffeescript", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/coffeescript", + "x = (a) -> 3" + ); + expect(getMode(source, source.content)).toEqual({ name: "coffeescript" }); + }); + + it("wasm", () => { + const source = makeMockWasmSourceWithContent({ + binary: "\x00asm\x01\x00\x00\x00", + }); + expect(getMode(source, source.content.value)).toEqual({ name: "text" }); + }); + + it("marko", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.marko", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("es6", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.es6", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("vue", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.vue?query=string", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js new file mode 100644 index 0000000000..33f479766a --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + find, + searchSourceForHighlight, + getMatchIndex, + removeOverlay, +} from "../source-search"; + +const getCursor = jest.fn(() => ({ line: 90, ch: 54 })); +const cursor = { + find: jest.fn(), + from: jest.fn(), + to: jest.fn(), +}; +const getSearchCursor = jest.fn(() => cursor); +const modifiers = { + caseSensitive: false, + regexMatch: false, + wholeWord: false, +}; + +const getCM = () => ({ + operation: jest.fn(cb => cb()), + addOverlay: jest.fn(), + removeOverlay: jest.fn(), + getCursor, + getSearchCursor, + firstLine: jest.fn(), + state: {}, +}); + +describe("source-search", () => { + describe("find", () => { + it("calls into CodeMirror APIs via clearSearch & doSearch", () => { + const ctx = { cm: getCM() }; + expect(ctx.cm.state).toEqual({}); + find(ctx, "test", false, modifiers); + // First we check the APIs called via clearSearch + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + // Next those via doSearch + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + const search = { + query: "test", + posTo: { line: 0, ch: 0 }, + posFrom: { line: 0, ch: 0 }, + overlay: { token: expect.any(Function) }, + results: [], + }; + expect(ctx.cm.state).toEqual({ search }); + }); + + it("clears a previous overlay", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: { token: expect.any(Function) }, + results: [], + }; + find(ctx, "test", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith({ + token: expect.any(Function), + }); + }); + + it("clears for empty queries", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: null, + results: [], + }; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + ctx.cm.removeOverlay.mockClear(); + ctx.cm.state.search.query = "bar"; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + }); + }); + + describe("searchSourceForHighlight", () => { + it("calls into CodeMirror APIs and sets the correct selection", () => { + const line = 15; + const from = { line, ch: 1 }; + const to = { line, ch: 5 }; + const cm = { + ...getCM(), + setSelection: jest.fn(), + getSearchCursor: () => ({ + find: () => true, + from: () => from, + to: () => to, + }), + }; + const ed = { alignLine: jest.fn() }; + const ctx = { cm, ed }; + + expect(ctx.cm.state).toEqual({}); + searchSourceForHighlight(ctx, false, "test", false, modifiers, line, 1); + + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + expect(ed.alignLine).toHaveBeenCalledWith(line, "center"); + expect(cm.setSelection).toHaveBeenCalledWith(from, to); + }); + }); + + describe("findNext", () => {}); + + describe("findPrev", () => {}); + + describe("getMatchIndex", () => { + it("iterates in the matches", () => { + const count = 3; + + // reverse 2, 1, 0, 2 + + let matchIndex = getMatchIndex(count, 2, true); + expect(matchIndex).toBe(1); + + matchIndex = getMatchIndex(count, 1, true); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, true); + expect(matchIndex).toBe(2); + + // forward 1, 2, 0, 1 + + matchIndex = getMatchIndex(count, 1, false); + expect(matchIndex).toBe(2); + + matchIndex = getMatchIndex(count, 2, false); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, false); + expect(matchIndex).toBe(1); + }); + }); + + describe("removeOverlay", () => { + it("calls CodeMirror APIs: removeOverlay, getCursor & setSelection", () => { + const ctx = { + cm: { + removeOverlay: jest.fn(), + getCursor, + state: {}, + doc: { + setSelection: jest.fn(), + }, + }, + }; + removeOverlay(ctx, "test"); + expect(ctx.cm.removeOverlay).toHaveBeenCalled(); + expect(ctx.cm.getCursor).toHaveBeenCalled(); + expect(ctx.cm.doc.setSelection).toHaveBeenCalledWith( + { line: 90, ch: 54 }, + { line: 90, ch: 54 }, + { scroll: false } + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/token-events.js b/devtools/client/debugger/src/utils/editor/token-events.js new file mode 100644 index 0000000000..aba5b8c94b --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/token-events.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getTokenLocation } from "."; + +function isInvalidTarget(target) { + if (!target || !target.innerText) { + return true; + } + + const tokenText = target.innerText.trim(); + const cursorPos = target.getBoundingClientRect(); + + // exclude literal tokens where it does not make sense to show a preview + const invalidType = ["cm-atom", ""].includes(target.className); + + // exclude syntax where the expression would be a syntax error + const invalidToken = + tokenText === "" || tokenText.match(/^[(){}\|&%,.;=<>\+-/\*\s](?=)/); + + // exclude codemirror elements that are not tokens + const invalidTarget = + (target.parentElement && + !target.parentElement.closest(".CodeMirror-line")) || + cursorPos.top == 0; + + const invalidClasses = ["editor-mount"]; + if (invalidClasses.some(className => target.classList.contains(className))) { + return true; + } + + if (target.closest(".popover")) { + return true; + } + + return !!(invalidTarget || invalidToken || invalidType); +} + +function dispatch(codeMirror, eventName, data) { + codeMirror.constructor.signal(codeMirror, eventName, data); +} + +function invalidLeaveTarget(target) { + if (!target || target.closest(".popover")) { + return true; + } + + return false; +} + +export function onMouseOver(codeMirror) { + let prevTokenPos = null; + + function onMouseLeave(event) { + if (invalidLeaveTarget(event.relatedTarget)) { + addMouseLeave(event.target); + return; + } + + prevTokenPos = null; + dispatch(codeMirror, "tokenleave", event); + } + + function addMouseLeave(target) { + target.addEventListener("mouseleave", onMouseLeave, { + capture: true, + once: true, + }); + } + + return enterEvent => { + const { target } = enterEvent; + + if (isInvalidTarget(target)) { + return; + } + + const tokenPos = getTokenLocation(codeMirror, target); + + if ( + prevTokenPos?.line !== tokenPos?.line || + prevTokenPos?.column !== tokenPos?.column + ) { + addMouseLeave(target); + + dispatch(codeMirror, "tokenenter", { + event: enterEvent, + target, + tokenPos, + }); + prevTokenPos = tokenPos; + } + }; +} diff --git a/devtools/client/debugger/src/utils/environment.js b/devtools/client/debugger/src/utils/environment.js new file mode 100644 index 0000000000..6f68dd793c --- /dev/null +++ b/devtools/client/debugger/src/utils/environment.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function isNode() { + try { + return process.release.name == "node"; + } catch (e) { + return false; + } +} + +export function isNodeTest() { + return isNode() && process.env.NODE_ENV != "production"; +} diff --git a/devtools/client/debugger/src/utils/evaluation-result.js b/devtools/client/debugger/src/utils/evaluation-result.js new file mode 100644 index 0000000000..def02aacfa --- /dev/null +++ b/devtools/client/debugger/src/utils/evaluation-result.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function isFront(result) { + return !!result && typeof result === "object" && !!result.getGrip; +} + +export function getGrip(result) { + if (isFront(result)) { + return result.getGrip(); + } + + return result; +} + +export function getFront(result) { + return isFront(result) ? result : null; +} diff --git a/devtools/client/debugger/src/utils/expressions.js b/devtools/client/debugger/src/utils/expressions.js new file mode 100644 index 0000000000..f30d0c089c --- /dev/null +++ b/devtools/client/debugger/src/utils/expressions.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { correctIndentation } from "./indentation"; +import { getGrip, getFront } from "./evaluation-result"; + +const UNAVAILABLE_GRIP = { unavailable: true }; + +/* + * wrap the expression input in a try/catch so that it can be safely + * evaluated. + * + * NOTE: we add line after the expression to protect against comments. + */ +export function wrapExpression(input) { + return correctIndentation(` + try { + ${input} + } catch (e) { + e + } + `); +} + +function isUnavailable(value) { + return ( + value && + !!value.isError && + (value.class === "ReferenceError" || value.class === "TypeError") + ); +} + +/** + * + * @param {Object} expression: Expression item as stored in state.expressions in reducers/expressions.js + * @param {String} expression.input: evaluated expression string + * @param {Object} expression.value: evaluated expression result object as returned from ScriptCommand#execute + * @param {Object} expression.value.result: expression result, might be a primitive, a grip or a front + * @param {Object} expression.value.exception: expression result error, might be a primitive, a grip or a front + * @returns {Object} an object of the following shape: + * - expressionResultGrip: A primitive or a grip + * - expressionResultFront: An object front if it exists, or undefined + */ +export function getExpressionResultGripAndFront(expression) { + const { value } = expression; + + if (!value) { + return { expressionResultGrip: UNAVAILABLE_GRIP }; + } + + const expressionResultReturn = value.exception || value.result; + const valueGrip = getGrip(expressionResultReturn); + if (!valueGrip || isUnavailable(valueGrip)) { + return { expressionResultGrip: UNAVAILABLE_GRIP }; + } + + if (valueGrip.isError) { + const { name, message } = valueGrip.preview; + return { expressionResultGrip: `${name}: ${message}` }; + } + + return { + expressionResultGrip: valueGrip, + expressionResultFront: getFront(expressionResultReturn), + }; +} diff --git a/devtools/client/debugger/src/utils/function.js b/devtools/client/debugger/src/utils/function.js new file mode 100644 index 0000000000..39d0f5a9a7 --- /dev/null +++ b/devtools/client/debugger/src/utils/function.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isFulfilled } from "./async-value"; +import { findClosestFunction } from "./ast"; +import { correctIndentation } from "./indentation"; + +export function findFunctionText(line, source, sourceTextContent, symbols) { + const func = findClosestFunction(symbols, { + sourceId: source.id, + line, + column: Infinity, + }); + + if ( + source.isWasm || + !func || + !sourceTextContent || + !isFulfilled(sourceTextContent) || + sourceTextContent.value.type !== "text" + ) { + return null; + } + + const { + location: { start, end }, + } = func; + const lines = sourceTextContent.value.value.split("\n"); + const firstLine = lines[start.line - 1].slice(start.column); + const lastLine = lines[end.line - 1].slice(0, end.column); + const middle = lines.slice(start.line, end.line - 1); + const functionText = [firstLine, ...middle, lastLine].join("\n"); + const indentedFunctionText = correctIndentation(functionText); + + return indentedFunctionText; +} diff --git a/devtools/client/debugger/src/utils/indentation.js b/devtools/client/debugger/src/utils/indentation.js new file mode 100644 index 0000000000..80e5014f6d --- /dev/null +++ b/devtools/client/debugger/src/utils/indentation.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getIndentation(line) { + if (!line) { + return 0; + } + + const lineMatch = line.match(/^\s*/); + if (!lineMatch) { + return 0; + } + + return lineMatch[0].length; +} + +function getMaxIndentation(lines) { + const firstLine = lines[0]; + const secondLine = lines[1]; + const lastLine = lines[lines.length - 1]; + + const indentations = [ + getIndentation(firstLine), + getIndentation(secondLine), + getIndentation(lastLine), + ]; + + return Math.max(...indentations); +} + +export function correctIndentation(text) { + const lines = text.trim().split("\n"); + const indentation = getMaxIndentation(lines); + const formattedLines = lines.map(_line => + _line.replace(new RegExp(`^\\s{0,${indentation - 1}}`), "") + ); + + return formattedLines.join("\n"); +} diff --git a/devtools/client/debugger/src/utils/isMinified.js b/devtools/client/debugger/src/utils/isMinified.js new file mode 100644 index 0000000000..dc5963b1b2 --- /dev/null +++ b/devtools/client/debugger/src/utils/isMinified.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isFulfilled } from "./async-value"; + +// Used to detect minification for automatic pretty printing +const SAMPLE_SIZE = 50; +const INDENT_COUNT_THRESHOLD = 5; +const CHARACTER_LIMIT = 250; +const _minifiedCache = new Map(); + +export function isMinified(source, sourceTextContent) { + if (_minifiedCache.has(source.id)) { + return _minifiedCache.get(source.id); + } + + if ( + !sourceTextContent || + !isFulfilled(sourceTextContent) || + sourceTextContent.value.type !== "text" + ) { + return false; + } + + let text = sourceTextContent.value.value; + + let lineEndIndex = 0; + let lineStartIndex = 0; + let lines = 0; + let indentCount = 0; + let overCharLimit = false; + + // Strip comments. + text = text.replace(/\/\*[\S\s]*?\*\/|\/\/(.+|\n)/g, ""); + + while (lines++ < SAMPLE_SIZE) { + lineEndIndex = text.indexOf("\n", lineStartIndex); + if (lineEndIndex == -1) { + break; + } + if (/^\s+/.test(text.slice(lineStartIndex, lineEndIndex))) { + indentCount++; + } + // For files with no indents but are not minified. + if (lineEndIndex - lineStartIndex > CHARACTER_LIMIT) { + overCharLimit = true; + break; + } + lineStartIndex = lineEndIndex + 1; + } + + const minified = + (indentCount / lines) * 100 < INDENT_COUNT_THRESHOLD || overCharLimit; + + _minifiedCache.set(source.id, minified); + return minified; +} diff --git a/devtools/client/debugger/src/utils/location.js b/devtools/client/debugger/src/utils/location.js new file mode 100644 index 0000000000..eefe516f12 --- /dev/null +++ b/devtools/client/debugger/src/utils/location.js @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSelectedLocation } from "./selected-location"; +import { getSource } from "../selectors"; + +/** + * Note that arguments can be created via `createLocation`. + * But they can also be created via `createPendingLocation` in reducer/pending-breakpoints.js. + * Both will have similar line and column attributes. + */ +export function comparePosition(a, b) { + return a && b && a.line == b.line && a.column == b.column; +} + +export function createLocation({ + source, + sourceActor = null, + + // Line 0 represents no specific line chosen for action + line = 0, + column, + + sourceUrl = "", +}) { + return { + source, + sourceActor, + // Alias which should probably be migrate to query source and sourceActor? + sourceId: source.id, + sourceActorId: sourceActor?.id, + + line, + column, + + // Is this still used anywhere?? + sourceUrl, + }; +} + +/** + * Convert location objects created via `createLocation` into + * the format used by the Source Map Loader/Worker. + * It only needs sourceId, line and column attributes. + */ +export function debuggerToSourceMapLocation(location) { + return { + sourceId: location.source.id, + line: location.line, + column: location.column, + + // Also add sourceUrl attribute as this may be preserved in jest tests + // where we return the exact same object. + // This will be removed by bug 1822783. + sourceUrl: location.sourceUrl, + }; +} + +/** + * Pending location only need these three attributes, + * and especially doesn't need the large source and sourceActor objects of the regular location objects. + * + * @param {Object} location + */ +export function createPendingSelectedLocation(location) { + return { + url: location.source.url, + + line: location.line, + column: location.column, + }; +} + +export function sortSelectedLocations(locations, selectedSource) { + return Array.from(locations).sort((locationA, locationB) => { + const aSelected = getSelectedLocation(locationA, selectedSource); + const bSelected = getSelectedLocation(locationB, selectedSource); + + // Order the locations by line number… + if (aSelected.line < bSelected.line) { + return -1; + } + + if (aSelected.line > bSelected.line) { + return 1; + } + + // … and if we have the same line, we want to return location with undefined columns + // first, and then order them by column + if (aSelected.column == bSelected.column) { + return 0; + } + + if (aSelected.column === undefined) { + return -1; + } + + if (bSelected.column === undefined) { + return 1; + } + + return aSelected.column < bSelected.column ? -1 : 1; + }); +} + +/** + * Source map Loader/Worker and debugger frontend don't use the same objects for locations. + * Worker uses 'sourceId' attributes whereas the frontend has 'source' attribute. + */ +export function sourceMapToDebuggerLocation(state, location) { + // From MapScopes modules, we might re-process the exact same location objects + // for which we would already have computed the source object, + // and which would lack sourceId attribute. + if (location.source) { + return location; + } + + // SourceMapLoader doesn't known about debugger's source objects + // so that we have to fetch it from here + const source = getSource(state, location.sourceId); + if (!source) { + throw new Error(`Could not find source-map source ${location.sourceId}`); + } + + return createLocation({ + ...location, + source, + + // Ensure having location with sourceUrl attribute set. + // To be removed in bug 1822783. + sourceUrl: source.url, + }); +} diff --git a/devtools/client/debugger/src/utils/log.js b/devtools/client/debugger/src/utils/log.js new file mode 100644 index 0000000000..6e2e3b7b15 --- /dev/null +++ b/devtools/client/debugger/src/utils/log.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* */ + +/** + * + * Utils for logging to the console + * Suppresses logging in non-development environment + * + * @module utils/log + */ + +import { prefs } from "./prefs"; + +/** + * Produces a formatted console log line by imploding args, prefixed by [log] + * + * function input: log(["hello", "world"]) + * console output: [log] hello world + * + * @memberof utils/log + * @static + */ +export function log(...args) { + if (prefs.logging) { + console.log(...args); + } +} diff --git a/devtools/client/debugger/src/utils/memoizableAction.js b/devtools/client/debugger/src/utils/memoizableAction.js new file mode 100644 index 0000000000..0f465177e2 --- /dev/null +++ b/devtools/client/debugger/src/utils/memoizableAction.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { asSettled } from "./async-value"; +import { validateContext } from "./context"; + +/* + * memoizableActon is a utility for actions that should only be performed + * once per key. It is useful for loading sources, parsing symbols ... + * + * @getValue - gets the result from the redux store + * @createKey - creates a key for the requests map + * @action - kicks off the async work for the action + * + * + * For Example + * + * export const setItem = memoizeableAction( + * "setItem", + * { + * hasValue: ({ a }, { getState }) => hasItem(getState(), a), + * getValue: ({ a }, { getState }) => getItem(getState(), a), + * createKey: ({ a }) => a, + * action: ({ a }, thunkArgs) => doSetItem(a, thunkArgs) + * } + * ); + * + */ +export function memoizeableAction(name, { getValue, createKey, action }) { + const requests = new Map(); + return args => async thunkArgs => { + let result = asSettled(getValue(args, thunkArgs)); + if (!result) { + const key = createKey(args, thunkArgs); + if (!requests.has(key)) { + requests.set( + key, + (async () => { + try { + await action(args, thunkArgs); + } catch (e) { + console.warn(`Action ${name} had an exception:`, e); + } finally { + requests.delete(key); + } + })() + ); + } + + await requests.get(key); + + if (args.cx) { + validateContext(thunkArgs.getState(), args.cx); + } + + result = asSettled(getValue(args, thunkArgs)); + if (!result) { + // Returning null here is not ideal. This means that the action + // resolved but 'getValue' didn't return a loaded value, for instance + // if the data the action was meant to store was deleted. In a perfect + // world we'd throw a ContextError here or handle cancellation somehow. + // Throwing will also allow us to change the return type on the action + // to always return a promise for the getValue AsyncValue type, but + // for now we have to add an additional '| null' for this case. + return null; + } + } + + if (result.state === "rejected") { + throw result.value; + } + return result.value; + }; +} diff --git a/devtools/client/debugger/src/utils/memoize.js b/devtools/client/debugger/src/utils/memoize.js new file mode 100644 index 0000000000..3f634b326d --- /dev/null +++ b/devtools/client/debugger/src/utils/memoize.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function hasValue(keys, store) { + let currentStore = store; + for (const key of keys) { + if (!currentStore || !currentStore.has(key)) { + return false; + } + + currentStore = currentStore.get(key); + } + return true; +} + +function getValue(keys, store) { + let currentStore = store; + for (const key of keys) { + if (!currentStore) { + return null; + } + currentStore = currentStore.get(key); + } + + return currentStore; +} + +function setValue(keys, store, value) { + const keysExceptLast = keys.slice(0, -1); + const lastKey = keys[keys.length - 1]; + + let currentStore = store; + for (const key of keysExceptLast) { + if (!currentStore) { + return; + } + + if (!currentStore.has(key)) { + currentStore.set(key, new WeakMap()); + } + currentStore = currentStore.get(key); + } + + if (currentStore) { + currentStore.set(lastKey, value); + } +} + +// memoize with n arguments +export default function memoize(func) { + const store = new WeakMap(); + + return function (...keys) { + if (hasValue(keys, store)) { + return getValue(keys, store); + } + + const newValue = func.apply(null, keys); + setValue(keys, store, newValue); + return newValue; + }; +} diff --git a/devtools/client/debugger/src/utils/memoizeLast.js b/devtools/client/debugger/src/utils/memoizeLast.js new file mode 100644 index 0000000000..b3c46cab57 --- /dev/null +++ b/devtools/client/debugger/src/utils/memoizeLast.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function memoizeLast(fn) { + let lastArgs; + let lastResult; + + const memoized = (...args) => { + if ( + lastArgs && + args.length === lastArgs.length && + args.every((arg, i) => arg === lastArgs[i]) + ) { + return lastResult; + } + + lastArgs = args; + lastResult = fn(...args); + + return lastResult; + }; + + return memoized; +} + +export default memoizeLast; diff --git a/devtools/client/debugger/src/utils/moz.build b/devtools/client/debugger/src/utils/moz.build new file mode 100644 index 0000000000..5d729528e4 --- /dev/null +++ b/devtools/client/debugger/src/utils/moz.build @@ -0,0 +1,54 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "breakpoint", + "editor", + "pause", + "sources-tree", +] + +CompiledModules( + "assert.js", + "ast.js", + "async-value.js", + "bootstrap.js", + "build-query.js", + "clipboard.js", + "connect.js", + "context.js", + "dbg.js", + "DevToolsUtils.js", + "environment.js", + "expressions.js", + "evaluation-result.js", + "function.js", + "indentation.js", + "isMinified.js", + "location.js", + "log.js", + "memoize.js", + "memoizeLast.js", + "memoizableAction.js", + "path.js", + "prefs.js", + "preview.js", + "quick-open.js", + "result-list.js", + "selected-location.js", + "shallow-equal.js", + "source-maps.js", + "source-queue.js", + "source.js", + "tabs.js", + "task.js", + "telemetry.js", + "text.js", + "ui.js", + "url.js", + "utils.js", + "wasm.js", + "worker.js", +) diff --git a/devtools/client/debugger/src/utils/path.js b/devtools/client/debugger/src/utils/path.js new file mode 100644 index 0000000000..bc7f975919 --- /dev/null +++ b/devtools/client/debugger/src/utils/path.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function basename(path) { + return path.split("/").pop(); +} + +export function dirname(path) { + const idx = path.lastIndexOf("/"); + return path.slice(0, idx); +} + +export function isURL(str) { + return str.includes("://"); +} + +export function isAbsolute(str) { + return str[0] === "/"; +} + +export function join(base, dir) { + return `${base}/${dir}`; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js new file mode 100644 index 0000000000..ad5af11980 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/annotateFrames.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFrameUrl } from "./getFrameUrl"; +import { getLibraryFromUrl } from "./getLibraryFromUrl"; + +export function annotateFrames(frames) { + const annotatedFrames = frames.map(f => annotateFrame(f, frames)); + return annotateBabelAsyncFrames(annotatedFrames); +} + +function annotateFrame(frame, frames) { + const library = getLibraryFromUrl(frame, frames); + if (library) { + return { ...frame, library }; + } + + return frame; +} + +function annotateBabelAsyncFrames(frames) { + const babelFrameIndexes = getBabelFrameIndexes(frames); + const isBabelFrame = frameIndex => babelFrameIndexes.includes(frameIndex); + + return frames.map((frame, frameIndex) => + isBabelFrame(frameIndex) ? { ...frame, library: "Babel" } : frame + ); +} + +/** + * Returns all the indexes that are part of a babel async call stack. + * + * @param {Array<Object>} frames + * @returns Array<Integer> + */ +function getBabelFrameIndexes(frames) { + const startIndexes = []; + const endIndexes = []; + + frames.forEach((frame, index) => { + const frameUrl = getFrameUrl(frame); + + if ( + frameUrl.match(/regenerator-runtime/i) && + frame.displayName === "tryCatch" + ) { + startIndexes.push(index); + } + if (frame.displayName === "flush" && frameUrl.match(/_microtask/i)) { + endIndexes.push(index); + } + if (frame.displayName === "_asyncToGenerator/<") { + endIndexes.push(index + 1); + } + }); + + if (startIndexes.length != endIndexes.length || startIndexes.length === 0) { + return []; + } + + const babelFrameIndexes = []; + // We have the same number of start and end indexes, we can loop through one of them to + // build our async call stack index ranges + // e.g. if we have startIndexes: [1,5] and endIndexes: [3,8], we want to return [1,2,3,5,6,7,8] + startIndexes.forEach((startIndex, index) => { + const matchingEndIndex = endIndexes[index]; + for (let i = startIndex; i <= matchingEndIndex; i++) { + babelFrameIndexes.push(i); + } + }); + return babelFrameIndexes; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js new file mode 100644 index 0000000000..e497933e7f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/collapseFrames.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// eslint-disable-next-line max-len +import { getFrameUrl } from "./getFrameUrl"; + +function collapseLastFrames(frames) { + const index = frames.findIndex(frame => + getFrameUrl(frame).match(/webpack\/bootstrap/i) + ); + + if (index == -1) { + return { newFrames: frames, lastGroup: [] }; + } + + const newFrames = frames.slice(0, index); + const lastGroup = frames.slice(index); + return { newFrames, lastGroup }; +} + +export function collapseFrames(frames) { + // We collapse groups of one so that user frames + // are not in a group of one + function addGroupToList(group, list) { + if (!group) { + return list; + } + + if (group.length > 1) { + list.push(group); + } else { + list = list.concat(group); + } + + return list; + } + const { newFrames, lastGroup } = collapseLastFrames(frames); + frames = newFrames; + let items = []; + let currentGroup = null; + let prevItem = null; + for (const frame of frames) { + const prevLibrary = prevItem?.library; + + if (!currentGroup) { + currentGroup = [frame]; + } else if (prevLibrary && prevLibrary == frame.library) { + currentGroup.push(frame); + } else { + items = addGroupToList(currentGroup, items); + currentGroup = [frame]; + } + + prevItem = frame; + } + + items = addGroupToList(currentGroup, items); + items = addGroupToList(lastGroup, items); + return items; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/displayName.js b/devtools/client/debugger/src/utils/pause/frames/displayName.js new file mode 100644 index 0000000000..fa261b9d78 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/displayName.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// eslint-disable-next-line max-len + +// Decodes an anonymous naming scheme that +// spider monkey implements based on "Naming Anonymous JavaScript Functions" +// http://johnjbarton.github.io/nonymous/index.html +const objectProperty = /([\w\d\$#]+)$/; +const arrayProperty = /\[(.*?)\]$/; +const functionProperty = /([\w\d]+)[\/\.<]*?$/; +const annonymousProperty = /([\w\d]+)\(\^\)$/; + +export function simplifyDisplayName(displayName) { + // if the display name has a space it has already been mapped + if (!displayName || /\s/.exec(displayName)) { + return displayName; + } + + const scenarios = [ + objectProperty, + arrayProperty, + functionProperty, + annonymousProperty, + ]; + + for (const reg of scenarios) { + const match = reg.exec(displayName); + if (match) { + return match[1]; + } + } + + return displayName; +} + +const displayNameMap = { + Babel: { + tryCatch: "Async", + }, + Backbone: { + "extend/child": "Create Class", + ".create": "Create Model", + }, + jQuery: { + "jQuery.event.dispatch": "Dispatch Event", + }, + React: { + // eslint-disable-next-line max-len + "ReactCompositeComponent._renderValidatedComponentWithoutOwnerOrContext/renderedElement<": + "Render", + _renderValidatedComponentWithoutOwnerOrContext: "Render", + }, + VueJS: { + "renderMixin/Vue.prototype._render": "Render", + }, + Webpack: { + // eslint-disable-next-line camelcase + __webpack_require__: "Bootstrap", + }, +}; + +function mapDisplayNames(frame, library) { + const { displayName } = frame; + return displayNameMap[library]?.[displayName] || displayName; +} + +function getFrameDisplayName(frame) { + const { displayName, originalDisplayName, userDisplayName, name } = frame; + return originalDisplayName || userDisplayName || displayName || name; +} + +export function formatDisplayName( + frame, + { shouldMapDisplayName = true } = {}, + l10n +) { + const { library } = frame; + let displayName = getFrameDisplayName(frame); + if (library && shouldMapDisplayName) { + displayName = mapDisplayNames(frame, library); + } + + return simplifyDisplayName(displayName) || l10n.getStr("anonymousFunction"); +} + +export function formatCopyName(frame, l10n) { + const displayName = formatDisplayName(frame, undefined, l10n); + if (!frame.source) { + throw new Error("no frame source"); + } + const fileName = frame.source.url || frame.source.id; + const frameLocation = frame.location.line; + + return `${displayName} (${fileName}#${frameLocation})`; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js new file mode 100644 index 0000000000..b03a4fd30f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getFrameUrl.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getFrameUrl(frame) { + return frame?.source?.url ?? ""; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js new file mode 100644 index 0000000000..17e294f258 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/getLibraryFromUrl.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFrameUrl } from "./getFrameUrl"; + +const libraryMap = [ + { + label: "Backbone", + pattern: /backbone/i, + }, + { + label: "Babel", + pattern: /node_modules\/@babel/i, + }, + { + label: "jQuery", + pattern: /jquery/i, + }, + { + label: "Preact", + pattern: /preact/i, + }, + { + label: "React", + pattern: + /(node_modules\/(?:react(-dom)?(-dev)?\/))|(react(-dom)?(-dev)?(\.[a-z]+)*\.js$)/, + }, + { + label: "Immutable", + pattern: /immutable/i, + }, + { + label: "Webpack", + pattern: /webpack\/bootstrap/i, + }, + { + label: "Express", + pattern: /node_modules\/express/, + }, + { + label: "Pug", + pattern: /node_modules\/pug/, + }, + { + label: "ExtJS", + pattern: /\/ext-all[\.\-]/, + }, + { + label: "MobX", + pattern: /mobx/i, + }, + { + label: "Underscore", + pattern: /underscore/i, + }, + { + label: "Lodash", + pattern: /lodash/i, + }, + { + label: "Ember", + pattern: /ember/i, + }, + { + label: "Choo", + pattern: /choo/i, + }, + { + label: "VueJS", + pattern: /vue(?:\.[a-z]+)*\.js/i, + }, + { + label: "RxJS", + pattern: /rxjs/i, + }, + { + label: "Angular", + pattern: /angular(?!.*\/app\/)/i, + contextPattern: /zone\.js/, + }, + { + label: "Redux", + pattern: /redux/i, + }, + { + label: "Dojo", + pattern: /dojo/i, + }, + { + label: "Marko", + pattern: /marko/i, + }, + { + label: "NuxtJS", + pattern: /[\._]nuxt/i, + }, + { + label: "Aframe", + pattern: /aframe/i, + }, + { + label: "NextJS", + pattern: /[\._]next/i, + }, +]; + +export function getLibraryFromUrl(frame, callStack = []) { + // @TODO each of these fns calls getFrameUrl, just call it once + // (assuming there's not more complex logic to identify a lib) + const frameUrl = getFrameUrl(frame); + + // Let's first check if the frame match a defined pattern. + let match = libraryMap.find(o => o.pattern.test(frameUrl)); + if (match) { + return match.label; + } + + // If it does not, it might still be one of the case where the file is used + // by a library but the name has not enough specificity. In such case, we want + // to only return the library name if there are frames matching the library + // pattern in the callStack (e.g. `zone.js` is used by Angular, but the name + // could be quite common and return false positive if evaluated alone. So we + // only return Angular if there are other frames matching Angular). + match = libraryMap.find( + o => o.contextPattern && o.contextPattern.test(frameUrl) + ); + if (match) { + const contextMatch = callStack.some(f => { + const url = getFrameUrl(f); + if (!url) { + return false; + } + + return libraryMap.some(o => o.pattern.test(url)); + }); + + if (contextMatch) { + return match.label; + } + } + + return null; +} diff --git a/devtools/client/debugger/src/utils/pause/frames/index.js b/devtools/client/debugger/src/utils/pause/frames/index.js new file mode 100644 index 0000000000..0912be05d5 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/index.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./annotateFrames"; +export * from "./collapseFrames"; +export * from "./displayName"; +export * from "./getFrameUrl"; +export * from "./getLibraryFromUrl"; diff --git a/devtools/client/debugger/src/utils/pause/frames/moz.build b/devtools/client/debugger/src/utils/pause/frames/moz.build new file mode 100644 index 0000000000..5bb330a57f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "annotateFrames.js", + "collapseFrames.js", + "displayName.js", + "getFrameUrl.js", + "getLibraryFromUrl.js", + "index.js", +) diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap new file mode 100644 index 0000000000..89dbccd374 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/__snapshots__/collapseFrames.spec.js.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collapseFrames default 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], +] +`; + +exports[`collapseFrames promises 1`] = ` +Array [ + Object { + "displayName": "a", + }, + Array [ + Object { + "displayName": "b", + "library": "React", + }, + Object { + "displayName": "c", + "library": "React", + }, + ], + Object { + "asyncCause": "promise callback", + "displayName": "d", + "library": undefined, + }, + Array [ + Object { + "displayName": "e", + "library": "React", + }, + Object { + "displayName": "f", + "library": "React", + }, + ], + Object { + "asyncCause": null, + "displayName": "g", + "library": undefined, + }, +] +`; diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js new file mode 100644 index 0000000000..6579293b5f --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/annotateFrames.spec.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { annotateFrames } from "../annotateFrames"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("annotateFrames", () => { + it("should return Angular", () => { + const callstack = [ + makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ), + makeMockFrameWithURL("/node_modules/zone/zone.js"), + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + const frames = annotateFrames(callstack); + expect(frames).toEqual(callstack.map(f => ({ ...f, library: "Angular" }))); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js new file mode 100644 index 0000000000..15210e0437 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/collapseFrames.spec.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { collapseFrames } from "../collapseFrames"; + +describe("collapseFrames", () => { + it("default", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + ]); + + expect(groups).toMatchSnapshot(); + }); + + it("promises", () => { + const groups = collapseFrames([ + { displayName: "a" }, + + { displayName: "b", library: "React" }, + { displayName: "c", library: "React" }, + { + displayName: "d", + library: undefined, + asyncCause: "promise callback", + }, + { displayName: "e", library: "React" }, + { displayName: "f", library: "React" }, + { displayName: "g", library: undefined, asyncCause: null }, + ]); + + expect(groups).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js new file mode 100644 index 0000000000..d969c18753 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/displayName.spec.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + formatCopyName, + formatDisplayName, + simplifyDisplayName, +} from "../displayName"; + +import { makeMockFrame, makeMockSource } from "../../../test-mockup"; + +describe("formatCopyName", () => { + it("simple", () => { + const source = makeMockSource("todo-view.js"); + const frame = makeMockFrame(undefined, source, undefined, 12, "child"); + + expect(formatCopyName(frame, L10N)).toEqual("child (todo-view.js#12)"); + }); +}); + +describe("formatting display names", () => { + it("uses a library description", () => { + const source = makeMockSource("assets/backbone.js"); + const frame = { + ...makeMockFrame(undefined, source, undefined, undefined, "extend/child"), + library: "Backbone", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("Create Class"); + }); + + it("shortens an anonymous function", () => { + const source = makeMockSource("assets/bar.js"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "extend/child/bar/baz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("baz"); + }); + + it("does not truncates long function names", () => { + const source = makeMockSource("extend/child/bar/baz"); + const frame = makeMockFrame( + undefined, + source, + undefined, + undefined, + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + + expect(formatDisplayName(frame, undefined, L10N)).toEqual( + "bazbazbazbazbazbazbazbazbazbazbazbazbaz" + ); + }); + + it("returns the original function name when present", () => { + const source = makeMockSource("entry.js"); + const frame = { + ...makeMockFrame(undefined, source), + originalDisplayName: "originalFn", + displayName: "fn", + }; + + expect(formatDisplayName(frame, undefined, L10N)).toEqual("originalFn"); + }); + + it("returns anonymous when displayName is undefined", () => { + const frame = { ...makeMockFrame(), displayName: undefined }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is null", () => { + const frame = { ...makeMockFrame(), displayName: null }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); + + it("returns anonymous when displayName is an empty string", () => { + const frame = { ...makeMockFrame(), displayName: "" }; + expect(formatDisplayName(frame, undefined, L10N)).toEqual("<anonymous>"); + }); +}); + +describe("simplifying display names", () => { + const cases = { + defaultCase: [["define", "define"]], + + objectProperty: [ + ["z.foz", "foz"], + ["z.foz/baz", "baz"], + ["z.foz/baz/y.bay", "bay"], + ["outer/x.fox.bax.nx", "nx"], + ["outer/fow.baw", "baw"], + ["fromYUI._attach", "_attach"], + ["Y.ClassNameManager</getClassName", "getClassName"], + ["orion.textview.TextView</addHandler", "addHandler"], + ["this.eventPool_.createObject", "createObject"], + ], + + arrayProperty: [ + ["this.eventPool_[createObject]", "createObject"], + ["jQuery.each(^)/jQuery.fn[o]", "o"], + ["viewport[get+D]", "get+D"], + ["arr[0]", "0"], + ], + + functionProperty: [ + ["fromYUI._attach/<.", "_attach"], + ["Y.ClassNameManager<", "ClassNameManager"], + ["fromExtJS.setVisible/cb<", "cb"], + ["fromDojo.registerWin/<", "registerWin"], + ], + + annonymousProperty: [["jQuery.each(^)", "each"]], + + privateMethod: [["#privateFunc", "#privateFunc"]], + }; + + Object.keys(cases).forEach(type => { + cases[type].forEach(([kase, expected]) => { + it(`${type} - ${kase}`, () => + expect(simplifyDisplayName(kase)).toEqual(expected)); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js new file mode 100644 index 0000000000..ff5be43285 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/frames/tests/getLibraryFromUrl.spec.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getLibraryFromUrl } from "../getLibraryFromUrl"; +import { makeMockFrameWithURL } from "../../../test-mockup"; + +describe("getLibraryFromUrl", () => { + describe("When Preact is on the frame", () => { + it("should return Preact and not React", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/preact/8.2.5/preact.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Preact"); + }); + }); + + describe("When Vue is on the frame", () => { + it("should return VueJS for different builds", () => { + const buildTypeList = [ + "vue.js", + "vue.common.js", + "vue.esm.js", + "vue.runtime.js", + "vue.runtime.common.js", + "vue.runtime.esm.js", + "vue.min.js", + "vue.runtime.min.js", + ]; + + buildTypeList.forEach(buildType => { + const frame = makeMockFrameWithURL( + `https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/${buildType}` + ); + expect(getLibraryFromUrl(frame)).toEqual("VueJS"); + }); + }); + }); + + describe("When React is in the URL", () => { + it("should not return React if it is not part of the filename", () => { + const notReactUrlList = [ + "https://react.js.com/test.js", + "https://debugger-example.com/test.js", + "https://debugger-react-example.com/test.js", + "https://debugger-react-example.com/react/test.js", + "https://debugger-example.com/react-contextmenu.js", + ]; + notReactUrlList.forEach(notReactUrl => { + const frame = makeMockFrameWithURL(notReactUrl); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + it("should return React if it is part of the filename", () => { + const reactUrlList = [ + "https://debugger-example.com/react.js", + "https://debugger-example.com/react.development.js", + "https://debugger-example.com/react.production.min.js", + "https://debugger-react-example.com/react.js", + "https://debugger-react-example.com/react/react.js", + "https://debugger-example.com/react-dom.js", + "https://debugger-example.com/react-dom.development.js", + "https://debugger-example.com/react-dom.production.min.js", + "https://debugger-react-example.com/react-dom.js", + "https://debugger-react-example.com/react/react-dom.js", + "https://debugger-react-example.com/react-dom-dev.js", + "/node_modules/react/test.js", + "/node_modules/react-dev/test.js", + "/node_modules/react-dom/test.js", + "/node_modules/react-dom-dev/test.js", + ]; + reactUrlList.forEach(reactUrl => { + const frame = makeMockFrameWithURL(reactUrl); + expect(getLibraryFromUrl(frame)).toEqual("React"); + }); + }); + }); + + describe("When Angular is in the URL", () => { + it("should return Angular for AngularJS (1.x)", () => { + const frame = makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should return Angular for Angular (2.x)", () => { + const frame = makeMockFrameWithURL( + "https://stackblitz.io/turbo_modules/@angular/core@7.2.4/bundles/core.umd.js" + ); + expect(getLibraryFromUrl(frame)).toEqual("Angular"); + }); + + it("should not return Angular for Angular components", () => { + const frame = makeMockFrameWithURL( + "https://firefox-devtools-angular-log.stackblitz.io/~/src/app/hello.component.ts" + ); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + }); + + describe("When zone.js is on the frame", () => { + it("should not return Angular when no callstack", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + expect(getLibraryFromUrl(frame)).toBeNull(); + }); + + it("should not return Angular when stack without Angular frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [frame]; + + expect(getLibraryFromUrl(frame, callstack)).toBeNull(); + }); + + it("should return Angular when stack with AngularJS (1.x) frames", () => { + const frame = makeMockFrameWithURL("/node_modules/zone/zone.js"); + const callstack = [ + frame, + makeMockFrameWithURL( + "https://cdnjs.cloudflare.com/ajax/libs/angular/angular.js" + ), + ]; + + expect(getLibraryFromUrl(frame, callstack)).toEqual("Angular"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/index.js b/devtools/client/debugger/src/utils/pause/index.js new file mode 100644 index 0000000000..f6966999b0 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/index.js @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export * from "./why"; diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/README.md b/devtools/client/debugger/src/utils/pause/mapScopes/README.md new file mode 100644 index 0000000000..2f65b8e847 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/README.md @@ -0,0 +1,191 @@ +# mapScopes + +The files in this directory manage the Devtools logic for taking the original +JS file from the sourcemap and using the content, combined with source +mappings, to provide an enhanced user experience for debugging the code. + +In this document, we'll refer to the files as either: + +* `original` - The code that the user wrote originally. +* `generated` - The code actually executing in the engine, which may have been + transformed from the `original`, and if a bundler was used, may contain the + final output for several different `original` files. + +The enhancements implemented here break down into as two primary improvements: + +* Rendering the scopes as the user expects them, using the position in the + original file, rather than the content of the generated file. +* Allowing users to interact with the code in the console as if it were the + original code, rather than the generated file. + + +## Overall Approach + +The core goal of scope mapping is to parse the original and generated files, +and then infer a correlation between the bindings in the original file, +and the bindings in the generated file. This is correlation is done via the +source mappings provided alongside the original code in the sourcemap. + +The overall steps break down into: + + +### 1. Parsing + +First the generated and original files are parsed into ASTs, and then the ASTs +are each traversed in order to generate a full picture of the scopes that are +present in the file. This covers information for each binding in each scope, +including all metadata about the declarations themselves, and all references +to each of the bindings. The exact location of each binding reference is the +most important factor because these will be used when working with sourcemaps. + +Importantly, this scope tree's structure also mirrors the structure of the +scope data that the engine itself returns when paused. + + +### 2. Generated Binding -> Grip Correlation + +When the engine pauses, we get back a full description of the current scope, +as well as `grip` objects that can be used as proxy-like objects in order to +access the actual values in the engine itself. + +With this data, along with the location where the engine is paused, we can +use the AST-based scope information from step 1 to attach a line/column +range to each `grip`. Using those mappings we have enough information to get +the real engine value of any variable based on its position in the generated +code, as long as it is in scope at the paused location in the generated file. + +The generated and engine scopes are correlated depth-first, because in some +cases the scope data from the engine will be deeper that the data from the +parsed code, meaning there are additional unknown parent scopes. This can +happen, for instance, if the executing code is running inside of an `eval` +since the parser only knows about the scopes inside of `eval`, and cannot +know what context it is executed inside of. + + +### 3. Original Binding -> Generated Binding Correlation + +Now that we can get the value of a generated location, we need to decide which +generated location correlates with which original location. For each of +the original bindings in each original scope, we iterate through the +individual references to the binding in the file and: + +1. Use the sourcemap to convert the original location into a range on the + generated code. +2. Filter the available bindings in the generated file down to those that + overlap with this generated range. +3. Perform heuristics to decide if any of the bindings in the range appear + to be a valid and safe mapping. + +These steps allow us to build a datastructure that describes the scope +as it is declared in the original file, while rendering the value using the +`grip` objects, as returned from the engine itself. + +There is additional complexity here in the exact details of the heuristics +mentioned above. See the later Heuristics sections diving into these details. + +During this phase, one additional task is performed, which is the construction +of expressions that relate back to the original binding. After matching each +binding in a range, we know the name of the binding in the original scope, +and we _also_ know the name of the generated binding, or more generally the +expression to evaluate in order to get the value of the original binding. + +These expression values are important for ensuring that the developer console +is able to allow users to interact with the original code. If a user types +a variable into the console, it can be translated into the expression +correlated with that original variable, allowing the code to behave as it +would have, were it actually part of the original file. + + +### 4. Generate a Scope Tree + +The structure generated in step 3 is converted into a structure compatible +with the standard scope data returned from the engine itself in order to allow +for consistent usage of scope data, irrespective of the scope data source. +This stage also re-attaches the global scope data, which is otherwise ignored +during the correlation process. + + +### 5. Validation + +As a simple form of validation, we ensure that a large percentage of bindings +were actually resolved to a `grip` object. This offers some amount of +protection, if the sourcemap itself turned out to be somewhat low-quality. + +This happens often with tooling that generates maps that simply map an original +line to a generated line. While line-to-line mappings still enable step +debugging, they do not provide enough information for original-scope mapping. + +On the other hand, generally line-to-line mappings are usually generated by +tooling that performs minimal transformations on their own, so it is often +acceptable to fall back to the engine-only generated-file scope data in this +case. + + +## Range Mapping Heuristics + +Since we know lots of information about the original bindings when performing +matches, it is possible to make educated guesses about how their generated +code will have likely been transformed. This allows us to perform more +aggressive matching logic what would normally be too false-positive-heavy +for generic use, when it is deemed safe enough. + + +### Standard Matching + +In the general case, we iterate through the bindings that were found in the +generated range, and consider it a match if the binding overlaps with the +start of the range. One extra case here is that ranges at the start of +a line often point to the first binding in a range and do not overlap the +first binding, so there is special-casing for the first binding in each range. + + +### ES6 Import Reference Special Case + +References to ES6 imports are often transformed into property accesses on objects +in order to emulate the "live-binding" behavior defined by ES6. The standard +logic here would end up always resolving the imported value to be the import +namespace object itself, rather than reading the value of the property. + +To support this, specifically for ES6 imports, we walk outward from the matched +binding itself, taking property accesses into account, as long as the +property access itself is also within the mapped range. + +While decently effective, there are currently two downsides to this approach: + +* The "live" behavior of imports is often implemented using accessor + properties, which as of the time of this writing, cannot be evaluated to + retrieve their real value. +* The "live" behavior of imports is sometimes implemented with function calls, + which also also cannot be evaluated, causing their value to be + unknown. + + +### ES6 Import Declaration Special Case + +If there are no references to an imported value, or matching based on the +reference failed, we fall back to a second case. + +ES6 import declarations themselves often map back to the location of the +declaration of the imported module's namespace object. By getting the range for +the import declaration itself, we can infer which generated binding is the +namespace object. Alongside that, we already know the name of the property on +the namespace itself because it is statically knowable by analyzing the +import declaration. + +By combining both of those pieces of information, we can access the namespace's +property to get the imported value. + + +### Typescript Classes + +Typescript have several non-ideal ways in which they output source maps, most +of which center around classes that are either exported, or decorated. These +issues are currently tracked in [Typescript issue #22833][1]. + +To work around this issue, we use a method similar to the import declaration +case above. While the class name itself often maps to unhelpful locations, +the class declaration itself generally maps to the class's transformed binding, +so we make use of the class declaration location instead of the location of +the class's declared name in these cases. + + [1]: https://github.com/Microsoft/TypeScript/issues/22833 diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js new file mode 100644 index 0000000000..5f90138013 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/buildGeneratedBindingList.js @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { clientCommands } from "../../../client/firefox"; + +import { locColumn } from "./locColumn"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +export function buildGeneratedBindingList( + scopes, + generatedAstScopes, + thisBinding +) { + // The server's binding data doesn't include general 'this' binding + // information, so we manually inject the one 'this' binding we have into + // the normal binding data we are working with. + const frameThisOwner = generatedAstScopes.find( + generated => "this" in generated.bindings + ); + + let globalScope = null; + const clientScopes = []; + for (let s = scopes; s; s = s.parent) { + const bindings = s.bindings + ? Object.assign({}, ...s.bindings.arguments, s.bindings.variables) + : {}; + + clientScopes.push(bindings); + globalScope = s; + } + + const generatedMainScopes = generatedAstScopes.slice(0, -2); + const generatedGlobalScopes = generatedAstScopes.slice(-2); + + const clientMainScopes = clientScopes.slice(0, generatedMainScopes.length); + const clientGlobalScopes = clientScopes.slice(generatedMainScopes.length); + + // Map the main parsed script body using the nesting hierarchy of the + // generated and client scopes. + const generatedBindings = generatedMainScopes.reduce((acc, generated, i) => { + const bindings = clientMainScopes[i]; + + if (generated === frameThisOwner && thisBinding) { + bindings.this = { + value: thisBinding, + }; + } + + for (const name of Object.keys(generated.bindings)) { + // If there is no 'this' value, we exclude the binding entirely. + // Otherwise it would pass through as found, but "(unscoped)", causing + // the search logic to stop with a match. + if (name === "this" && !bindings[name]) { + continue; + } + + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name] || null), + }); + } + } + return acc; + }, []); + + // Bindings in the global/lexical global of the generated code may or + // may not be the real global if the generated code is running inside + // of an evaled context. To handle this, we just look up the client scope + // hierarchy to find the closest binding with that name. + for (const generated of generatedGlobalScopes) { + for (const name of Object.keys(generated.bindings)) { + const { refs } = generated.bindings[name]; + const bindings = clientGlobalScopes.find(b => name in b); + + for (const loc of refs) { + if (bindings) { + generatedBindings.push({ + name, + loc, + desc: () => Promise.resolve(bindings[name]), + }); + } else { + const globalGrip = globalScope?.object; + if (globalGrip) { + // Should always exist, just checking to keep Flow happy. + + generatedBindings.push({ + name, + loc, + desc: async () => { + const objectFront = + clientCommands.createObjectFront(globalGrip); + return (await objectFront.getProperty(name)).descriptor; + }, + }); + } + } + } + } + } + + // Sort so we can binary-search. + return sortBindings(generatedBindings); +} + +export function buildFakeBindingList(generatedAstScopes) { + // TODO if possible, inject real bindings for the global scope + const generatedBindings = generatedAstScopes.reduce((acc, generated) => { + for (const name of Object.keys(generated.bindings)) { + if (name === "this") { + continue; + } + const { refs } = generated.bindings[name]; + for (const loc of refs) { + acc.push({ + name, + loc, + desc: () => Promise.resolve(getOptimizedOutGrip()), + }); + } + } + return acc; + }, []); + return sortBindings(generatedBindings); +} + +function sortBindings(generatedBindings) { + return generatedBindings.sort((a, b) => { + const aStart = a.loc.start; + const bStart = b.loc.start; + + if (aStart.line === bStart.line) { + return locColumn(aStart) - locColumn(bStart); + } + return aStart.line - bStart.line; + }); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js new file mode 100644 index 0000000000..dec3772e82 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/filtering.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function findInsertionLocation(array, callback) { + let left = 0; + let right = array.length; + while (left < right) { + const mid = Math.floor((left + right) / 2); + const item = array[mid]; + + const result = callback(item); + if (result === 0) { + left = mid; + break; + } + if (result >= 0) { + right = mid; + } else { + left = mid + 1; + } + } + + // Ensure the value is the start of any set of matches. + let i = left; + if (i < array.length) { + while (i >= 0 && callback(array[i]) >= 0) { + i--; + } + return i + 1; + } + + return i; +} + +export function filterSortedArray(array, callback) { + const start = findInsertionLocation(array, callback); + + const results = []; + for (let i = start; i < array.length && callback(array[i]) === 0; i++) { + results.push(array[i]); + } + + return results; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js new file mode 100644 index 0000000000..6e833959f4 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/findGeneratedBindingFromPosition.js @@ -0,0 +1,305 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; +import { mappingContains } from "./mappingContains"; + +// eslint-disable-next-line max-len + +import { clientCommands } from "../../../client/firefox"; + +/** + * Given a mapped range over the generated source, attempt to resolve a real + * binding descriptor that can be used to access the value. + */ +export async function findGeneratedReference(applicableBindings) { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 4) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapBindingReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + return null; +} + +export async function findGeneratedImportReference(applicableBindings) { + // When wrapped, for instance as `Object(ns.default)`, the `Object` binding + // will be the first in the list. To avoid resolving `Object` as the + // value of the import itself, we potentially skip the first binding. + applicableBindings = applicableBindings.filter((applicable, i) => { + if ( + !applicable.firstInRange || + applicable.binding.loc.type !== "ref" || + applicable.binding.loc.meta + ) { + return true; + } + + const next = + i + 1 < applicableBindings.length ? applicableBindings[i + 1] : null; + + return !next || next.binding.loc.type !== "ref" || !next.binding.loc.meta; + }); + + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 2) { + // Babel's for..of generates at least 3 bindings inside one range for + // block-scoped loop variables, so we shouldn't go below that. + applicableBindings = []; + } + + for (const applicable of applicableBindings) { + const result = await mapImportReferenceToDescriptor(applicable); + if (result) { + return result; + } + } + + return null; +} + +/** + * Given a mapped range over the generated source and the name of the imported + * value that is referenced, attempt to resolve a binding descriptor for + * the import's value. + */ +export async function findGeneratedImportDeclaration( + applicableBindings, + importName +) { + // We can adjust this number as we go, but these are a decent start as a + // general heuristic to assume the bindings were bad or just map a chunk of + // whole line or something. + if (applicableBindings.length > 10) { + // Import declarations tend to have a large number of bindings for + // for things like 'require' and 'interop', so this number is larger + // than other binding count checks. + applicableBindings = []; + } + + let result = null; + + for (const { binding } of applicableBindings) { + if (binding.loc.type === "ref") { + continue; + } + + const namespaceDesc = await binding.desc(); + if (isPrimitiveValue(namespaceDesc)) { + continue; + } + if (!isObjectValue(namespaceDesc)) { + // We want to handle cases like + // + // var _mod = require(...); + // var _mod2 = _interopRequire(_mod); + // + // where "_mod" is optimized out because it is only referenced once. To + // allow that, we track the optimized-out value as a possible result, + // but allow later binding values to overwrite the result. + result = { + name: binding.name, + desc: namespaceDesc, + expression: binding.name, + }; + continue; + } + + const desc = await readDescriptorProperty(namespaceDesc, importName); + const expression = `${binding.name}.${importName}`; + + if (desc) { + result = { + name: binding.name, + desc, + expression, + }; + break; + } + } + + return result; +} + +/** + * Given a generated binding, and a range over the generated code, statically + * check if the given binding matches the range. + */ +async function mapBindingReferenceToDescriptor({ + binding, + range, + firstInRange, + firstOnLine, +}) { + // Allow the mapping to point anywhere within the generated binding + // location to allow for less than perfect sourcemaps. Since you also + // need at least one character between identifiers, we also give one + // characters of space at the front the generated binding in order + // to increase the probability of finding the right mapping. + if ( + range.start.line === binding.loc.start.line && + // If a binding is the first on a line, Babel will extend the mapping to + // include the whitespace between the newline and the binding. To handle + // that, we skip the range requirement for starting location. + (firstInRange || + firstOnLine || + locColumn(range.start) >= locColumn(binding.loc.start)) && + locColumn(range.start) <= locColumn(binding.loc.end) + ) { + return { + name: binding.name, + desc: await binding.desc(), + expression: binding.name, + }; + } + + return null; +} + +/** + * Given an generated binding, and a range over the generated code, statically + * evaluate accessed properties within the mapped range to resolve the actual + * imported value. + */ +async function mapImportReferenceToDescriptor({ binding, range }) { + if (binding.loc.type !== "ref") { + return null; + } + + // Expression matches require broader searching because sourcemaps usage + // varies in how they map certain things. For instance given + // + // import { bar } from "mod"; + // bar(); + // + // The "bar()" expression is generally expanded into one of two possibly + // forms, both of which map the "bar" identifier in different ways. See + // the "^^" markers below for the ranges. + // + // (0, foo.bar)() // Babel + // ^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // __webpack_require__.i(foo.bar)() // Webpack 2 + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // vs + // + // Object(foo.bar)() // Webpack >= 3 + // ^^^^^^^^^^^^^^^ // mapping + // ^^^ // binding + // + // Unfortunately, Webpack also has a tendancy to over-map past the call + // expression to the start of the next line, at least when there isn't + // anything else on that line that is mapped, e.g. + // + // Object(foo.bar)() + // ^^^^^^^^^^^^^^^^^ + // ^ // wrapped to column 0 of next line + + if (!mappingContains(range, binding.loc)) { + return null; + } + + // Webpack 2's import declarations wrap calls with an identity fn, so we + // need to make sure to skip that binding because it is mapped to the + // location of the original binding usage. + if ( + binding.name === "__webpack_require__" && + binding.loc.meta && + binding.loc.meta.type === "member" && + binding.loc.meta.property === "i" + ) { + return null; + } + + let expression = binding.name; + let desc = await binding.desc(); + + if (binding.loc.type === "ref") { + const { meta } = binding.loc; + + // Limit to 2 simple property or inherits operartions, since it would + // just be more work to search more and it is very unlikely that + // bindings would be mapped to more than a single member + inherits + // wrapper. + for ( + let op = meta, index = 0; + op && mappingContains(range, op) && desc && index < 2; + index++, op = op?.parent + ) { + // Calling could potentially trigger side-effects, which would not + // be ideal for this case. + if (op.type === "call") { + return null; + } + + if (op.type === "inherit") { + continue; + } + + desc = await readDescriptorProperty(desc, op.property); + expression += `.${op.property}`; + } + } + + return desc + ? { + name: binding.name, + desc, + expression, + } + : null; +} + +function isPrimitiveValue(desc) { + return desc && (!desc.value || typeof desc.value !== "object"); +} +function isObjectValue(desc) { + return ( + desc && + !isPrimitiveValue(desc) && + desc.value.type === "object" && + // Note: The check for `.type` might already cover the optimizedOut case + // but not 100% sure, so just being cautious. + !desc.value.optimizedOut + ); +} + +async function readDescriptorProperty(desc, property) { + if (!desc) { + return null; + } + + if (typeof desc.value !== "object" || !desc.value) { + // If accessing a property on a primitive type, just return 'undefined' + // as the value. + return { + value: { + type: "undefined", + }, + }; + } + + if (!isObjectValue(desc)) { + // If we got a non-primitive descriptor but it isn't an object, then + // it's definitely not the namespace and it is probably an error. + return desc; + } + + const objectFront = clientCommands.createObjectFront(desc.value); + return (await objectFront.getProperty(property)).descriptor; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js new file mode 100644 index 0000000000..c1a64ddfa0 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/getApplicableBindingsForOriginalPosition.js @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; +import { mappingContains } from "./mappingContains"; +import { getGeneratedLocation } from "../../source-maps"; + +export async function originalRangeStartsInside({ start, end }, thunkArgs) { + const endPosition = await getGeneratedLocation(end, thunkArgs); + const startPosition = await getGeneratedLocation(start, thunkArgs); + + // If the start and end positions collapse into eachother, it means that + // the range in the original content didn't _start_ at the start position. + // Since this likely means that the range doesn't logically apply to this + // binding location, we skip it. + return positionCmp(startPosition, endPosition) !== 0; +} + +export async function getApplicableBindingsForOriginalPosition( + generatedAstBindings, + source, + { start, end }, + bindingType, + locationType, + thunkArgs +) { + const { sourceMapLoader } = thunkArgs; + const ranges = await sourceMapLoader.getGeneratedRanges(start); + + const resultRanges = ranges.map(mapRange => ({ + start: { + line: mapRange.line, + column: mapRange.columnStart, + }, + end: { + line: mapRange.line, + // SourceMapConsumer's 'lastColumn' is inclusive, so we add 1 to make + // it exclusive like all other locations. + column: mapRange.columnEnd + 1, + }, + })); + + // When searching for imports, we expand the range to up to the next available + // mapping to allow for import declarations that are composed of multiple + // variable statements, where the later ones are entirely unmapped. + // Babel 6 produces imports in this style, e.g. + // + // var _mod = require("mod"); // mapped from import statement + // var _mod2 = interop(_mod); // entirely unmapped + if (bindingType === "import" && locationType !== "ref") { + const endPosition = await getGeneratedLocation(end, thunkArgs); + const startPosition = await getGeneratedLocation(start, thunkArgs); + + for (const range of resultRanges) { + if ( + mappingContains(range, { start: startPosition, end: startPosition }) && + positionCmp(range.end, endPosition) < 0 + ) { + range.end = { + line: endPosition.line, + column: endPosition.column, + }; + break; + } + } + } + + return filterApplicableBindings(generatedAstBindings, resultRanges); +} + +function filterApplicableBindings(bindings, ranges) { + const result = []; + for (const range of ranges) { + // Any binding overlapping a part of the mapping range. + const filteredBindings = filterSortedArray(bindings, binding => { + if (positionCmp(binding.loc.end, range.start) <= 0) { + return -1; + } + if (positionCmp(binding.loc.start, range.end) >= 0) { + return 1; + } + + return 0; + }); + + let firstInRange = true; + let firstOnLine = true; + let line = -1; + + for (const binding of filteredBindings) { + if (binding.loc.start.line === line) { + firstOnLine = false; + } else { + line = binding.loc.start.line; + firstOnLine = true; + } + + result.push({ + binding, + range, + firstOnLine, + firstInRange, + }); + + firstInRange = false; + } + } + + return result; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/index.js b/devtools/client/debugger/src/utils/pause/mapScopes/index.js new file mode 100644 index 0000000000..8736c42218 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js @@ -0,0 +1,583 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "../../location"; +import { locColumn } from "./locColumn"; +import { loadRangeMetadata, findMatchingRange } from "./rangeMetadata"; + +// eslint-disable-next-line max-len +import { + findGeneratedReference, + findGeneratedImportReference, + findGeneratedImportDeclaration, +} from "./findGeneratedBindingFromPosition"; +import { + buildGeneratedBindingList, + buildFakeBindingList, +} from "./buildGeneratedBindingList"; +import { + originalRangeStartsInside, + getApplicableBindingsForOriginalPosition, +} from "./getApplicableBindingsForOriginalPosition"; +import { getOptimizedOutGrip } from "./optimizedOut"; + +import { log } from "../../log"; + +// Create real location objects for all location start and end. +// +// Parser worker returns scopes with location having a sourceId +// instead of a source object as it doesn't know about main thread source objects. +function updateLocationsInScopes(state, scopes) { + for (const item of scopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + const locs = [ref]; + if (ref.type !== "ref") { + locs.push(ref.declaration); + } + for (const loc of locs) { + loc.start = sourceMapToDebuggerLocation(state, loc.start); + loc.end = sourceMapToDebuggerLocation(state, loc.end); + } + } + } + } +} + +export async function buildMappedScopes( + source, + content, + frame, + scopes, + thunkArgs +) { + const { getState, parserWorker } = thunkArgs; + if (!parserWorker.isLocationSupported(frame.location)) { + return null; + } + const originalAstScopes = await parserWorker.getScopes(frame.location); + updateLocationsInScopes(getState(), originalAstScopes); + const generatedAstScopes = await parserWorker.getScopes( + frame.generatedLocation + ); + updateLocationsInScopes(getState(), generatedAstScopes); + + if (!originalAstScopes || !generatedAstScopes) { + return null; + } + + const originalRanges = await loadRangeMetadata( + frame.location, + originalAstScopes, + thunkArgs + ); + + if (hasLineMappings(originalRanges)) { + return null; + } + + let generatedAstBindings; + if (scopes) { + generatedAstBindings = buildGeneratedBindingList( + scopes, + generatedAstScopes, + frame.this + ); + } else { + generatedAstBindings = buildFakeBindingList(generatedAstScopes); + } + + const { mappedOriginalScopes, expressionLookup } = + await mapOriginalBindingsToGenerated( + source, + content, + originalRanges, + originalAstScopes, + generatedAstBindings, + thunkArgs + ); + + const globalLexicalScope = scopes + ? getGlobalFromScope(scopes) + : generateGlobalFromAst(generatedAstScopes); + const mappedGeneratedScopes = generateClientScope( + globalLexicalScope, + mappedOriginalScopes + ); + + return isReliableScope(mappedGeneratedScopes) + ? { mappings: expressionLookup, scope: mappedGeneratedScopes } + : null; +} + +async function mapOriginalBindingsToGenerated( + source, + content, + originalRanges, + originalAstScopes, + generatedAstBindings, + thunkArgs +) { + const expressionLookup = {}; + const mappedOriginalScopes = []; + + const cachedSourceMaps = batchScopeMappings( + originalAstScopes, + source, + thunkArgs + ); + // Override sourceMapLoader attribute with the special cached SourceMapLoader instance + // in order to make it used by all functions used in this method. + thunkArgs = { ...thunkArgs, sourceMapLoader: cachedSourceMaps }; + + for (const item of originalAstScopes) { + const generatedBindings = {}; + + for (const name of Object.keys(item.bindings)) { + const binding = item.bindings[name]; + + const result = await findGeneratedBinding( + source, + content, + name, + binding, + originalRanges, + generatedAstBindings, + thunkArgs + ); + + if (result) { + generatedBindings[name] = result.grip; + + if ( + binding.refs.length !== 0 && + // These are assigned depth-first, so we don't want shadowed + // bindings in parent scopes overwriting the expression. + !Object.prototype.hasOwnProperty.call(expressionLookup, name) + ) { + expressionLookup[name] = result.expression; + } + } + } + + mappedOriginalScopes.push({ + ...item, + generatedBindings, + }); + } + + return { + mappedOriginalScopes, + expressionLookup, + }; +} + +/** + * Consider a scope and its parents reliable if the vast majority of its + * bindings were successfully mapped to generated scope bindings. + */ +function isReliableScope(scope) { + let totalBindings = 0; + let unknownBindings = 0; + + for (let s = scope; s; s = s.parent) { + const vars = s.bindings?.variables || {}; + for (const key of Object.keys(vars)) { + const binding = vars[key]; + + totalBindings += 1; + if ( + binding.value && + typeof binding.value === "object" && + (binding.value.type === "unscoped" || binding.value.type === "unmapped") + ) { + unknownBindings += 1; + } + } + } + + // As determined by fair dice roll. + return totalBindings === 0 || unknownBindings / totalBindings < 0.25; +} + +function hasLineMappings(ranges) { + return ranges.every( + range => range.columnStart === 0 && range.columnEnd === Infinity + ); +} + +/** + * Build a special SourceMapLoader instance, based on the one passed in thunkArgs, + * which will both: + * - preload generated ranges/locations for original locations mentioned + * in originalAstScopes + * - cache the requests to fetch these genereated ranges/locations + */ +function batchScopeMappings(originalAstScopes, source, thunkArgs) { + const { sourceMapLoader } = thunkArgs; + const precalculatedRanges = new Map(); + const precalculatedLocations = new Map(); + + // Explicitly dispatch all of the sourcemap requests synchronously up front so + // that they will be batched into a single request for the worker to process. + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + const locs = [ref]; + if (ref.type !== "ref") { + locs.push(ref.declaration); + } + + for (const loc of locs) { + precalculatedRanges.set( + buildLocationKey(loc.start), + sourceMapLoader.getGeneratedRanges( + debuggerToSourceMapLocation(loc.start) + ) + ); + precalculatedLocations.set( + buildLocationKey(loc.start), + sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(loc.start) + ) + ); + precalculatedLocations.set( + buildLocationKey(loc.end), + sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(loc.end) + ) + ); + } + } + } + } + + return { + async getGeneratedRanges(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedRanges.has(key)) { + log("Bad precalculated mapping"); + return sourceMapLoader.getGeneratedRanges( + debuggerToSourceMapLocation(pos) + ); + } + return precalculatedRanges.get(key); + }, + + async getGeneratedLocation(pos) { + const key = buildLocationKey(pos); + + if (!precalculatedLocations.has(key)) { + log("Bad precalculated mapping"); + return sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(pos) + ); + } + return precalculatedLocations.get(key); + }, + }; +} +function buildLocationKey(loc) { + return `${loc.line}:${locColumn(loc)}`; +} + +function generateClientScope(globalLexicalScope, originalScopes) { + // Build a structure similar to the client's linked scope object using + // the original AST scopes, but pulling in the generated bindings + // linked to each scope. + const result = originalScopes + .slice(0, -2) + .reverse() + .reduce((acc, orig, i) => { + const { + // The 'this' binding data we have is handled independently, so + // the binding data is not included here. + // eslint-disable-next-line no-unused-vars + this: _this, + ...variables + } = orig.generatedBindings; + + return { + parent: acc, + actor: `originalActor${i}`, + type: orig.type, + scopeKind: orig.scopeKind, + bindings: { + arguments: [], + variables, + }, + ...(orig.type === "function" + ? { + function: { + displayName: orig.displayName, + }, + } + : null), + ...(orig.type === "block" + ? { + block: { + displayName: orig.displayName, + }, + } + : null), + }; + }, globalLexicalScope); + + // The rendering logic in getScope 'this' bindings only runs on the current + // selected frame scope, so we pluck out the 'this' binding that was mapped, + // and put it in a special location + const thisScope = originalScopes.find(scope => scope.bindings.this); + if (result.bindings && thisScope) { + result.bindings.this = thisScope.generatedBindings.this || null; + } + + return result; +} + +function getGlobalFromScope(scopes) { + // Pull the root object scope and root lexical scope to reuse them in + // our mapped scopes. This assumes that file being processed is + // a CommonJS or ES6 module, which might not be ideal. Potentially + // should add some logic to try to detect those cases? + let globalLexicalScope = null; + for (let s = scopes; s.parent; s = s.parent) { + globalLexicalScope = s; + } + if (!globalLexicalScope) { + throw new Error("Assertion failure - there should always be a scope"); + } + return globalLexicalScope; +} + +function generateGlobalFromAst(generatedScopes) { + const globalLexicalAst = generatedScopes[generatedScopes.length - 2]; + if (!globalLexicalAst) { + throw new Error("Assertion failure - there should always be a scope"); + } + return { + actor: "generatedActor1", + type: "block", + scopeKind: "", + bindings: { + arguments: [], + variables: Object.fromEntries( + Object.keys(globalLexicalAst).map(key => [key, getOptimizedOutGrip()]) + ), + }, + parent: { + actor: "generatedActor0", + object: getOptimizedOutGrip(), + scopeKind: "", + type: "object", + }, + }; +} + +function hasValidIdent(range, pos) { + return ( + range.type === "match" || + // For declarations, we allow the range on the identifier to be a + // more general "contains" to increase the chances of a match. + (pos.type !== "ref" && range.type === "contains") + ); +} + +// eslint-disable-next-line complexity +async function findGeneratedBinding( + source, + content, + name, + originalBinding, + originalRanges, + generatedAstBindings, + thunkArgs +) { + // If there are no references to the implicits, then we have no way to + // even attempt to map it back to the original since there is no location + // data to use. Bail out instead of just showing it as unmapped. + if ( + originalBinding.type === "implicit" && + !originalBinding.refs.some(item => item.type === "ref") + ) { + return null; + } + + const loadApplicableBindings = async (pos, locationType) => { + let applicableBindings = await getApplicableBindingsForOriginalPosition( + generatedAstBindings, + source, + pos, + originalBinding.type, + locationType, + thunkArgs + ); + if (applicableBindings.length) { + hadApplicableBindings = true; + } + if (locationType === "ref") { + // Some tooling creates ranges that map a line as a whole, which is useful + // for step-debugging, but can easily lead to finding the wrong binding. + // To avoid these false-positives, we entirely ignore bindings matched + // by ranges that cover full lines. + applicableBindings = applicableBindings.filter( + ({ range }) => + !(range.start.column === 0 && range.end.column === Infinity) + ); + } + if ( + locationType !== "ref" && + !(await originalRangeStartsInside(pos, thunkArgs)) + ) { + applicableBindings = []; + } + return applicableBindings; + }; + + const { refs } = originalBinding; + + let hadApplicableBindings = false; + let genContent = null; + for (const pos of refs) { + const applicableBindings = await loadApplicableBindings(pos, pos.type); + + const range = findMatchingRange(originalRanges, pos); + if (range && hasValidIdent(range, pos)) { + if (originalBinding.type === "import") { + genContent = await findGeneratedImportReference(applicableBindings); + } else { + genContent = await findGeneratedReference(applicableBindings); + } + } + + if ( + (pos.type === "class-decl" || pos.type === "class-inner") && + content.contentType && + content.contentType.match(/\/typescript/) + ) { + const declRange = findMatchingRange(originalRanges, pos.declaration); + if (declRange && declRange.type !== "multiple") { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // Resolve to first binding in the range + const declContent = await findGeneratedReference( + applicableDeclBindings + ); + + if (declContent) { + // Prefer the declaration mapping in this case because TS sometimes + // maps class declaration names to "export.Foo = Foo;" or to + // the decorator logic itself + genContent = declContent; + } + } + } + + if ( + !genContent && + pos.type === "import-decl" && + typeof pos.importName === "string" + ) { + const { importName } = pos; + const declRange = findMatchingRange(originalRanges, pos.declaration); + + // The import declaration should have an original position mapping, + // but otherwise we don't really have preferences on the range type + // because it can have multiple bindings, but we do want to make sure + // that all of the bindings that match the range are part of the same + // import declaration. + if (declRange?.singleDeclaration) { + const applicableDeclBindings = await loadApplicableBindings( + pos.declaration, + pos.type + ); + + // match the import declaration location + genContent = await findGeneratedImportDeclaration( + applicableDeclBindings, + importName + ); + } + } + + if (genContent) { + break; + } + } + + if (genContent && genContent.desc) { + return { + grip: genContent.desc, + expression: genContent.expression, + }; + } else if (genContent) { + // If there is no descriptor for 'this', then this is not the top-level + // 'this' that the server gave us a binding for, and we can just ignore it. + if (name === "this") { + return null; + } + + // If the location is found but the descriptor is not, then it + // means that the server scope information didn't match the scope + // information from the DevTools parsed scopes. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unscoped", + unscoped: true, + + // HACK: Until support for "unscoped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; + } else if (!hadApplicableBindings && name !== "this") { + // If there were no applicable bindings to consider while searching for + // matching bindings, then the source map for this file didn't make any + // attempt to map the binding, and that most likely means that the + // code was entirely emitted from the output code. + return { + grip: getOptimizedOutGrip(), + expression: ` + (() => { + throw new Error('"' + ${JSON.stringify( + name + )} + '" has been optimized out.'); + })() + `, + }; + } + + // If no location mapping is found, then the map is bad, or + // the map is okay but it original location is inside + // of some scope, but the generated location is outside, leading + // us to search for bindings that don't technically exist. + return { + grip: { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "unmapped", + unmapped: true, + + // HACK: Until support for "unmapped" lands in devtools-reps, + // this will make these show as (unavailable). + missingArguments: true, + }, + }, + expression: null, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js new file mode 100644 index 0000000000..075e902299 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/locColumn.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function locColumn(loc) { + if (typeof loc.column !== "number") { + // This shouldn't really happen with locations from the AST, but + // the datatype we are using allows null/undefined column. + return 0; + } + + return loc.column; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js new file mode 100644 index 0000000000..ca82fe42ec --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/mappingContains.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { positionCmp } from "./positionCmp"; + +export function mappingContains(mapped, item) { + return ( + positionCmp(item.start, mapped.start) >= 0 && + positionCmp(item.end, mapped.end) <= 0 + ); +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/moz.build b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build new file mode 100644 index 0000000000..05f2b7e3d8 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "buildGeneratedBindingList.js", + "filtering.js", + "findGeneratedBindingFromPosition.js", + "getApplicableBindingsForOriginalPosition.js", + "index.js", + "locColumn.js", + "mappingContains.js", + "optimizedOut.js", + "positionCmp.js", + "rangeMetadata.js", +) diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js new file mode 100644 index 0000000000..755b308a2d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/optimizedOut.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getOptimizedOutGrip() { + return { + configurable: false, + enumerable: true, + writable: false, + value: { + type: "null", + optimizedOut: true, + }, + }; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js new file mode 100644 index 0000000000..5d53fb933e --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/positionCmp.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; + +/** + * * === 0 - Positions are equal. + * * < 0 - first position before second position + * * > 0 - first position after second position + */ +export function positionCmp(p1, p2) { + if (p1.line === p2.line) { + const l1 = locColumn(p1); + const l2 = locColumn(p2); + + if (l1 === l2) { + return 0; + } + return l1 < l2 ? -1 : 1; + } + + return p1.line < p2.line ? -1 : 1; +} diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js new file mode 100644 index 0000000000..7a22313aca --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/mapScopes/rangeMetadata.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { locColumn } from "./locColumn"; +import { positionCmp } from "./positionCmp"; +import { filterSortedArray } from "./filtering"; + +// * match - Range contains a single identifier with matching start location +// * contains - Range contains a single identifier with non-matching start +// * multiple - Range contains multiple identifiers +// * empty - Range contains no identifiers + +export async function loadRangeMetadata( + location, + originalAstScopes, + { sourceMapLoader } +) { + const originalRanges = await sourceMapLoader.getOriginalRanges( + location.sourceId + ); + + const sortedOriginalAstBindings = []; + for (const item of originalAstScopes) { + for (const name of Object.keys(item.bindings)) { + for (const ref of item.bindings[name].refs) { + sortedOriginalAstBindings.push(ref); + } + } + } + sortedOriginalAstBindings.sort((a, b) => positionCmp(a.start, b.start)); + + let i = 0; + + return originalRanges.map(range => { + const bindings = []; + + while ( + i < sortedOriginalAstBindings.length && + (sortedOriginalAstBindings[i].start.line < range.line || + (sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) < range.columnStart)) + ) { + i++; + } + + while ( + i < sortedOriginalAstBindings.length && + sortedOriginalAstBindings[i].start.line === range.line && + locColumn(sortedOriginalAstBindings[i].start) >= range.columnStart && + locColumn(sortedOriginalAstBindings[i].start) < range.columnEnd + ) { + const lastBinding = bindings[bindings.length - 1]; + // Only add bindings when they're in new positions + if ( + !lastBinding || + positionCmp(lastBinding.start, sortedOriginalAstBindings[i].start) !== 0 + ) { + bindings.push(sortedOriginalAstBindings[i]); + } + i++; + } + + let type = "empty"; + let singleDeclaration = true; + if (bindings.length === 1) { + const binding = bindings[0]; + if ( + binding.start.line === range.line && + binding.start.column === range.columnStart + ) { + type = "match"; + } else { + type = "contains"; + } + } else if (bindings.length > 1) { + type = "multiple"; + const binding = bindings[0]; + const declStart = + binding.type !== "ref" ? binding.declaration.start : null; + + singleDeclaration = bindings.every(b => { + return ( + declStart && + b.type !== "ref" && + positionCmp(declStart, b.declaration.start) === 0 + ); + }); + } + + return { + type, + singleDeclaration, + ...range, + }; + }); +} + +export function findMatchingRange(sortedOriginalRanges, bindingRange) { + return filterSortedArray(sortedOriginalRanges, range => { + if (range.line < bindingRange.start.line) { + return -1; + } + if (range.line > bindingRange.start.line) { + return 1; + } + + if (range.columnEnd <= locColumn(bindingRange.start)) { + return -1; + } + if (range.columnStart > locColumn(bindingRange.start)) { + return 1; + } + + return 0; + }).pop(); +} diff --git a/devtools/client/debugger/src/utils/pause/moz.build b/devtools/client/debugger/src/utils/pause/moz.build new file mode 100644 index 0000000000..e0705d3115 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/moz.build @@ -0,0 +1,15 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "frames", + "mapScopes", + "scopes", +] + +CompiledModules( + "index.js", + "why.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/getScope.js b/devtools/client/debugger/src/utils/pause/scopes/getScope.js new file mode 100644 index 0000000000..549e387d51 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getScope.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { objectInspector } from "devtools/client/shared/components/reps/index"; +import { getBindingVariables } from "./getVariables"; +import { getFramePopVariables, getThisVariable } from "./utils"; +import { simplifyDisplayName } from "../../pause/frames"; + +const { + utils: { + node: { NODE_TYPES }, + }, +} = objectInspector; + +function getScopeTitle(type, scope) { + if (type === "block" && scope.block && scope.block.displayName) { + return scope.block.displayName; + } + + if (type === "function" && scope.function) { + return scope.function.displayName + ? simplifyDisplayName(scope.function.displayName) + : L10N.getStr("anonymousFunction"); + } + return L10N.getStr("scopes.block"); +} + +export function getScope(scope, selectedFrame, frameScopes, why, scopeIndex) { + const { type, actor } = scope; + + const isLocalScope = scope.actor === frameScopes.actor; + + const key = `${actor}-${scopeIndex}`; + if (type === "function" || type === "block") { + const { bindings } = scope; + + let vars = getBindingVariables(bindings, key); + + // show exception, return, and this variables in innermost scope + if (isLocalScope) { + vars = vars.concat(getFramePopVariables(why, key)); + + let thisDesc_ = selectedFrame.this; + + if (bindings && "this" in bindings) { + // The presence of "this" means we're rendering a "this" binding + // generated from mapScopes and this can override the binding + // provided by the current frame. + thisDesc_ = bindings.this ? bindings.this.value : null; + } + + const this_ = getThisVariable(thisDesc_, key); + + if (this_) { + vars.push(this_); + } + } + + if (vars?.length) { + const title = getScopeTitle(type, scope) || ""; + vars.sort((a, b) => a.name.localeCompare(b.name)); + return { + name: title, + path: key, + contents: vars, + type: NODE_TYPES.BLOCK, + }; + } + } else if (type === "object" && scope.object) { + let value = scope.object; + // If this is the global window scope, mark it as such so that it will + // preview Window: Global instead of Window: Window + if (value.class === "Window") { + value = { ...scope.object, displayClass: "Global" }; + } + return { + name: scope.object.class, + path: key, + contents: { value }, + }; + } + + return null; +} + +export function mergeScopes(scope, parentScope, item, parentItem) { + if (scope.scopeKind == "function lexical" && parentScope.type == "function") { + const contents = item.contents.concat(parentItem.contents); + contents.sort((a, b) => a.name.localeCompare(b.name)); + + return { + name: parentItem.name, + path: parentItem.path, + contents, + type: NODE_TYPES.BLOCK, + }; + } + + return null; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/getVariables.js b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js new file mode 100644 index 0000000000..3346a99cdb --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/getVariables.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// VarAndBindingsPair actually is [name: string, contents: BindingContents] + +// Scope's bindings field which holds variables and arguments + +// Create the tree nodes representing all the variables and arguments +// for the bindings from a scope. +export function getBindingVariables(bindings, parentName) { + if (!bindings) { + return []; + } + + const nodes = []; + const addNode = (name, contents) => + nodes.push({ name, contents, path: `${parentName}/${name}` }); + + for (const arg of bindings.arguments) { + // `arg` is an object which only has a single property whose name is the name of the + // argument. So here we can directly pick the first (and only) entry of `arg` + const [name, contents] = Object.entries(arg)[0]; + addNode(name, contents); + } + + for (const name in bindings.variables) { + addNode(name, bindings.variables[name]); + } + + return nodes; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/index.js b/devtools/client/debugger/src/utils/pause/scopes/index.js new file mode 100644 index 0000000000..2f24a41640 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/index.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getScope, mergeScopes } from "./getScope"; + +export function getScopes(why, selectedFrame, frameScopes) { + if (!why || !selectedFrame) { + return null; + } + + if (!frameScopes) { + return null; + } + + const scopes = []; + + let scope = frameScopes; + let scopeIndex = 1; + let prev = null, + prevItem = null; + + while (scope) { + let scopeItem = getScope( + scope, + selectedFrame, + frameScopes, + why, + scopeIndex + ); + + if (scopeItem) { + const mergedItem = + prev && prevItem ? mergeScopes(prev, scope, prevItem, scopeItem) : null; + if (mergedItem) { + scopeItem = mergedItem; + scopes.pop(); + } + scopes.push(scopeItem); + } + prev = scope; + prevItem = scopeItem; + scopeIndex++; + scope = scope.parent; + } + + return scopes; +} diff --git a/devtools/client/debugger/src/utils/pause/scopes/moz.build b/devtools/client/debugger/src/utils/pause/scopes/moz.build new file mode 100644 index 0000000000..059d187e3d --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/moz.build @@ -0,0 +1,13 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "getScope.js", + "getVariables.js", + "index.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js new file mode 100644 index 0000000000..cf05c71345 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/getFramePopVariables.spec.js @@ -0,0 +1,114 @@ +/* eslint max-nested-callbacks: ["error", 4] */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getFramePopVariables } from "../utils"; + +const errorGrip = { + type: "object", + actor: "server2.conn66.child1/obj243", + class: "Error", + extensible: true, + frozen: false, + sealed: false, + ownPropertyLength: 4, + preview: { + kind: "Error", + name: "Error", + message: "blah", + stack: + "onclick@http://localhost:8000/examples/doc-return-values.html:1:18\n", + fileName: "http://localhost:8000/examples/doc-return-values.html", + lineNumber: 1, + columnNumber: 18, + }, +}; + +function returnWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + return: grip, + }, + }; +} + +function throwWhy(grip) { + return { + type: "resumeLimit", + frameFinished: { + throw: grip, + }, + }; +} + +function getContentsValue(v) { + return v.contents.value; +} + +function getContentsClass(v) { + const value = getContentsValue(v); + return value ? value.class || undefined : ""; +} + +describe("pause - scopes", () => { + describe("getFramePopVariables", () => { + describe("falsey values", () => { + // NOTE: null and undefined are treated like objects and given a type + const falsey = { false: false, 0: 0, null: { type: "null" } }; + for (const test in falsey) { + const value = falsey[test]; + it(`shows ${test} returns`, () => { + const why = returnWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + + it(`shows ${test} throws`, () => { + const why = throwWhy(value); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual(value); + }); + } + }); + + describe("Error / Objects", () => { + it("shows Error returns", () => { + const why = returnWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<return>"); + expect(vars[0].name).toEqual("<return>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + + it("shows error throws", () => { + const why = throwWhy(errorGrip); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsClass(vars[0])).toEqual("Error"); + }); + }); + + describe("undefined", () => { + it("does not show undefined returns", () => { + const why = returnWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars).toHaveLength(0); + }); + + it("shows undefined throws", () => { + const why = throwWhy({ type: "undefined" }); + const vars = getFramePopVariables(why, ""); + expect(vars[0].name).toEqual("<exception>"); + expect(vars[0].name).toEqual("<exception>"); + expect(getContentsValue(vars[0])).toEqual({ type: "undefined" }); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js new file mode 100644 index 0000000000..07a962dfab --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/tests/scopes.spec.js @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getScopes } from ".."; +import { + makeMockFrame, + makeMockScope, + makeWhyNormal, + makeWhyThrow, + mockScopeAddVariable, +} from "../../../test-mockup"; + +function convertScope(scope) { + return scope; +} + +describe("scopes", () => { + describe("getScopes", () => { + it("single scope", () => { + const pauseData = makeWhyNormal(); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[0].path).toEqual("actor1-1"); + expect(scopes[0].contents[0]).toEqual({ + name: "<this>", + path: "actor1-1/<this>", + contents: { value: {} }, + }); + }); + + it("second scope", () => { + const pauseData = makeWhyNormal(); + const scope0 = makeMockScope("actor2"); + const scope1 = makeMockScope("actor1", undefined, scope0); + const selectedFrame = makeMockFrame(undefined, undefined, scope1); + mockScopeAddVariable(scope0, "foo"); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(pauseData, selectedFrame, frameScopes); + if (!scopes) { + throw new Error("missing scopes"); + } + expect(scopes[1].path).toEqual("actor2-2"); + expect(scopes[1].contents[0]).toEqual({ + name: "foo", + path: "actor2-2/foo", + contents: { value: null }, + }); + }); + + it("returning scope", () => { + const why = makeWhyNormal("to sender"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<return>", + path: "actor1-1/<return>", + contents: { + value: "to sender", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + + it("throwing scope", () => { + const why = makeWhyThrow("a party"); + const scope = makeMockScope("actor1"); + const selectedFrame = makeMockFrame(undefined, undefined, scope); + + if (!selectedFrame.scope) { + throw new Error("Frame must include scopes"); + } + + const frameScopes = convertScope(selectedFrame.scope); + const scopes = getScopes(why, selectedFrame, frameScopes); + expect(scopes).toMatchObject([ + { + path: "actor1-1", + contents: [ + { + name: "<exception>", + path: "actor1-1/<exception>", + contents: { + value: "a party", + }, + }, + { + name: "<this>", + path: "actor1-1/<this>", + contents: { + value: {}, + }, + }, + ], + }, + ]); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/pause/scopes/utils.js b/devtools/client/debugger/src/utils/pause/scopes/utils.js new file mode 100644 index 0000000000..16ebbf04a9 --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/scopes/utils.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function getFramePopVariables(why, path) { + const vars = []; + + if (why && why.frameFinished) { + const { frameFinished } = why; + + // Always display a `throw` property if present, even if it is falsy. + if (Object.prototype.hasOwnProperty.call(frameFinished, "throw")) { + vars.push({ + name: "<exception>", + path: `${path}/<exception>`, + contents: { value: frameFinished.throw }, + }); + } + + if (Object.prototype.hasOwnProperty.call(frameFinished, "return")) { + const returned = frameFinished.return; + + // Do not display undefined. Do display falsy values like 0 and false. The + // protocol grip for undefined is a JSON object: { type: "undefined" }. + if (typeof returned !== "object" || returned.type !== "undefined") { + vars.push({ + name: "<return>", + path: `${path}/<return>`, + contents: { value: returned }, + }); + } + } + } + + return vars; +} + +export function getThisVariable(this_, path) { + if (!this_) { + return null; + } + + return { + name: "<this>", + path: `${path}/<this>`, + contents: { value: this_ }, + }; +} + +// Get a string path for an scope item which can be used in different pauses for +// a thread. +export function getScopeItemPath(item) { + // Calling toString() on item.path allows symbols to be handled. + return item.path.toString(); +} diff --git a/devtools/client/debugger/src/utils/pause/why.js b/devtools/client/debugger/src/utils/pause/why.js new file mode 100644 index 0000000000..115d94873b --- /dev/null +++ b/devtools/client/debugger/src/utils/pause/why.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { DEBUGGER_PAUSED_REASONS_L10N_MAPPING } from "devtools/shared/constants"; + +export function getPauseReason(why) { + if (!why) { + return null; + } + + const reasonType = why.type; + if (!DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType]) { + console.log("Please file an issue: reasonType=", reasonType); + } + + return DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reasonType]; +} + +export function isException(why) { + return why?.type === "exception"; +} + +export function isInterrupted(why) { + return why?.type === "interrupted"; +} + +export function inDebuggerEval(why) { + if ( + why && + why.type === "exception" && + why.exception && + why.exception.preview && + why.exception.preview.fileName + ) { + return why.exception.preview.fileName === "debugger eval code"; + } + + return false; +} diff --git a/devtools/client/debugger/src/utils/prefs.js b/devtools/client/debugger/src/utils/prefs.js new file mode 100644 index 0000000000..03d41c1b6b --- /dev/null +++ b/devtools/client/debugger/src/utils/prefs.js @@ -0,0 +1,153 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const { PrefsHelper } = require("devtools/client/shared/prefs"); + +import { isNode } from "./environment"; + +// Schema version to bump when the async store format has changed incompatibly +// and old stores should be cleared. +const prefsSchemaVersion = 11; +const { pref } = Services; + +if (isNode()) { + pref("devtools.debugger.logging", false); + pref("devtools.debugger.alphabetize-outline", false); + pref("devtools.debugger.auto-pretty-print", false); + pref("devtools.source-map.client-service.enabled", true); + pref("devtools.chrome.enabled", false); + pref("devtools.debugger.pause-on-exceptions", false); + pref("devtools.debugger.pause-on-caught-exceptions", false); + pref("devtools.debugger.ignore-caught-exceptions", true); + pref("devtools.debugger.call-stack-visible", true); + pref("devtools.debugger.scopes-visible", true); + pref("devtools.debugger.threads-visible", true); + pref("devtools.debugger.expressions-visible", false); + pref("devtools.debugger.xhr-breakpoints-visible", false); + pref("devtools.debugger.breakpoints-visible", true); + pref("devtools.debugger.event-listeners-visible", false); + pref("devtools.debugger.dom-mutation-breakpoints-visible", false); + pref("devtools.debugger.start-panel-collapsed", false); + pref("devtools.debugger.end-panel-collapsed", false); + pref("devtools.debugger.start-panel-size", 300); + pref("devtools.debugger.end-panel-size", 300); + pref("devtools.debugger.ui.editor-wrapping", false); + pref("devtools.debugger.ui.framework-grouping-on", true); + pref("devtools.debugger.pending-selected-location", "{}"); + pref("devtools.debugger.expressions", "[]"); + pref("devtools.debugger.search-options", "{}"); + pref("devtools.debugger.project-directory-root", ""); + pref("devtools.debugger.map-scopes-enabled", false); + pref("devtools.debugger.prefs-schema-version", prefsSchemaVersion); + pref("devtools.debugger.skip-pausing", false); + pref("devtools.debugger.log-actions", true); + pref("devtools.debugger.log-event-breakpoints", false); + pref("devtools.debugger.javascript-tracing-log-method", "console"); + pref("devtools.debugger.hide-ignored-sources", false); + pref("devtools.debugger.source-map-ignore-list-enabled", true); + pref("devtools.debugger.features.wasm", true); + pref("devtools.debugger.features.map-scopes", true); + pref("devtools.debugger.features.code-folding", false); + pref("devtools.debugger.features.command-click", false); + pref("devtools.debugger.features.component-pane", false); + pref("devtools.debugger.features.autocomplete-expressions", false); + pref("devtools.debugger.features.map-expression-bindings", true); + pref("devtools.debugger.features.map-await-expression", true); + pref("devtools.debugger.features.log-points", true); + pref("devtools.debugger.features.inline-preview", true); + pref("devtools.debugger.features.javascript-tracing", false); + pref("devtools.editor.tabsize", 2); +} + +export const prefs = new PrefsHelper("devtools", { + logging: ["Bool", "debugger.logging"], + editorWrapping: ["Bool", "debugger.ui.editor-wrapping"], + alphabetizeOutline: ["Bool", "debugger.alphabetize-outline"], + autoPrettyPrint: ["Bool", "debugger.auto-pretty-print"], + clientSourceMapsEnabled: ["Bool", "source-map.client-service.enabled"], + chromeAndExtensionsEnabled: ["Bool", "chrome.enabled"], + pauseOnExceptions: ["Bool", "debugger.pause-on-exceptions"], + pauseOnCaughtExceptions: ["Bool", "debugger.pause-on-caught-exceptions"], + ignoreCaughtExceptions: ["Bool", "debugger.ignore-caught-exceptions"], + callStackVisible: ["Bool", "debugger.call-stack-visible"], + scopesVisible: ["Bool", "debugger.scopes-visible"], + threadsVisible: ["Bool", "debugger.threads-visible"], + breakpointsVisible: ["Bool", "debugger.breakpoints-visible"], + expressionsVisible: ["Bool", "debugger.expressions-visible"], + xhrBreakpointsVisible: ["Bool", "debugger.xhr-breakpoints-visible"], + eventListenersVisible: ["Bool", "debugger.event-listeners-visible"], + domMutationBreakpointsVisible: [ + "Bool", + "debugger.dom-mutation-breakpoints-visible", + ], + startPanelCollapsed: ["Bool", "debugger.start-panel-collapsed"], + endPanelCollapsed: ["Bool", "debugger.end-panel-collapsed"], + startPanelSize: ["Int", "debugger.start-panel-size"], + endPanelSize: ["Int", "debugger.end-panel-size"], + frameworkGroupingOn: ["Bool", "debugger.ui.framework-grouping-on"], + pendingSelectedLocation: ["Json", "debugger.pending-selected-location", {}], + expressions: ["Json", "debugger.expressions", []], + searchOptions: ["Json", "debugger.search-options"], + debuggerPrefsSchemaVersion: ["Int", "debugger.prefs-schema-version"], + projectDirectoryRoot: ["Char", "debugger.project-directory-root", ""], + projectDirectoryRootName: [ + "Char", + "debugger.project-directory-root-name", + "", + ], + skipPausing: ["Bool", "debugger.skip-pausing"], + mapScopes: ["Bool", "debugger.map-scopes-enabled"], + logActions: ["Bool", "debugger.log-actions"], + logEventBreakpoints: ["Bool", "debugger.log-event-breakpoints"], + indentSize: ["Int", "editor.tabsize"], + javascriptTracingLogMethod: [ + "String", + "debugger.javascript-tracing-log-method", + ], + hideIgnoredSources: ["Bool", "debugger.hide-ignored-sources"], + sourceMapIgnoreListEnabled: [ + "Bool", + "debugger.source-map-ignore-list-enabled", + ], +}); + +// The pref may not be defined. Defaulting to null isn't viable (cursor never blinks). +// Can't use CodeMirror.defaults here because it's loaded later. +// Hardcode the fallback value to that of CodeMirror.defaults.cursorBlinkRate. +prefs.cursorBlinkRate = Services.prefs.getIntPref("ui.caretBlinkTime", 530); + +export const features = new PrefsHelper("devtools.debugger.features", { + wasm: ["Bool", "wasm"], + mapScopes: ["Bool", "map-scopes"], + outline: ["Bool", "outline"], + codeFolding: ["Bool", "code-folding"], + autocompleteExpression: ["Bool", "autocomplete-expressions"], + mapExpressionBindings: ["Bool", "map-expression-bindings"], + mapAwaitExpression: ["Bool", "map-await-expression"], + componentPane: ["Bool", "component-pane"], + logPoints: ["Bool", "log-points"], + commandClick: ["Bool", "command-click"], + inlinePreview: ["Bool", "inline-preview"], + windowlessServiceWorkers: ["Bool", "windowless-service-workers"], + javascriptTracing: ["Bool", "javascript-tracing"], +}); + +// Import the asyncStore already spawned by the TargetMixin class +const ThreadUtils = require("devtools/client/shared/thread-utils"); +export const asyncStore = ThreadUtils.asyncStore; + +export function resetSchemaVersion() { + prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion; +} + +export function verifyPrefSchema() { + if (prefs.debuggerPrefsSchemaVersion < prefsSchemaVersion) { + asyncStore.pendingBreakpoints = {}; + asyncStore.tabs = []; + asyncStore.xhrBreakpoints = []; + asyncStore.eventListenerBreakpoints = undefined; + asyncStore.blackboxedRanges = {}; + prefs.debuggerPrefsSchemaVersion = prefsSchemaVersion; + } +} diff --git a/devtools/client/debugger/src/utils/preview.js b/devtools/client/debugger/src/utils/preview.js new file mode 100644 index 0000000000..48b9d2bc98 --- /dev/null +++ b/devtools/client/debugger/src/utils/preview.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function isConsole(expression) { + return /^console/.test(expression); +} diff --git a/devtools/client/debugger/src/utils/quick-open.js b/devtools/client/debugger/src/utils/quick-open.js new file mode 100644 index 0000000000..f522242891 --- /dev/null +++ b/devtools/client/debugger/src/utils/quick-open.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { endTruncateStr } from "./utils"; +import { + getFilename, + getSourceClassnames, + getSourceQueryString, + getRelativeUrl, +} from "./source"; + +export const MODIFIERS = { + "@": "functions", + "#": "variables", + ":": "goto", + "?": "shortcuts", +}; + +export function parseQuickOpenQuery(query) { + const startsWithModifier = + query[0] === "@" || + query[0] === "#" || + query[0] === ":" || + query[0] === "?"; + + if (startsWithModifier) { + const modifier = query[0]; + return MODIFIERS[modifier]; + } + + const isGotoSource = query.includes(":", 1); + + if (isGotoSource) { + return "gotoSource"; + } + + return "sources"; +} + +export function parseLineColumn(query) { + const [, line, column] = query.split(":"); + const lineNumber = parseInt(line, 10); + const columnNumber = parseInt(column, 10); + if (isNaN(lineNumber)) { + return null; + } + + return { + line: lineNumber, + ...(!isNaN(columnNumber) ? { column: columnNumber } : null), + }; +} + +export function formatSourceForList( + source, + hasTabOpened, + isBlackBoxed, + projectDirectoryRoot +) { + const title = getFilename(source); + const relativeUrlWithQuery = `${getRelativeUrl( + source, + projectDirectoryRoot + )}${getSourceQueryString(source) || ""}`; + const subtitle = endTruncateStr(relativeUrlWithQuery, 100); + const value = relativeUrlWithQuery; + return { + value, + title, + subtitle, + icon: hasTabOpened + ? "tab result-item-icon" + : `result-item-icon ${getSourceClassnames(source, null, isBlackBoxed)}`, + id: source.id, + url: source.url, + source, + }; +} + +export function formatSymbol(symbol) { + return { + id: `${symbol.name}:${symbol.location.start.line}`, + title: symbol.name, + subtitle: `${symbol.location.start.line}`, + value: symbol.name, + location: symbol.location, + }; +} + +export function formatSymbols(symbols, maxResults) { + if (!symbols) { + return { functions: [] }; + } + + let { functions } = symbols; + // Avoid formating more symbols than necessary + functions = functions.slice(0, maxResults); + + return { + functions: functions.map(formatSymbol), + }; +} + +export function formatShortcutResults() { + return [ + { + value: L10N.getStr("symbolSearch.search.functionsPlaceholder.title"), + title: `@ ${L10N.getStr("symbolSearch.search.functionsPlaceholder")}`, + id: "@", + }, + { + value: L10N.getStr("symbolSearch.search.variablesPlaceholder.title"), + title: `# ${L10N.getStr("symbolSearch.search.variablesPlaceholder")}`, + id: "#", + }, + { + value: L10N.getStr("gotoLineModal.title"), + title: `: ${L10N.getStr("gotoLineModal.placeholder")}`, + id: ":", + }, + ]; +} diff --git a/devtools/client/debugger/src/utils/result-list.js b/devtools/client/debugger/src/utils/result-list.js new file mode 100644 index 0000000000..c8fe97ade6 --- /dev/null +++ b/devtools/client/debugger/src/utils/result-list.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function scrollList(resultList, index) { + if (!resultList.hasOwnProperty(index)) { + return; + } + + const resultEl = resultList[index]; + + const scroll = () => { + // Avoid expensive DOM computations involved in scrollIntoView + // https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/ + requestAnimationFrame(() => { + setTimeout(() => { + if (!resultEl.scrollIntoView) { + return; + } + resultEl.scrollIntoView({ block: "nearest", behavior: "auto" }); + }); + }); + }; + + scroll(); +} diff --git a/devtools/client/debugger/src/utils/selected-location.js b/devtools/client/debugger/src/utils/selected-location.js new file mode 100644 index 0000000000..c590f6e8ed --- /dev/null +++ b/devtools/client/debugger/src/utils/selected-location.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; + +export function getSelectedLocation(mappedLocation, context) { + if (!context) { + return mappedLocation.location; + } + + const sourceId = context.sourceId || context.id; + return isOriginalId(sourceId) + ? mappedLocation.location + : mappedLocation.generatedLocation; +} diff --git a/devtools/client/debugger/src/utils/shallow-equal.js b/devtools/client/debugger/src/utils/shallow-equal.js new file mode 100644 index 0000000000..f75430f476 --- /dev/null +++ b/devtools/client/debugger/src/utils/shallow-equal.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Shallow equal will consider equal: + * - exact same values (strict '===' equality) + * - distinct array instances having the exact same values in them (same number and strict equality). + * - distinct object instances having the exact same attributes and values. + * + * It will typically consider different array and object whose values + * aren't strictly equal. You may consider using "deep equality" checks for this scenario. + */ +export function shallowEqual(value, other) { + if (value === other) { + return true; + } + + if (Array.isArray(value) && Array.isArray(other)) { + return arrayShallowEqual(value, other); + } + + if (isObject(value) && isObject(other)) { + return objectShallowEqual(value, other); + } + + return false; +} + +export function arrayShallowEqual(value, other) { + // Redo this check in case we are called directly from the selectors. + if (value === other) { + return true; + } + return value.length === other.length && value.every((k, i) => k === other[i]); +} + +function objectShallowEqual(value, other) { + const existingKeys = Object.keys(other); + const keys = Object.keys(value); + + return ( + keys.length === existingKeys.length && + keys.every((k, i) => k === existingKeys[i]) && + keys.every(k => value[k] === other[k]) + ); +} + +function isObject(value) { + return typeof value === "object" && !!value; +} diff --git a/devtools/client/debugger/src/utils/source-maps.js b/devtools/client/debugger/src/utils/source-maps.js new file mode 100644 index 0000000000..774bb3997f --- /dev/null +++ b/devtools/client/debugger/src/utils/source-maps.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "./location"; +import { waitForSourceToBeRegisteredInStore } from "../client/firefox/create"; + +/** + * For any location, return the matching generated location. + * If this is already a generated location, returns the same location. + * + * In additional to `SourceMapLoader.getGeneratedLocation`, + * this asserts that the related source is still registered in the reducer current state. + * + * @param {Object} location + * @param {Object} thunkArgs + * Redux action thunk arguments + * @param {Object} + * The matching generated location. + */ +export async function getGeneratedLocation(location, thunkArgs) { + if (!isOriginalId(location.sourceId)) { + return location; + } + + const { sourceMapLoader, getState } = thunkArgs; + const generatedLocation = await sourceMapLoader.getGeneratedLocation( + debuggerToSourceMapLocation(location) + ); + if (!generatedLocation) { + return location; + } + + return sourceMapToDebuggerLocation(getState(), generatedLocation); +} + +/** + * For any location, return the matching original location. + * If this is already an original location, returns the same location. + * + * In additional to `SourceMapLoader.getOriginalLocation`, + * this automatically fetches the original source object in order to build + * the original location object. + * + * @param {Object} location + * @param {Object} thunkArgs + * Redux action thunk arguments + * @param {boolean} waitForSource + * Default to false. If true is passed, this function will + * ensure waiting, possibly asynchronously for the related original source + * to be registered in the redux store. + * + * @param {Object} + * The matching original location. + */ +export async function getOriginalLocation( + location, + thunkArgs, + waitForSource = false +) { + if (isOriginalId(location.sourceId)) { + return location; + } + const { getState, sourceMapLoader } = thunkArgs; + const originalLocation = await sourceMapLoader.getOriginalLocation( + debuggerToSourceMapLocation(location) + ); + if (!originalLocation) { + return location; + } + + // When we are mapping frames while being paused, + // the original source may not be registered yet in the reducer. + if (waitForSource) { + await waitForSourceToBeRegisteredInStore(originalLocation.sourceId); + } + + return sourceMapToDebuggerLocation(getState(), originalLocation); +} + +export async function getMappedLocation(location, thunkArgs) { + if (!location.source) { + throw new Error(`no source ${location.sourceId}`); + } + + if (isOriginalId(location.sourceId)) { + const generatedLocation = await getGeneratedLocation(location, thunkArgs); + return { location, generatedLocation }; + } + + const generatedLocation = location; + const originalLocation = await getOriginalLocation( + generatedLocation, + thunkArgs + ); + + return { location: originalLocation, generatedLocation }; +} + +/** + * Gets the "mapped location". + * + * If the passed location is on a generated source, it gets the + * related location in the original source. + * If the passed location is on an original source, it gets the + * related location in the generated source. + */ +export async function getRelatedMapLocation(location, thunkArgs) { + if (!location.source) { + return location; + } + + if (isOriginalId(location.sourceId)) { + return getGeneratedLocation(location, thunkArgs); + } + + return getOriginalLocation(location, thunkArgs); +} diff --git a/devtools/client/debugger/src/utils/source-queue.js b/devtools/client/debugger/src/utils/source-queue.js new file mode 100644 index 0000000000..b871dc14e5 --- /dev/null +++ b/devtools/client/debugger/src/utils/source-queue.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const { throttle } = require("devtools/shared/throttle"); + +// This SourceQueue module is now only used for source mapped sources +let newOriginalQueuedSources; +let queuedOriginalSources; +let currentWork; + +async function dispatchNewSources() { + const sources = queuedOriginalSources; + queuedOriginalSources = []; + currentWork = await newOriginalQueuedSources(sources); +} + +const queue = throttle(dispatchNewSources, 100); + +export default { + initialize: actions => { + newOriginalQueuedSources = actions.newOriginalSources; + queuedOriginalSources = []; + }, + queueOriginalSources: sources => { + if (sources.length) { + queuedOriginalSources = queuedOriginalSources.concat(sources); + queue(); + } + }, + + flush: () => Promise.all([queue.flush(), currentWork]), + clear: () => { + queuedOriginalSources = []; + queue.cancel(); + }, +}; diff --git a/devtools/client/debugger/src/utils/source.js b/devtools/client/debugger/src/utils/source.js new file mode 100644 index 0000000000..245caa67a9 --- /dev/null +++ b/devtools/client/debugger/src/utils/source.js @@ -0,0 +1,536 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Utils for working with Source URLs + * @module utils/source + */ + +const { getUnicodeUrl } = require("devtools/client/shared/unicode-url"); +const { + micromatch, +} = require("devtools/client/shared/vendor/micromatch/micromatch.js"); + +import { getRelativePath } from "../utils/sources-tree/utils"; +import { endTruncateStr } from "./utils"; +import { truncateMiddleText } from "../utils/text"; +import { parse as parseURL } from "../utils/url"; +import { memoizeLast } from "../utils/memoizeLast"; +import { renderWasmText } from "./wasm"; +import { toEditorLine } from "./editor"; +export { isMinified } from "./isMinified"; + +import { isFulfilled } from "./async-value"; + +export const sourceTypes = { + coffee: "coffeescript", + js: "javascript", + jsx: "react", + ts: "typescript", + tsx: "typescript", + vue: "vue", +}; + +export const javascriptLikeExtensions = new Set(["marko", "es6", "vue", "jsm"]); + +function getPath(source) { + const { path } = source.displayURL; + let lastIndex = path.lastIndexOf("/"); + let nextToLastIndex = path.lastIndexOf("/", lastIndex - 1); + + const result = []; + do { + result.push(path.slice(nextToLastIndex + 1, lastIndex)); + lastIndex = nextToLastIndex; + nextToLastIndex = path.lastIndexOf("/", lastIndex - 1); + } while (lastIndex !== nextToLastIndex); + + result.push(""); + + return result; +} + +export function shouldBlackbox(source) { + if (!source) { + return false; + } + + if (!source.url) { + return false; + } + + return true; +} + +/** + * Checks if the frame is within a line ranges which are blackboxed + * in the source. + * + * @param {Object} frame + * The current frame + * @param {Object} blackboxedRanges + * The currently blackboxedRanges for all the sources. + * @param {Boolean} isFrameBlackBoxed + * If the frame is within the blackboxed range + * or not. + */ +export function isFrameBlackBoxed(frame, blackboxedRanges) { + return ( + frame.source && + !!blackboxedRanges[frame.source.url] && + (!blackboxedRanges[frame.source.url].length || + !!findBlackBoxRange(frame.source, blackboxedRanges, { + start: frame.location.line, + end: frame.location.line, + })) + ); +} + +/** + * Checks if a blackbox range exist for the line range. + * That is if any start and end lines overlap any of the + * blackbox ranges + * + * @param {Object} source + * The current selected source + * @param {Object} blackboxedRanges + * The store of blackboxedRanges + * @param {Object} lineRange + * The start/end line range `{ start: <Number>, end: <Number> }` + * @return {Object} blackboxRange + * The first matching blackbox range that all or part of the + * specified lineRange sits within. + */ +export function findBlackBoxRange(source, blackboxedRanges, lineRange) { + const ranges = blackboxedRanges[source.url]; + if (!ranges || !ranges.length) { + return null; + } + + return ranges.find( + range => + (lineRange.start >= range.start.line && + lineRange.start <= range.end.line) || + (lineRange.end >= range.start.line && lineRange.end <= range.end.line) + ); +} + +/** + * Checks if a source line is blackboxed + * @param {Array} ranges - Line ranges that are blackboxed + * @param {Number} line + * @param {Boolean} isSourceOnIgnoreList - is the line in a source that is on + * the sourcemap ignore lists then the line is blackboxed. + * @returns boolean + */ +export function isLineBlackboxed(ranges, line, isSourceOnIgnoreList) { + if (isSourceOnIgnoreList) { + return true; + } + + if (!ranges) { + return false; + } + // If the whole source is ignored , then the line is + // ignored. + if (!ranges.length) { + return true; + } + return !!ranges.find( + range => line >= range.start.line && line <= range.end.line + ); +} + +/** + * Returns true if the specified url and/or content type are specific to + * javascript files. + * + * @return boolean + * True if the source is likely javascript. + * + * @memberof utils/source + * @static + */ +export function isJavaScript(source, content) { + const extension = source.displayURL.fileExtension; + const contentType = content.type === "wasm" ? null : content.contentType; + return ( + javascriptLikeExtensions.has(extension) || + !!(contentType && contentType.includes("javascript")) + ); +} + +/** + * @memberof utils/source + * @static + */ +export function isPretty(source) { + return isPrettyURL(source.url); +} + +export function isPrettyURL(url) { + return url ? url.endsWith(":formatted") : false; +} + +/** + * @memberof utils/source + * @static + */ +export function getPrettySourceURL(url) { + if (!url) { + url = ""; + } + return `${url}:formatted`; +} + +/** + * @memberof utils/source + * @static + */ +export function getRawSourceURL(url) { + return url && url.endsWith(":formatted") + ? url.slice(0, -":formatted".length) + : url; +} + +function resolveFileURL( + url, + transformUrl = initialUrl => initialUrl, + truncate = true +) { + url = getRawSourceURL(url || ""); + const name = transformUrl(url); + if (!truncate) { + return name; + } + return endTruncateStr(name, 50); +} + +export function getFormattedSourceId(id) { + return id.substring(id.lastIndexOf("/") + 1); +} + +/** + * Gets a readable filename from a source URL for display purposes. + * If the source does not have a URL, the source ID will be returned instead. + * + * @memberof utils/source + * @static + */ +export function getFilename( + source, + rawSourceURL = getRawSourceURL(source.url) +) { + const { id } = source; + if (!rawSourceURL) { + return getFormattedSourceId(id); + } + + const { filename } = source.displayURL; + return getRawSourceURL(filename); +} + +/** + * Provides a middle-trunated filename + * + * @memberof utils/source + * @static + */ +export function getTruncatedFileName(source, querystring = "", length = 30) { + return truncateMiddleText(`${getFilename(source)}${querystring}`, length); +} + +/* Gets path for files with same filename for editor tabs, breakpoints, etc. + * Pass the source, and list of other sources + * + * @memberof utils/source + * @static + */ + +export function getDisplayPath(mySource, sources) { + const rawSourceURL = getRawSourceURL(mySource.url); + const filename = getFilename(mySource, rawSourceURL); + + // Find sources that have the same filename, but different paths + // as the original source + const similarSources = sources.filter(source => { + const rawSource = getRawSourceURL(source.url); + return ( + rawSourceURL != rawSource && filename == getFilename(source, rawSource) + ); + }); + + if (!similarSources.length) { + return undefined; + } + + // get an array of source path directories e.g. ['a/b/c.html'] => [['b', 'a']] + const paths = new Array(similarSources.length + 1); + + paths[0] = getPath(mySource); + for (let i = 0; i < similarSources.length; ++i) { + paths[i + 1] = getPath(similarSources[i]); + } + + // create an array of similar path directories and one dis-similar directory + // for example [`a/b/c.html`, `a1/b/c.html`] => ['b', 'a'] + // where 'b' is the similar directory and 'a' is the dis-similar directory. + let displayPath = ""; + for (let i = 0; i < paths[0].length; i++) { + let similar = false; + for (let k = 1; k < paths.length; ++k) { + if (paths[k][i] === paths[0][i]) { + similar = true; + break; + } + } + + displayPath = paths[0][i] + (i !== 0 ? "/" : "") + displayPath; + + if (!similar) { + break; + } + } + + return displayPath; +} + +/** + * Gets a readable source URL for display purposes. + * If the source does not have a URL, the source ID will be returned instead. + * + * @memberof utils/source + * @static + */ +export function getFileURL(source, truncate = true) { + const { url, id } = source; + if (!url) { + return getFormattedSourceId(id); + } + + return resolveFileURL(url, getUnicodeUrl, truncate); +} + +export function getSourcePath(url) { + if (!url) { + return ""; + } + + const { path, href } = parseURL(url); + // for URLs like "about:home" the path is null so we pass the full href + return path || href; +} + +/** + * Returns amount of lines in the source. If source is a WebAssembly binary, + * the function returns amount of bytes. + */ +export function getSourceLineCount(content) { + if (content.type === "wasm") { + const { binary } = content.value; + return binary.length; + } + + let count = 0; + + for (let i = 0; i < content.value.length; ++i) { + if (content.value[i] === "\n") { + ++count; + } + } + + return count + 1; +} + +export function isInlineScript(source) { + return source.introductionType === "scriptElement"; +} + +function getNthLine(str, lineNum) { + let startIndex = -1; + + let newLinesFound = 0; + while (newLinesFound < lineNum) { + const nextIndex = str.indexOf("\n", startIndex + 1); + if (nextIndex === -1) { + return null; + } + startIndex = nextIndex; + newLinesFound++; + } + const endIndex = str.indexOf("\n", startIndex + 1); + if (endIndex === -1) { + return str.slice(startIndex + 1); + } + + return str.slice(startIndex + 1, endIndex); +} + +export const getLineText = memoizeLast((sourceId, asyncContent, line) => { + if (!asyncContent || !isFulfilled(asyncContent)) { + return ""; + } + + const content = asyncContent.value; + + if (content.type === "wasm") { + const editorLine = toEditorLine(sourceId, line); + const lines = renderWasmText(sourceId, content); + return lines[editorLine] || ""; + } + + const lineText = getNthLine(content.value, line - 1); + return lineText || ""; +}); + +export function getTextAtPosition(sourceId, asyncContent, location) { + const { column, line = 0 } = location; + + const lineText = getLineText(sourceId, asyncContent, line); + return lineText.slice(column, column + 100).trim(); +} + +/** + * Compute the CSS classname string to use for the icon of a given source. + * + * @param {Object} source + * The reducer source object. + * @param {Object} symbols + * The reducer symbol object for the given source. + * @param {Boolean} isBlackBoxed + * To be set to true, when the given source is blackboxed. + * @param {Boolean} hasPrettyTab + * To be set to true, if the given source isn't the pretty printed one, + * but another tab for that source is opened pretty printed. + * @return String + * The classname to use. + */ +export function getSourceClassnames( + source, + symbols, + isBlackBoxed, + hasPrettyTab = false +) { + // Conditionals should be ordered by priority of icon! + const defaultClassName = "file"; + + if (!source || !source.url) { + return defaultClassName; + } + + // In the SourceTree, we don't show the pretty printed sources, + // but still want to show the pretty print icon when a pretty printed tab + // for the current source is opened. + if (isPretty(source) || hasPrettyTab) { + return "prettyPrint"; + } + + if (isBlackBoxed) { + return "blackBox"; + } + + if (symbols && symbols.framework) { + return symbols.framework.toLowerCase(); + } + + if (isUrlExtension(source.url)) { + return "extension"; + } + + return sourceTypes[source.displayURL.fileExtension] || defaultClassName; +} + +export function getRelativeUrl(source, root) { + const { group, path } = source.displayURL; + if (!root) { + return path; + } + + // + 1 removes the leading "/" + const url = group + path; + return url.slice(url.indexOf(root) + root.length + 1); +} + +/** + * source.url doesn't include thread actor ID, so before calling underRoot(), the thread actor ID + * must be removed from the root, which this function handles. + * @param {string} root The root url to be cleaned + * @param {Set<Thread>} threads The list of threads + * @returns {string} The root url with thread actor IDs removed + */ +export function removeThreadActorId(root, threads) { + threads.forEach(thread => { + if (root.includes(thread.actor)) { + root = root.slice(thread.actor.length + 1); + } + }); + return root; +} + +/** + * Checks if the source is descendant of the root identified by the + * root url specified. The root might likely be projectDirectoryRoot which + * is a defined by a pref that allows users restrict the source tree to + * a subset of sources. + * + * @param {Object} source + * The source object + * @param {String} rootUrlWithoutThreadActor + * The url for the root node, without the thread actor ID. This can be obtained + * by calling removeThreadActorId() + */ +export function isDescendantOfRoot(source, rootUrlWithoutThreadActor) { + if (source.url && source.url.includes("chrome://")) { + const { group, path } = source.displayURL; + return (group + path).includes(rootUrlWithoutThreadActor); + } + + return !!source.url && source.url.includes(rootUrlWithoutThreadActor); +} + +export function isGenerated(source) { + return !source.isOriginal; +} + +export function getSourceQueryString(source) { + if (!source) { + return ""; + } + + return parseURL(getRawSourceURL(source.url)).search; +} + +export function isUrlExtension(url) { + return url.includes("moz-extension:") || url.includes("chrome-extension"); +} + +/** +* Checks that source url matches one of the glob patterns +* +* @param {Object} source +* @param {String} excludePatterns + String of comma-seperated glob patterns +* @return {return} Boolean value specifies if the string matches any + of the patterns. +*/ +export function matchesGlobPatterns(source, excludePatterns) { + if (!excludePatterns) { + return false; + } + const patterns = excludePatterns + .split(",") + .map(pattern => pattern.trim()) + .filter(pattern => pattern !== ""); + + if (!patterns.length) { + return false; + } + + return micromatch.contains( + // Makes sure we format the url or id exactly the way its displayed in the search ui, + // as user wil usually create glob patterns based on what is seen in the ui. + source.url ? getRelativePath(source.url) : getFormattedSourceId(source.id), + patterns + ); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/getURL.js b/devtools/client/debugger/src/utils/sources-tree/getURL.js new file mode 100644 index 0000000000..c06e522723 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/getURL.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { parse } from "../url"; + +const { + getUnicodeHostname, + getUnicodeUrlPath, +} = require("devtools/client/shared/unicode-url"); + +export function getFilenameFromPath(pathname) { + let filename = ""; + if (pathname) { + filename = pathname.substring(pathname.lastIndexOf("/") + 1); + // This file does not have a name. Default should be (index). + if (filename == "") { + filename = "(index)"; + } else if (filename == ":formatted") { + filename = "(index:formatted)"; + } + } + return filename; +} + +function getFileExtension(path) { + if (!path) { + return ""; + } + + const lastIndex = path.lastIndexOf("."); + return lastIndex !== -1 ? path.slice(lastIndex + 1).toLowerCase() : ""; +} + +const NoDomain = "(no domain)"; +const def = { + path: "", + search: "", + group: "", + filename: "", + fileExtension: "", +}; + +/** + * Compute the URL which may be displayed in the Source Tree. + * + * @param {String} url + * The source absolute URL as a string + * @param {String} extensionName + * Optional, but mandatory when passing a moz-extension URL. + * Name of the extension serving this moz-extension source. + * @return URL Object + * A URL object to represent this source. + * + * Note that this isn't the standard URL object. + * This is augmented with custom properties like: + * - `group`, which is mostly the host of the source's URL. + * This is used to sort sources in the Source tree. + * - `fileExtension`, lowercased file extension of the source + * (if any extension is available) + * - `path` and `pathname` have some special behavior. + * See `parse` implementation. + */ +export function getDisplayURL(url, extensionName = null) { + if (!url) { + return def; + } + + const { pathname, search, protocol, host } = parse(url); + const filename = getUnicodeUrlPath(getFilenameFromPath(pathname)); + + switch (protocol) { + case "javascript:": + // Ignore `javascript:` URLs for now + return def; + + case "moz-extension:": + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + // For moz-extension, we replace the uuid by the extension name + // that we receive from the SourceActor.extensionName attribute. + // `extensionName` might be null for content script of disabled add-ons. + group: extensionName || `${protocol}//${host}`, + }; + case "resource:": + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: `${protocol}//${host || ""}`, + }; + case "webpack:": + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: `Webpack`, + }; + case "ng:": + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: `Angular`, + }; + case "about:": + // An about page is a special case + return { + ...def, + path: "/", + search, + filename, + fileExtension: getFileExtension("/"), + group: url, + }; + + case "data:": + return { + ...def, + path: "/", + search, + filename: url, + fileExtension: getFileExtension("/"), + group: NoDomain, + }; + + case "": + if (pathname && pathname.startsWith("/")) { + // use file protocol for a URL like "/foo/bar.js" + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: "file://", + }; + } else if (!host) { + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: "", + }; + } + break; + + case "http:": + case "https:": + return { + ...def, + path: pathname, + search, + filename, + fileExtension: getFileExtension(pathname), + group: getUnicodeHostname(host), + }; + } + + return { + ...def, + path: pathname, + search, + fileExtension: getFileExtension(pathname), + filename, + group: protocol ? `${protocol}//` : "", + }; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/moz.build b/devtools/client/debugger/src/utils/sources-tree/moz.build new file mode 100644 index 0000000000..400c0f0d1a --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/moz.build @@ -0,0 +1,9 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +CompiledModules( + "getURL.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js new file mode 100644 index 0000000000..51919ffc4e --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getDisplayURL } from "../getURL"; + +describe("getUrl", () => { + it("handles normal url with http and https for filename", function () { + const urlObject = getDisplayURL("https://a/b.js"); + expect(urlObject.filename).toBe("b.js"); + + const urlObject2 = getDisplayURL("http://a/b.js"); + expect(urlObject2.filename).toBe("b.js"); + }); + + it("handles url with querystring for filename", function () { + const urlObject = getDisplayURL("https://a/b.js?key=randomKey"); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with '#' for filename", function () { + const urlObject = getDisplayURL("https://a/b.js#specialSection"); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with no file extension for filename", function () { + const urlObject = getDisplayURL("https://a/c"); + expect(urlObject.filename).toBe("c"); + }); + + it("handles url with no name for filename", function () { + const urlObject = getDisplayURL("https://a/"); + expect(urlObject.filename).toBe("(index)"); + }); + + it("separates resources by protocol and host", () => { + const urlObject = getDisplayURL("moz-extension://xyz/123"); + expect(urlObject.group).toBe("moz-extension://xyz"); + }); + + it("creates a group name for webpack", () => { + const urlObject = getDisplayURL("webpack:///src/component.jsx"); + expect(urlObject.group).toBe("Webpack"); + }); + + it("creates a group name for angular source", () => { + const urlObject = getDisplayURL("ng://src/component.jsx"); + expect(urlObject.group).toBe("Angular"); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/utils.js b/devtools/client/debugger/src/utils/sources-tree/utils.js new file mode 100644 index 0000000000..0a2f41752b --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/utils.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { parse } from "../../utils/url"; + +/** + * Get the relative path of the url + * Does not include any query parameters or fragment parts + * + * @param string url + * @returns string path + */ +export function getRelativePath(url) { + const { pathname } = parse(url); + if (!pathname) { + return url; + } + const index = pathname.indexOf("/"); + if (index !== -1) { + const path = pathname.slice(index + 1); + // If the path is empty this is likely the index file. + // e.g http://foo.com/ + if (path == "") { + return "(index)"; + } + return path; + } + return ""; +} + +/** + * + * @param {String} name: Name (e.g. computed in SourcesTreeItem renderItemName), + * which might include URI search. + * @returns {String} result of `decodedURI(name)`, or name if it `name` is malformed. + */ +export function safeDecodeItemName(name) { + try { + return decodeURI(name); + } catch (e) { + return name; + } +} diff --git a/devtools/client/debugger/src/utils/tabs.js b/devtools/client/debugger/src/utils/tabs.js new file mode 100644 index 0000000000..5f16b6ce63 --- /dev/null +++ b/devtools/client/debugger/src/utils/tabs.js @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* + * Finds the hidden tabs by comparing the tabs' top offset. + * hidden tabs will have a great top offset. + * + * @param sourceTabs Array + * @param sourceTabEls HTMLCollection + * + * @returns Array + */ + +export function getHiddenTabs(sourceTabs, sourceTabEls) { + sourceTabEls = [].slice.call(sourceTabEls); + function getTopOffset() { + const topOffsets = sourceTabEls.map(t => t.getBoundingClientRect().top); + return Math.min(...topOffsets); + } + + function hasTopOffset(el) { + // adding 10px helps account for cases where the tab might be offset by + // styling such as selected tabs which don't have a border. + const tabTopOffset = getTopOffset(); + return el.getBoundingClientRect().top > tabTopOffset + 10; + } + + return sourceTabs.filter((tab, index) => { + const element = sourceTabEls[index]; + return element && hasTopOffset(element); + }); +} + +export function getTabMenuItems() { + return { + closeTab: { + id: "node-menu-close-tab", + label: L10N.getStr("sourceTabs.closeTab"), + accesskey: L10N.getStr("sourceTabs.closeTab.accesskey"), + disabled: false, + }, + closeOtherTabs: { + id: "node-menu-close-other-tabs", + label: L10N.getStr("sourceTabs.closeOtherTabs"), + accesskey: L10N.getStr("sourceTabs.closeOtherTabs.accesskey"), + disabled: false, + }, + closeTabsToEnd: { + id: "node-menu-close-tabs-to-end", + label: L10N.getStr("sourceTabs.closeTabsToEnd"), + accesskey: L10N.getStr("sourceTabs.closeTabsToEnd.accesskey"), + disabled: false, + }, + closeAllTabs: { + id: "node-menu-close-all-tabs", + label: L10N.getStr("sourceTabs.closeAllTabs"), + accesskey: L10N.getStr("sourceTabs.closeAllTabs.accesskey"), + disabled: false, + }, + showSource: { + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: false, + }, + copySource: { + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + }, + copySourceUri2: { + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: false, + }, + toggleBlackBox: { + id: "node-menu-blackbox", + label: L10N.getStr("ignoreContextItem.ignore"), + accesskey: L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: false, + }, + prettyPrint: { + id: "node-menu-pretty-print", + label: L10N.getStr("sourceTabs.prettyPrint"), + accesskey: L10N.getStr("sourceTabs.prettyPrint.accesskey"), + disabled: false, + }, + }; +} + +/** + * Determines if a tab exists with the following properties + * + * @param {Object} tab + * @param {String} url + * @param {Boolean} isOriginal + */ +export function isSimilarTab(tab, url, isOriginal) { + return tab.url === url && tab.isOriginal === isOriginal; +} + +/** + * This cleans up some tab info (source id and thread info), + * mostly for persiting to pref and for navigation or reload. + * This is neccesary because the source and thread are destroyed + * and re-created across navigations / reloads. + * + * @param {Array} tabs + */ +export function persistTabs(tabs) { + return [...tabs] + .filter(tab => tab.url) + .map(tab => ({ + ...tab, + source: null, + sourceActor: null, + })); +} diff --git a/devtools/client/debugger/src/utils/task.js b/devtools/client/debugger/src/utils/task.js new file mode 100644 index 0000000000..25663fdd16 --- /dev/null +++ b/devtools/client/debugger/src/utils/task.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * This object provides the public module functions. + */ +export const Task = { + // XXX: Not sure if this works in all cases... + async: function (task) { + return function () { + return Task.spawn(task, this, arguments); + }; + }, + + /** + * Creates and starts a new task. + * @param task A generator function + * @return A promise, resolved when the task terminates + */ + spawn: function (task, scope, args) { + return new Promise(function (resolve, reject) { + const iterator = task.apply(scope, args); + + const callNext = lastValue => { + const iteration = iterator.next(lastValue); + Promise.resolve(iteration.value) + .then(value => { + if (iteration.done) { + resolve(value); + } else { + callNext(value); + } + }) + .catch(error => { + reject(error); + iterator.throw(error); + }); + }; + + callNext(undefined); + }); + }, +}; diff --git a/devtools/client/debugger/src/utils/telemetry.js b/devtools/client/debugger/src/utils/telemetry.js new file mode 100644 index 0000000000..3fb6fc23c9 --- /dev/null +++ b/devtools/client/debugger/src/utils/telemetry.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Usage: + * + * import { recordEvent } from "src/utils/telemetry"; + * + * // Event without extra properties + * recordEvent("add_breakpoint"); + * + * // Event with extra properties + * recordEvent("pause", { + * "reason": "debugger-statement", + * "collapsed_callstacks": 1 + * }); + * + * // If the properties are in multiple code paths and you can't send them all + * // in one go you will need to use the full telemetry API. + * + * const Telemetry = require("devtools/client/shared/telemetry"); + * + * const telemetry = new Telemetry(); + * + * // Prepare the event and define which properties to expect. + * // + * // NOTE: You CAN send properties before preparing the event. + * // + * telemetry.preparePendingEvent(this, "pause", "debugger", null, [ + * "reason", "collapsed_callstacks" + * ]); + * + * // Elsewhere in another codepath send the reason property + * telemetry.addEventProperty( + * this, "pause", "debugger", null, "reason", "debugger-statement" + * ); + * + * // Elsewhere in another codepath send the collapsed_callstacks property + * telemetry.addEventProperty( + * this, "pause", "debugger", null, "collapsed_callstacks", 1 + * ); + */ + +import { isNode } from "./environment"; + +let telemetry; + +if (isNode()) { + const Telemetry = require("devtools/client/shared/telemetry"); + telemetry = new Telemetry(); +} + +export function setToolboxTelemetry(toolboxTelemetry) { + telemetry = toolboxTelemetry; +} + +/** + * @memberof utils/telemetry + * @static + */ +export function recordEvent(eventName, fields = {}) { + telemetry.recordEvent(eventName, "debugger", null, fields); + + if (isNode()) { + const { events } = window.dbg._telemetry; + if (!events[eventName]) { + events[eventName] = []; + } + events[eventName].push(fields); + } +} diff --git a/devtools/client/debugger/src/utils/test-head.js b/devtools/client/debugger/src/utils/test-head.js new file mode 100644 index 0000000000..590726d08e --- /dev/null +++ b/devtools/client/debugger/src/utils/test-head.js @@ -0,0 +1,289 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Utils for Jest + * @module utils/test-head + */ + +import { combineReducers } from "redux"; +import reducers from "../reducers"; +import actions from "../actions"; +import * as selectors from "../selectors"; +import { + searchWorker, + prettyPrintWorker, + parserWorker, +} from "../test/tests-setup"; +import configureStore from "../actions/utils/create-store"; +import sourceQueue from "../utils/source-queue"; +import { setupCreate } from "../client/firefox/create"; +import { createLocation } from "./location"; + +// Import the internal module used by the source-map worker +// as node doesn't have Web Worker support and require path mapping +// doesn't work from nodejs worker thread and break mappings to devtools/ folder. +import sourceMapLoader from "devtools/client/shared/source-map-loader/source-map"; + +/** + * This file contains older interfaces used by tests that have not been + * converted to use test-mockup.js + */ + +/** + * @memberof utils/test-head + * @static + */ +function createStore(client, initialState = {}, sourceMapLoaderMock) { + const store = configureStore({ + log: false, + makeThunkArgs: args => { + return { + ...args, + client, + sourceMapLoader: + sourceMapLoaderMock !== undefined + ? sourceMapLoaderMock + : sourceMapLoader, + parserWorker, + prettyPrintWorker, + searchWorker, + }; + }, + })(combineReducers(reducers), initialState); + sourceQueue.clear(); + sourceQueue.initialize({ + newOriginalSources: sources => + store.dispatch(actions.newOriginalSources(sources)), + }); + + store.thunkArgs = () => ({ + dispatch: store.dispatch, + getState: store.getState, + client, + sourceMapLoader, + panel: {}, + }); + + // Put the initial context in the store, for convenience to unit tests. + store.cx = selectors.getThreadContext(store.getState()); + + setupCreate({ store }); + + return store; +} + +/** + * @memberof utils/test-head + * @static + */ +function commonLog(msg, data = {}) { + console.log(`[INFO] ${msg} ${JSON.stringify(data)}`); +} + +function makeFrame({ id, sourceId, thread }, opts = {}) { + const source = createSourceObject(sourceId); + const sourceActor = { + id: `${sourceId}-actor`, + actor: `${sourceId}-actor`, + source: sourceId, + sourceObject: source, + }; + return { + id, + scope: { bindings: { variables: {}, arguments: [] } }, + location: createLocation({ source, sourceActor, line: 4 }), + thread: thread || "FakeThread", + ...opts, + }; +} + +function createSourceObject(filename, props = {}) { + return { + id: filename, + url: makeSourceURL(filename), + isPrettyPrinted: false, + isExtension: false, + isOriginal: filename.includes("originalSource"), + }; +} + +function createOriginalSourceObject(generated) { + const rv = { + ...generated, + id: `${generated.id}/originalSource`, + }; + + return rv; +} + +function makeSourceURL(filename) { + return `http://localhost:8000/examples/${filename}`; +} + +function createMakeSource() { + const indicies = {}; + + return function (name, props = {}) { + const index = (indicies[name] | 0) + 1; + indicies[name] = index; + + // Mock a SOURCE Resource, which happens to be the SourceActor's form + // with resourceType and targetFront additional attributes + return { + resourceType: "source", + // Mock the targetFront to support makeSourceId function + targetFront: { + isDestroyed() { + return false; + }, + getCachedFront(typeName) { + return typeName == "thread" ? { actorID: "FakeThread" } : null; + }, + }, + // Allow to use custom ID's for reducer source objects + mockedJestID: name, + actor: `${name}-${index}-actor`, + url: `http://localhost:8000/examples/${name}`, + sourceMapBaseURL: props.sourceMapBaseURL || null, + sourceMapURL: props.sourceMapURL || null, + introductionType: props.introductionType || null, + extensionName: null, + }; + }; +} + +/** + * @memberof utils/test-head + * @static + */ +let creator; +beforeEach(() => { + creator = createMakeSource(); +}); +afterEach(() => { + creator = null; +}); +function makeSource(name, props) { + if (!creator) { + throw new Error("makeSource() cannot be called outside of a test"); + } + + return creator(name, props); +} + +function makeOriginalSource(source) { + return { + id: `${source.id}/originalSource`, + url: `${source.url}-original`, + sourceActor: { + thread: "FakeThread", + }, + }; +} + +function makeFuncLocation(startLine, endLine) { + if (!endLine) { + endLine = startLine + 1; + } + return { + start: { + line: startLine, + }, + end: { + line: endLine, + }, + }; +} + +function makeSymbolDeclaration(name, start, end, klass) { + return { + id: `${name}:${start}`, + name, + location: makeFuncLocation(start, end), + klass, + }; +} + +/** + * @memberof utils/test-head + * @static + */ +function waitForState(store, predicate) { + return new Promise(resolve => { + let ret = predicate(store.getState()); + if (ret) { + resolve(ret); + } + + const unsubscribe = store.subscribe(() => { + ret = predicate(store.getState()); + if (ret) { + unsubscribe(); + // NOTE: memoizableAction adds an additional tick for validating context + setTimeout(() => resolve(ret)); + } + }); + }); +} + +function watchForState(store, predicate) { + let sawState = false; + const checkState = function () { + if (!sawState && predicate(store.getState())) { + sawState = true; + } + return sawState; + }; + + let unsubscribe; + if (!checkState()) { + unsubscribe = store.subscribe(() => { + if (checkState()) { + unsubscribe(); + } + }); + } + + return function read() { + if (unsubscribe) { + unsubscribe(); + } + + return sawState; + }; +} + +function getTelemetryEvents(eventName) { + return window.dbg._telemetry.events[eventName] || []; +} + +function waitATick(callback) { + return new Promise(resolve => { + setTimeout(() => { + callback(); + resolve(); + }); + }); +} + +export { + actions, + selectors, + reducers, + createStore, + commonLog, + getTelemetryEvents, + makeFrame, + createSourceObject, + createOriginalSourceObject, + createMakeSource, + makeSourceURL, + makeSource, + makeOriginalSource, + makeSymbolDeclaration, + waitForState, + watchForState, + waitATick, +}; diff --git a/devtools/client/debugger/src/utils/test-mockup.js b/devtools/client/debugger/src/utils/test-mockup.js new file mode 100644 index 0000000000..3c3851a3d6 --- /dev/null +++ b/devtools/client/debugger/src/utils/test-mockup.js @@ -0,0 +1,270 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * This file is for use by unit tests for isolated debugger components that do + * not need to interact with the redux store. When these tests need to construct + * debugger objects, these interfaces should be used instead of plain object + * literals. + */ + +import * as asyncValue from "./async-value"; + +import { initialState } from "../reducers/index"; + +import { getDisplayURL } from "./sources-tree/getURL"; +import { createLocation } from "./location"; + +function makeMockSource(url = "url", id = "source", thread = "FakeThread") { + return { + id, + url, + displayURL: getDisplayURL(url), + thread, + isPrettyPrinted: false, + isWasm: false, + extensionName: null, + isExtension: false, + isOriginal: id.includes("originalSource"), + }; +} + +function makeMockDisplaySource( + url = "url", + id = "source", + thread = "FakeThread" +) { + return makeMockSource(url, id, thread); +} + +function makeMockSourceWithContent( + url, + id, + contentType = "text/javascript", + text = "" +) { + const source = makeMockSource(url, id); + + return { + ...source, + content: text + ? asyncValue.fulfilled({ + type: "text", + value: text, + contentType, + }) + : null, + }; +} + +function makeMockSourceAndContent( + url, + id, + contentType = "text/javascript", + text = "" +) { + const source = makeMockSource(url, id); + + return { + ...source, + content: { + type: "text", + value: text, + contentType, + }, + }; +} + +function makeFullfilledMockSourceContent( + text = "", + contentType = "text/javascript" +) { + return asyncValue.fulfilled({ + type: "text", + value: text, + contentType, + }); +} + +function makeMockWasmSource() { + return { + id: "wasm-source-id", + url: "url", + displayURL: getDisplayURL("url"), + thread: "FakeThread", + isPrettyPrinted: false, + isWasm: true, + extensionName: null, + isExtension: false, + isOriginal: false, + }; +} + +function makeMockWasmSourceWithContent(text) { + const source = makeMockWasmSource(); + + return { + ...source, + content: asyncValue.fulfilled({ + type: "wasm", + value: text, + }), + }; +} + +function makeMockScope(actor = "scope-actor", type = "block", parent = null) { + return { + actor, + parent, + bindings: { + arguments: [], + variables: {}, + }, + object: null, + function: null, + type, + scopeKind: "", + }; +} + +function mockScopeAddVariable(scope, name) { + if (!scope.bindings) { + throw new Error("no scope bindings"); + } + scope.bindings.variables[name] = { value: null }; +} + +function makeMockBreakpoint(source = makeMockSource(), line = 1, column) { + const location = column + ? { sourceId: source.id, source: { id: source.id }, line, column } + : { sourceId: source.id, source: { id: source.id }, line }; + return { + id: "breakpoint", + location, + generatedLocation: location, + disabled: false, + text: "text", + originalText: "text", + options: {}, + }; +} + +function makeMockFrame( + id = "frame", + source = makeMockSource("url"), + scope = makeMockScope(), + line = 4, + displayName = `display-${id}`, + index = 0 +) { + const sourceActor = { + id: `${source.id}-actor`, + actor: `${source.id}-actor`, + source: source.id, + sourceObject: source, + }; + const location = createLocation({ source, sourceActor, line }); + return { + id, + thread: "FakeThread", + displayName, + location, + generatedLocation: location, + source, + scope, + this: {}, + index, + asyncCause: null, + state: "on-stack", + type: "call", + }; +} + +function makeMockFrameWithURL(url) { + return makeMockFrame(undefined, makeMockSource(url)); +} + +function makeWhyNormal(frameReturnValue = undefined) { + if (frameReturnValue) { + return { type: "why-normal", frameFinished: { return: frameReturnValue } }; + } + return { type: "why-normal" }; +} + +function makeWhyThrow(frameThrowValue) { + return { type: "why-throw", frameFinished: { throw: frameThrowValue } }; +} + +function makeMockExpression(value) { + return { + input: "input", + value, + from: "from", + updating: false, + }; +} + +// Mock contexts for use in tests that do not create a redux store. +const mockcx = { navigateCounter: 0 }; +const mockthreadcx = { + navigateCounter: 0, + thread: "FakeThread", + pauseCounter: 0, + isPaused: false, +}; + +function makeMockThread(fields) { + return { + actor: "test", + url: "example.com", + type: "worker", + name: "test", + ...fields, + }; +} + +function makeMockState(state) { + return { + ...initialState(), + ...state, + }; +} + +function formatTree(tree, depth = 0, str = "") { + const whitespace = new Array(depth * 2).join(" "); + + if (tree.type === "directory") { + str += `${whitespace} - ${tree.name} path=${tree.path} \n`; + tree.contents.forEach(t => { + str = formatTree(t, depth + 1, str); + }); + } else { + str += `${whitespace} - ${tree.name} path=${tree.path} source_id=${tree.contents.id} \n`; + } + + return str; +} + +export { + makeMockDisplaySource, + makeMockSource, + makeMockSourceWithContent, + makeMockSourceAndContent, + makeMockWasmSource, + makeMockWasmSourceWithContent, + makeMockScope, + mockScopeAddVariable, + makeMockBreakpoint, + makeMockFrame, + makeMockFrameWithURL, + makeWhyNormal, + makeWhyThrow, + makeMockExpression, + mockcx, + mockthreadcx, + makeMockState, + makeMockThread, + makeFullfilledMockSourceContent, + formatTree, +}; diff --git a/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js new file mode 100644 index 0000000000..be405d8628 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/DevToolsUtils.spec.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { reportException, executeSoon } from "../DevToolsUtils.js"; + +describe("DevToolsUtils", () => { + describe("reportException", () => { + beforeEach(() => { + global.console = { error: jest.fn() }; + }); + + it("calls console.error", () => { + reportException("caller", ["you broke it"]); + expect(console.error).toHaveBeenCalled(); + }); + + it("returns a description of caller and exception text", () => { + const who = "who", + exception = "this is an error", + msgTxt = " threw an exception: "; + + reportException(who, [exception]); + + expect(console.error).toHaveBeenCalledWith(`${who}${msgTxt}`, [ + exception, + ]); + }); + }); + + describe("executeSoon", () => { + it("calls setTimeout with 0 ms", () => { + global.setTimeout = jest.fn(); + const fnc = () => {}; + + executeSoon(fnc); + + expect(setTimeout).toHaveBeenCalledWith(fnc, 0); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap new file mode 100644 index 0000000000..7fe8b4f716 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/ast.spec.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`find the best expression for the token should find the expression for the property 1`] = ` +Object { + "computed": false, + "expression": "obj.b", + "location": Object { + "end": Position { + "column": 17, + "line": 6, + }, + "start": Position { + "column": 16, + "line": 6, + }, + }, + "name": "b", +} +`; + +exports[`find the best expression for the token should find the identifier 1`] = ` +Object { + "expression": "key", + "location": Object { + "end": Position { + "column": 13, + "line": 1, + }, + "start": Position { + "column": 10, + "line": 1, + }, + }, + "name": "key", +} +`; + +exports[`find the best expression for the token should find the identifier for computed member expressions 1`] = ` +Object { + "expression": "key", + "location": Object { + "end": Position { + "column": 9, + "line": 5, + }, + "start": Position { + "column": 6, + "line": 5, + }, + }, + "name": "key", +} +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap new file mode 100644 index 0000000000..a0d6b17bf6 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/expressions.spec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expressions wrap expression should wrap an expression 1`] = ` +"try { + foo +} catch (e) { + e +}" +`; + +exports[`expressions wrap expression should wrap expression with a comment 1`] = ` +"try { + foo // yo yo +} catch (e) { + e +}" +`; + +exports[`expressions wrap expression should wrap quotes 1`] = ` +"try { + \\"2\\" +} catch (e) { + e +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap new file mode 100644 index 0000000000..8d83a48d3b --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/function.spec.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`function findFunctionText finds class function 1`] = ` +"bar() { + 2 + 2; +}" +`; + +exports[`function findFunctionText finds function 1`] = ` +"async function exSlowFoo() { + return \\"yay in a bit\\"; +}" +`; + +exports[`function findFunctionText finds function signature 1`] = ` +"async function exSlowFoo() { + return \\"yay in a bit\\"; +}" +`; + +exports[`function findFunctionText finds property function 1`] = ` +"function name() { + 2 + 2; +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap new file mode 100644 index 0000000000..883d48a7ff --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/__snapshots__/indentation.spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`indentation mad indentation 1`] = ` +"try { +console.log(\\"yo\\") +} catch (e) { +console.log(\\"yo\\") + }" +`; + +exports[`indentation one function 1`] = ` +"function foo() { + console.log(\\"yo\\") +}" +`; + +exports[`indentation one line 1`] = `"foo"`; + +exports[`indentation simple 1`] = `"foo"`; + +exports[`indentation try catch 1`] = ` +"try { + console.log(\\"yo\\") +} catch (e) { + console.log(\\"yo\\") +}" +`; diff --git a/devtools/client/debugger/src/utils/tests/assert.spec.js b/devtools/client/debugger/src/utils/tests/assert.spec.js new file mode 100644 index 0000000000..031665c5d3 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/assert.spec.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import assert from "../assert.js"; + +let testAssertMessageHead, testAssertMessage; + +describe("assert", () => { + beforeEach(() => { + testAssertMessageHead = "Assertion failure: "; + testAssertMessage = "Test assert.js Message"; + }); + + describe("when condition is truthy", () => { + it("does not throw an Error", () => { + expect(() => { + assert(true, testAssertMessage); + }).not.toThrow(); + }); + }); + + describe("when condition is falsy", () => { + it("throws an Error displaying the passed message", () => { + expect(() => { + assert(false, testAssertMessage); + }).toThrow(new Error(testAssertMessageHead + testAssertMessage)); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/ast.spec.js b/devtools/client/debugger/src/utils/tests/ast.spec.js new file mode 100644 index 0000000000..55e9e429d5 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/ast.spec.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { findBestMatchExpression } from "../ast"; + +import { getSymbols } from "../../workers/parser/getSymbols"; +import { populateSource } from "../../workers/parser/tests/helpers"; + +describe("find the best expression for the token", () => { + const source = populateSource("computed-props"); + const symbols = getSymbols(source.id); + + it("should find the identifier", () => { + const expression = findBestMatchExpression(symbols, { + line: 1, + column: 13, + }); + expect(expression).toMatchSnapshot(); + }); + + it("should find the expression for the property", () => { + const expression = findBestMatchExpression(symbols, { + line: 6, + column: 16, + }); + expect(expression).toMatchSnapshot(); + }); + + it("should find the identifier for computed member expressions", () => { + const expression = findBestMatchExpression(symbols, { line: 5, column: 6 }); + expect(expression).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/build-query.spec.js b/devtools/client/debugger/src/utils/tests/build-query.spec.js new file mode 100644 index 0000000000..dbd0eba4a4 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/build-query.spec.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import buildQuery from "../build-query"; + +describe("build-query", () => { + it("case-sensitive, whole-word, regex search", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: true, + }, + {} + ); + + expect(query.source).toBe("\\bhi.*\\b"); + expect(query.flags).toBe(""); + expect(query.ignoreCase).toBe(false); + }); + + it("case-sensitive, whole-word, regex search, global", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: true, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi.*\\b"); + expect(query.flags).toBe("g"); + expect(query.ignoreCase).toBe(false); + }); + + it("case-insensitive, non-whole, string search", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: false, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("i"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-insensitive, non-whole, string search, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("gi"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-sensitive string search", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe(""); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with wholeWord", () => { + const query = buildQuery( + "hi", + { + caseSensitive: false, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe("i"); + expect(query.ignoreCase).toBe(true); + }); + + it("case-insensitive, regex search", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: false, + wholeWord: false, + regexMatch: true, + }, + {} + ); + + expect(query.source).toBe("hi.*"); + expect(query.flags).toBe("i"); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(true); + }); + + it("string search with wholeWord and case sensitivity", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with wholeWord and case sensitivity, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi\\b"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with regex chars escaped", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + {} + ); + + expect(query.source).toBe("\\bhi\\.\\*\\b"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("string search with regex chars escaped, global", () => { + const query = buildQuery( + "hi.*", + { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }, + { isGlobal: true } + ); + + expect(query.source).toBe("\\bhi\\.\\*\\b"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/o spaces", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { ignoreSpaces: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/o spaces, global", () => { + const query = buildQuery( + "hi", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true, ignoreSpaces: true } + ); + + expect(query.source).toBe("hi"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/ spaces", () => { + const query = buildQuery( + " ", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { ignoreSpaces: true } + ); + + expect(query.source).toBe("(?!\\s*.*)"); + expect(query.flags).toBe(""); + expect(query.global).toBe(false); + expect(query.ignoreCase).toBe(false); + }); + + it("ignore spaces w/ spaces, global", () => { + const query = buildQuery( + " ", + { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }, + { isGlobal: true, ignoreSpaces: true } + ); + + expect(query.source).toBe("(?!\\s*.*)"); + expect(query.flags).toBe("g"); + expect(query.global).toBe(true); + expect(query.ignoreCase).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/clipboard.spec.js b/devtools/client/debugger/src/utils/tests/clipboard.spec.js new file mode 100644 index 0000000000..dcba730149 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/clipboard.spec.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { copyToTheClipboard } from "../clipboard"; + +let clipboardTestCopyString, expectedCopyEvent; +const addEventListener = jest.fn(); +const execCommand = jest.fn(); +const removeEventListener = jest.fn(); + +describe("copyToTheClipboard()", () => { + beforeEach(() => { + expectedCopyEvent = "copy"; + clipboardTestCopyString = "content intended for clipboard"; + + global.document.addEventListener = addEventListener; + global.document.execCommand = execCommand; + global.document.removeEventListener = removeEventListener; + }); + + it("listens for 'copy' event", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(document.addEventListener).toHaveBeenCalledWith( + expectedCopyEvent, + expect.anything() + ); + }); + + it("calls document.execCommand() with 'copy' command", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(execCommand).toHaveBeenCalledWith(expectedCopyEvent, false, null); + }); + + it("removes event listener for 'copy' event", () => { + copyToTheClipboard(clipboardTestCopyString); + + expect(document.removeEventListener).toHaveBeenCalledWith( + expectedCopyEvent, + expect.anything() + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/expressions.spec.js b/devtools/client/debugger/src/utils/tests/expressions.spec.js new file mode 100644 index 0000000000..b7c91c4e07 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/expressions.spec.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + wrapExpression, + getExpressionResultGripAndFront, +} from "../expressions"; +import { makeMockExpression } from "../test-mockup"; + +function createError(type, preview) { + return makeMockExpression({ + result: { getGrip: () => ({ class: type, isError: true, preview }) }, + }); +} + +describe("expressions", () => { + describe("wrap expression", () => { + it("should wrap an expression", () => { + expect(wrapExpression("foo")).toMatchSnapshot(); + }); + + it("should wrap expression with a comment", () => { + expect(wrapExpression("foo // yo yo")).toMatchSnapshot(); + }); + + it("should wrap quotes", () => { + expect(wrapExpression('"2"')).toMatchSnapshot(); + }); + }); + + describe("sanitize input", () => { + it("sanitizes quotes", () => { + expect('foo"').toEqual('foo"'); + }); + + it("sanitizes 2 quotes", () => { + expect('"3"').toEqual('"3"'); + }); + + it("evaluates \\u{61} as a", () => { + expect("\u{61}").toEqual("a"); + }); + + it("evaluates N\\u{61}N as NaN", () => { + expect("N\u{61}N").toEqual("NaN"); + }); + }); + + describe("getValue", () => { + it("Reference Errors should be shown as (unavailable)", () => { + const { expressionResultGrip } = getExpressionResultGripAndFront( + createError("ReferenceError", { name: "ReferenceError" }) + ); + expect(expressionResultGrip).toEqual({ + unavailable: true, + }); + }); + + it("Errors messages should be shown", () => { + const { expressionResultGrip } = getExpressionResultGripAndFront( + createError("Error", { name: "Foo", message: "YO" }) + ); + expect(expressionResultGrip).toEqual("Foo: YO"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/function.spec.js b/devtools/client/debugger/src/utils/tests/function.spec.js new file mode 100644 index 0000000000..7376aa1280 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/function.spec.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { findFunctionText } from "../function"; + +import { getSymbols } from "../../workers/parser/getSymbols"; +import { populateOriginalSource } from "../../workers/parser/tests/helpers"; + +describe("function", () => { + describe("findFunctionText", () => { + it("finds function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + const text = findFunctionText(14, source, source.content, symbols); + expect(text).toMatchSnapshot(); + }); + + it("finds function signature", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(13, source, source.content, symbols); + expect(text).toMatchSnapshot(); + }); + + it("misses function closing brace", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(15, source, source.content, symbols); + + // TODO: we should try and match the closing bracket. + expect(text).toEqual(null); + }); + + it("finds property function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(29, source, source.content, symbols); + expect(text).toMatchSnapshot(); + }); + + it("finds class function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(33, source, source.content, symbols); + expect(text).toMatchSnapshot(); + }); + + it("cant find function", () => { + const source = populateOriginalSource("func"); + const symbols = getSymbols(source.id); + + const text = findFunctionText(20, source, source.content, symbols); + expect(text).toEqual(null); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/indentation.spec.js b/devtools/client/debugger/src/utils/tests/indentation.spec.js new file mode 100644 index 0000000000..5ee7419371 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/indentation.spec.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { correctIndentation, getIndentation } from "../indentation"; + +describe("indentation", () => { + it("simple", () => { + expect( + correctIndentation(` + foo + `) + ).toMatchSnapshot(); + }); + + it("one line", () => { + expect(correctIndentation("foo")).toMatchSnapshot(); + }); + + it("one function", () => { + const text = ` + function foo() { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); + + it("try catch", () => { + const text = ` + try { + console.log("yo") + } catch (e) { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); + + it("mad indentation", () => { + const text = ` + try { + console.log("yo") + } catch (e) { + console.log("yo") + } + `; + + expect(correctIndentation(text)).toMatchSnapshot(); + }); +}); + +describe("indentation length", () => { + it("leading spaces", () => { + const line = " console.log('Hello World');"; + + expect(getIndentation(line)).toEqual(16); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/isMinified.spec.js b/devtools/client/debugger/src/utils/tests/isMinified.spec.js new file mode 100644 index 0000000000..1c49f96737 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/isMinified.spec.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isMinified } from "../isMinified"; +import { makeMockSourceWithContent } from "../test-mockup"; + +describe("isMinified", () => { + it("no indents", () => { + const sourceWithContent = makeMockSourceWithContent( + undefined, + undefined, + undefined, + "function base(boo) {\n}" + ); + expect(isMinified(sourceWithContent, sourceWithContent.content)).toBe(true); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/location.spec.js b/devtools/client/debugger/src/utils/tests/location.spec.js new file mode 100644 index 0000000000..2be71f3cae --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/location.spec.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { sortSelectedLocations } from "../location"; + +function loc(line, column) { + return { + location: { sourceId: "foo", line, column }, + generatedLocation: { sourceId: "foo", line, column }, + }; +} +describe("location.spec.js", () => { + it("sorts lines", () => { + const a = loc(3, 5); + const b = loc(1, 10); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); + + it("sorts columns", () => { + const a = loc(3, 10); + const b = loc(3, 5); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); + + it("prioritizes undefined columns", () => { + const a = loc(3, 10); + const b = loc(3, undefined); + expect(sortSelectedLocations([a, b], { id: "foo" })).toEqual([b, a]); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/log.spec.js b/devtools/client/debugger/src/utils/tests/log.spec.js new file mode 100644 index 0000000000..ea7e4ca4d2 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/log.spec.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs } from "../prefs"; +import { log } from "../log.js"; + +let logArgFirst, logArgSecond, logArgArray; + +describe("log()", () => { + beforeEach(() => { + logArgFirst = "my info"; + logArgSecond = "my other info"; + logArgArray = [logArgFirst, logArgSecond]; + global.console = { log: jest.fn() }; + }); + + afterEach(() => { + prefs.logging = false; + }); + + describe("when logging pref is true", () => { + it("prints arguments", () => { + prefs.logging = true; + log(logArgArray); + + expect(global.console.log).toHaveBeenCalledWith(logArgArray); + }); + + it("does not print by default", () => { + log(logArgArray); + expect(global.console.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/memoize.spec.js b/devtools/client/debugger/src/utils/tests/memoize.spec.js new file mode 100644 index 0000000000..fb72958516 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/memoize.spec.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import memoize from "../memoize"; + +const a = { number: 3 }; +const b = { number: 4 }; +const c = { number: 5 }; +const d = { number: 6 }; + +function add(...numberObjects) { + return numberObjects.reduce((prev, cur) => prev + cur.number, 0); +} + +describe("memozie", () => { + it("should work for one arg as key", () => { + const mAdd = memoize(add); + mAdd(a); + expect(mAdd(a)).toEqual(3); + mAdd(b); + expect(mAdd(b)).toEqual(4); + }); + + it("should only be called once", () => { + const spy = jest.fn(() => 2); + const mAdd = memoize(spy); + + mAdd(a); + mAdd(a); + mAdd(a); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("should work for two args as key", () => { + const mAdd = memoize(add); + expect(mAdd(a, b)).toEqual(7); + expect(mAdd(a, b)).toEqual(7); + expect(mAdd(a, c)).toEqual(8); + }); + + it("should work with many args as key", () => { + const mAdd = memoize(add); + expect(mAdd(a, b, c)).toEqual(12); + expect(mAdd(a, b, d)).toEqual(13); + expect(mAdd(a, b, c)).toEqual(12); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js new file mode 100644 index 0000000000..a5622510e3 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/memoizeLast.spec.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { memoizeLast } from "../memoizeLast"; + +const a = { number: 3 }; +const b = { number: 4 }; + +function add(...numberObjects) { + return numberObjects.reduce((prev, cur) => prev + cur.number, 0); +} + +describe("memozie", () => { + it("should re-calculate when a value changes", () => { + const mAdd = memoizeLast(add); + mAdd(a); + expect(mAdd(a)).toEqual(3); + mAdd(b); + expect(mAdd(b)).toEqual(4); + }); + + it("should only run once", () => { + const mockAdd = jest.fn(add); + const mAdd = memoizeLast(mockAdd); + mAdd(a); + mAdd(a); + + expect(mockAdd.mock.calls[0]).toEqual([{ number: 3 }]); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/path.spec.js b/devtools/client/debugger/src/utils/tests/path.spec.js new file mode 100644 index 0000000000..58bdf046f0 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/path.spec.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { basename, dirname, isURL, isAbsolute, join } from "../path"; + +const fullTestURL = "https://www.example.com/some/endpoint"; +const absoluteTestPath = "/some/absolute/path/to/resource"; +const aTestName = "name"; + +describe("basename()", () => { + it("returns the basename of the path", () => { + expect(basename(fullTestURL)).toBe("endpoint"); + }); +}); + +describe("dirname()", () => { + it("returns the current directory in a path", () => { + expect(dirname(fullTestURL)).toBe("https://www.example.com/some"); + }); +}); + +describe("isURL()", () => { + it("returns true if a string contains characters denoting a scheme", () => { + expect(isURL(fullTestURL)).toBe(true); + }); + + it("returns false if string does not denote a scheme", () => { + expect(isURL(absoluteTestPath)).toBe(false); + }); +}); + +describe("isAbsolute()", () => { + it("returns true if a string begins with a slash", () => { + expect(isAbsolute(absoluteTestPath)).toBe(true); + }); + + it("returns false if a string does not begin with a slash", () => { + expect(isAbsolute(fullTestURL)).toBe(false); + }); +}); + +describe("join()", () => { + it("concatenates a base path and a directory name", () => { + expect(join(absoluteTestPath, aTestName)).toBe( + "/some/absolute/path/to/resource/name" + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/quick-open.spec.js b/devtools/client/debugger/src/utils/tests/quick-open.spec.js new file mode 100644 index 0000000000..37d4b99fe6 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/quick-open.spec.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import cases from "jest-in-case"; +import { parseQuickOpenQuery, parseLineColumn } from "../quick-open"; + +cases( + "parseQuickOpenQuery utility", + ({ type, query }) => expect(parseQuickOpenQuery(query)).toEqual(type), + [ + { name: "empty query defaults to sources", type: "sources", query: "" }, + { name: "sources query", type: "sources", query: "test" }, + { name: "functions query", type: "functions", query: "@test" }, + { name: "variables query", type: "variables", query: "#test" }, + { name: "goto line", type: "goto", query: ":30" }, + { name: "goto line:column", type: "goto", query: ":30:60" }, + { name: "goto source line", type: "gotoSource", query: "test:30:60" }, + { name: "shortcuts", type: "shortcuts", query: "?" }, + ] +); + +cases( + "parseLineColumn utility", + ({ query, location }) => expect(parseLineColumn(query)).toEqual(location), + [ + { name: "empty query", query: "", location: null }, + { name: "just line", query: ":30", location: { line: 30 } }, + { + name: "line and column", + query: ":30:90", + location: { column: 90, line: 30 }, + }, + ] +); diff --git a/devtools/client/debugger/src/utils/tests/result-list.spec.js b/devtools/client/debugger/src/utils/tests/result-list.spec.js new file mode 100644 index 0000000000..a0ce0376a1 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/result-list.spec.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { scrollList } from "../result-list.js"; + +describe("scrollList", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + /* eslint-disable jest/expect-expect */ + it("just returns if element not found", () => { + const li = document.createElement("li"); + scrollList([li], 1); + }); + /* eslint-enable jest/expect-expect */ + + it("calls scrollIntoView ", () => { + const ul = document.createElement("ul"); + const li = document.createElement("li"); + + li.scrollIntoView = jest.fn(); + ul.appendChild(li); + + scrollList([li], 0); + + jest.runAllTimers(); + + expect(li.scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/source.spec.js b/devtools/client/debugger/src/utils/tests/source.spec.js new file mode 100644 index 0000000000..484c8ce570 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/source.spec.js @@ -0,0 +1,367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + getFilename, + getTruncatedFileName, + getFileURL, + getDisplayPath, + getSourceLineCount, + isJavaScript, + isDescendantOfRoot, + removeThreadActorId, + isUrlExtension, + getLineText, +} from "../source.js"; + +import { + makeMockSource, + makeMockSourceWithContent, + makeMockSourceAndContent, + makeMockWasmSourceWithContent, + makeMockThread, + makeFullfilledMockSourceContent, +} from "../test-mockup"; +import { isFulfilled } from "../async-value.js"; + +describe("sources", () => { + const unicode = "\u6e2c"; + const encodedUnicode = encodeURIComponent(unicode); + + describe("getFilename", () => { + it("should give us a default of (index)", () => { + expect( + getFilename(makeMockSource("http://localhost.com:7999/increment/")) + ).toBe("(index)"); + }); + it("should give us the filename", () => { + expect( + getFilename( + makeMockSource("http://localhost.com:7999/increment/hello.html") + ) + ).toBe("hello.html"); + }); + it("should give us the readable Unicode filename if encoded", () => { + expect( + getFilename( + makeMockSource( + `http://localhost.com:7999/increment/${encodedUnicode}.html` + ) + ) + ).toBe(`${unicode}.html`); + }); + it("should give us the filename excluding the query strings", () => { + expect( + getFilename( + makeMockSource( + "http://localhost.com:7999/increment/hello.html?query_strings" + ) + ) + ).toBe("hello.html"); + }); + it("should give us the proper filename for pretty files", () => { + expect( + getFilename( + makeMockSource( + "http://localhost.com:7999/increment/hello.html:formatted" + ) + ) + ).toBe("hello.html"); + }); + }); + + describe("getTruncatedFileName", () => { + it("should truncate the file name when it is more than 30 chars", () => { + expect( + getTruncatedFileName( + makeMockSource( + "really-really-really-really-really-really-long-name.html" + ), + "", + 30 + ) + ).toBe("really-really…long-name.html"); + }); + it("should first decode the filename and then truncate it", () => { + expect( + getTruncatedFileName( + makeMockSource(`${encodedUnicode.repeat(30)}.html`), + "", + 30 + ) + ).toBe("測測測測測測測測測測測測測…測測測測測測測測測.html"); + }); + }); + + describe("getDisplayPath", () => { + it("should give us the path for files with same name", () => { + const sources = [ + makeMockSource("http://localhost.com:7999/increment/xyz/hello.html"), + makeMockSource("http://localhost.com:7999/increment/abc/hello.html"), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource("http://localhost.com:7999/increment/abc/hello.html"), + sources + ) + ).toBe("abc"); + }); + + it(`should give us the path for files with same name + in directories with same name`, () => { + const sources = [ + makeMockSource( + "http://localhost.com:7999/increment/xyz/web/hello.html" + ), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + sources + ) + ).toBe("abc/web"); + }); + + it("should give no path for files with unique name", () => { + const sources = [ + makeMockSource("http://localhost.com:7999/increment/xyz.html"), + makeMockSource("http://localhost.com:7999/increment/abc.html"), + makeMockSource("http://localhost.com:7999/increment/hello.html"), + ]; + expect( + getDisplayPath( + makeMockSource("http://localhost.com:7999/increment/abc/web.html"), + sources + ) + ).toBe(undefined); + }); + it("should not show display path for pretty file", () => { + const sources = [ + makeMockSource("http://localhost.com:7999/increment/abc/web/hell.html"), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html" + ), + makeMockSource( + "http://localhost.com:7999/increment/xyz.html:formatted" + ), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + sources + ) + ).toBe(undefined); + }); + it(`should give us the path for files with same name when both + are pretty and different path`, () => { + const sources = [ + makeMockSource( + "http://localhost.com:7999/increment/xyz/web/hello.html:formatted" + ), + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + makeMockSource( + "http://localhost.com:7999/increment/hello.html:formatted" + ), + ]; + expect( + getDisplayPath( + makeMockSource( + "http://localhost.com:7999/increment/abc/web/hello.html:formatted" + ), + sources + ) + ).toBe("abc/web"); + }); + }); + + describe("getFileURL", () => { + it("should give us the file url", () => { + expect( + getFileURL( + makeMockSource("http://localhost.com:7999/increment/hello.html") + ) + ).toBe("http://localhost.com:7999/increment/hello.html"); + }); + it("should truncate the file url when it is more than 50 chars", () => { + expect( + getFileURL( + makeMockSource("http://localhost-long.com:7999/increment/hello.html") + ) + ).toBe("…ttp://localhost-long.com:7999/increment/hello.html"); + }); + it("should first decode the file URL and then truncate it", () => { + expect( + getFileURL(makeMockSource(`http://${encodedUnicode.repeat(39)}.html`)) + ).toBe(`…ttp://${unicode.repeat(39)}.html`); + }); + }); + + describe("isJavaScript", () => { + it("is not JavaScript", () => { + { + const source = makeMockSourceAndContent("foo.html", undefined, ""); + expect(isJavaScript(source, source.content)).toBe(false); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/html" + ); + expect(isJavaScript(source, source.content)).toBe(false); + } + }); + + it("is JavaScript", () => { + { + const source = makeMockSourceAndContent("foo.js"); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent("bar.jsm"); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "text/javascript" + ); + expect(isJavaScript(source, source.content)).toBe(true); + } + { + const source = makeMockSourceAndContent( + undefined, + undefined, + "application/javascript" + ); + expect(isJavaScript(source, source.content)).toBe(true); + } + }); + }); + + describe("getSourceLineCount", () => { + it("should give us the amount bytes for wasm source", () => { + const { content } = makeMockWasmSourceWithContent({ + binary: "\x00asm\x01\x00\x00\x00", + }); + expect(getSourceLineCount(content.value)).toEqual(8); + }); + + it("should give us the amout of lines for js source", () => { + const { content } = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + "function foo(){\n}" + ); + if (!content || !isFulfilled(content)) { + throw new Error("Unexpected content value"); + } + expect(getSourceLineCount(content.value)).toEqual(2); + }); + }); + + describe("isDescendantOfRoot", () => { + const threads = [ + makeMockThread({ actor: "server0.conn1.child1/thread19" }), + ]; + + it("should detect normal source urls", () => { + const source = makeMockSource( + "resource://activity-stream/vendor/react.js" + ); + const rootWithoutThreadActor = removeThreadActorId( + "resource://activity-stream", + threads + ); + expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true); + }); + + it("should detect source urls under chrome:// as root", () => { + const source = makeMockSource( + "chrome://browser/content/contentSearchUI.js" + ); + const rootWithoutThreadActor = removeThreadActorId("chrome://", threads); + expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true); + }); + + it("should detect source urls if root is a thread actor Id", () => { + const source = makeMockSource( + "resource://activity-stream/vendor/react-dom.js" + ); + const rootWithoutThreadActor = removeThreadActorId( + "server0.conn1.child1/thread19", + threads + ); + expect(isDescendantOfRoot(source, rootWithoutThreadActor)).toBe(true); + }); + }); + + describe("isUrlExtension", () => { + it("should detect mozilla extension", () => { + expect(isUrlExtension("moz-extension://id/js/content.js")).toBe(true); + }); + it("should detect chrome extension", () => { + expect(isUrlExtension("chrome-extension://id/js/content.js")).toBe(true); + }); + it("should return false for non-extension assets", () => { + expect(isUrlExtension("https://example.org/init.js")).toBe(false); + }); + }); + + describe("getLineText", () => { + it("first line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + 1 + ); + + expect(text).toEqual("aaa"); + }); + + it("last line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + 3 + ); + + expect(text).toEqual("ccc"); + }); + + it("one line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa"), + 1 + ); + + expect(text).toEqual("aaa"); + }); + + it("bad line", () => { + const text = getLineText( + "fake-source", + makeFullfilledMockSourceContent("aaa\nbbb\nccc"), + + 5 + ); + + expect(text).toEqual(""); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/telemetry.spec.js b/devtools/client/debugger/src/utils/tests/telemetry.spec.js new file mode 100644 index 0000000000..7223641afd --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/telemetry.spec.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { recordEvent } from "../telemetry"; + +describe("telemetry.recordEvent()", () => { + it("Receives the correct telemetry information", () => { + recordEvent("foo", { bar: 1 }); + + expect(window.dbg._telemetry.events.foo).toStrictEqual([{ bar: 1 }]); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/text.spec.js b/devtools/client/debugger/src/utils/tests/text.spec.js new file mode 100644 index 0000000000..5786bc6232 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/text.spec.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { truncateMiddleText } from "../text"; + +describe("text", () => { + it("should truncate the text in the middle", () => { + const sourceText = "this is a very long text and ends here"; + expect(truncateMiddleText(sourceText, 30)).toMatch( + "this is a ver… and ends here" + ); + }); + it("should keep the text as it is", () => { + const sourceText = "this is a short text ends here"; + expect(truncateMiddleText(sourceText, 30)).toMatch( + "this is a short text ends here" + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/ui.spec.js b/devtools/client/debugger/src/utils/tests/ui.spec.js new file mode 100644 index 0000000000..aa091e6798 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/ui.spec.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { isVisible } from "../ui"; + +describe("ui", () => { + it("should return #mount width", () => { + if (!document.body) { + throw new Error("no document body"); + } + document.body.innerHTML = "<div id='mount'></div>"; + expect(isVisible()).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/url.spec.js b/devtools/client/debugger/src/utils/tests/url.spec.js new file mode 100644 index 0000000000..d842acf7db --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/url.spec.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { parse } from "../url"; + +describe("url", () => { + describe("parse", () => { + it("parses an absolute URL", () => { + const val = parse("http://example.com:8080/path/file.js"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe(""); + }); + + it("parses an absolute URL with query params", () => { + const val = parse("http://example.com:8080/path/file.js?param"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe(""); + }); + + it("parses an absolute URL with a fragment", () => { + const val = parse("http://example.com:8080/path/file.js#hash"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe("#hash"); + }); + + it("parses an absolute URL with query params and a fragment", () => { + const val = parse("http://example.com:8080/path/file.js?param#hash"); + + expect(val.protocol).toBe("http:"); + expect(val.host).toBe("example.com:8080"); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe("#hash"); + }); + + it("parses a partial URL", () => { + const val = parse("/path/file.js"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe(""); + }); + + it("parses a partial URL with query params", () => { + const val = parse("/path/file.js?param"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe(""); + }); + + it("parses a partial URL with a fragment", () => { + const val = parse("/path/file.js#hash"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe(""); + expect(val.hash).toBe("#hash"); + }); + + it("parses a partial URL with query params and a fragment", () => { + const val = parse("/path/file.js?param#hash"); + + expect(val.protocol).toBe(""); + expect(val.host).toBe(""); + expect(val.pathname).toBe("/path/file.js"); + expect(val.search).toBe("?param"); + expect(val.hash).toBe("#hash"); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/utils.spec.js b/devtools/client/debugger/src/utils/tests/utils.spec.js new file mode 100644 index 0000000000..f358ffc42a --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/utils.spec.js @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { handleError, promisify, endTruncateStr, waitForMs } from "../utils"; + +describe("handleError()", () => { + const testErrorText = "ERROR: "; + const testErrorObject = { oh: "noes" }; + + beforeEach(() => { + global.console = { log: jest.fn() }; + }); + + it("logs error text with error value", () => { + handleError(testErrorObject); + + expect(console.log).toHaveBeenCalledWith(testErrorText, testErrorObject); + }); +}); + +describe("promisify()", () => { + let testPromise, testContext, testMethod, testArgs; + + beforeEach(() => { + testContext = {}; + testMethod = jest.fn(); + testArgs = []; + }); + + it("returns a Promise", () => { + testPromise = promisify(testContext, testMethod, testArgs); + + expect(testPromise instanceof Promise).toBe(true); + }); + + it("applies promisified method", () => { + testPromise = promisify(testContext, testMethod, testArgs); + + expect(testMethod).toHaveBeenCalledWith(testArgs, expect.anything()); + }); +}); + +describe("endTruncateStr()", () => { + let testString; + const testSize = 11; + + describe("when the string is larger than the specified size", () => { + it("returns an elipsis and characters at the end of the string", () => { + testString = "Mozilla Firefox is my favorite web browser"; + + expect(endTruncateStr(testString, testSize)).toBe("…web browser"); + }); + }); + + describe("when the string is not larger than the specified size", () => { + it("returns the string unchanged", () => { + testString = "Firefox"; + + expect(endTruncateStr(testString, testSize)).toBe(testString); + }); + }); +}); + +describe("waitForMs()", () => { + let testPromise; + const testMilliseconds = 10; + + beforeEach(() => { + global.setTimeout = jest.fn(); + }); + + it("returns a Promise", () => { + testPromise = waitForMs(testMilliseconds); + + expect(testPromise instanceof Promise).toBe(true); + }); + + it("calls setTimeout() on the resolve of the Promise", () => { + testPromise = waitForMs(testMilliseconds); + + expect(setTimeout).toHaveBeenCalledWith( + expect.anything(), + testMilliseconds + ); + }); +}); diff --git a/devtools/client/debugger/src/utils/tests/wasm.spec.js b/devtools/client/debugger/src/utils/tests/wasm.spec.js new file mode 100644 index 0000000000..e803393c12 --- /dev/null +++ b/devtools/client/debugger/src/utils/tests/wasm.spec.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { + isWasm, + lineToWasmOffset, + wasmOffsetToLine, + clearWasmStates, + renderWasmText, +} from "../wasm.js"; + +import { makeMockWasmSourceWithContent } from "../test-mockup"; + +describe("wasm", () => { + // Compiled version of `(module (func (nop)))` + const SIMPLE_WASM = { + binary: + "\x00asm\x01\x00\x00\x00\x01\x84\x80\x80\x80\x00\x01`\x00\x00" + + "\x03\x82\x80\x80\x80\x00\x01\x00\x06\x81\x80\x80\x80\x00\x00" + + "\n\x89\x80\x80\x80\x00\x01\x83\x80\x80\x80\x00\x00\x01\v", + }; + + // malformed binary which contains an unknown operator (\x09) which + // should cause the wasm parser to throw. + const MALFORMED_SIMPLE_WASM = { + binary: + "\x00asm\x01\x00\x00\x00\x09\x84\x80\x80\x80\x00\x01`\x00\x00" + + "\x03\x82\x80\x80\x80\x00\x01\x00\x06\x81\x80\x80\x80\x00\x00" + + "\n\x89\x80\x80\x80\x00\x01\x83\x80\x80\x80\x00\x00\x01\v", + }; + + const SIMPLE_WASM_TEXT = `(module + (func $func0 + nop + ) +)`; + const SIMPLE_WASM_NOP_TEXT_LINE = 2; + const SIMPLE_WASM_NOP_OFFSET = 46; + + describe("isWasm", () => { + it("should give us the false when wasm text was not registered", () => { + const sourceId = "source.0"; + expect(isWasm(sourceId)).toEqual(false); + }); + it("should give us the true when wasm text was registered", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + expect(isWasm(source.id)).toEqual(true); + // clear shall remove + clearWasmStates(); + expect(isWasm(source.id)).toEqual(false); + }); + }); + + describe("renderWasmText", () => { + it("render simple wasm", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + const lines = renderWasmText(source.id, source.content.value); + expect(lines.join("\n")).toEqual(SIMPLE_WASM_TEXT); + clearWasmStates(); + }); + + it("should return error information when the parser throws", () => { + const source = makeMockWasmSourceWithContent(MALFORMED_SIMPLE_WASM); + const lines = renderWasmText(source.id, source.content.value); + expect(lines.join("\n")).toEqual( + "Error occured during wast conversion : Unknown operator: 6" + ); + clearWasmStates(); + }); + }); + + describe("lineToWasmOffset", () => { + // Test data sanity check: checking if 'nop' is found in the SIMPLE_WASM. + expect(SIMPLE_WASM.binary[SIMPLE_WASM_NOP_OFFSET]).toEqual("\x01"); + + it("get simple wasm nop offset", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + const offset = lineToWasmOffset(source.id, SIMPLE_WASM_NOP_TEXT_LINE); + expect(offset).toEqual(SIMPLE_WASM_NOP_OFFSET); + clearWasmStates(); + }); + }); + + describe("wasmOffsetToLine", () => { + it("get simple wasm nop line", () => { + const source = makeMockWasmSourceWithContent(SIMPLE_WASM); + renderWasmText(source.id, source.content.value); + const line = wasmOffsetToLine(source.id, SIMPLE_WASM_NOP_OFFSET); + expect(line).toEqual(SIMPLE_WASM_NOP_TEXT_LINE); + clearWasmStates(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/text.js b/devtools/client/debugger/src/utils/text.js new file mode 100644 index 0000000000..19f99b3175 --- /dev/null +++ b/devtools/client/debugger/src/utils/text.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Utils for keyboard command strings + * @module utils/text + */ + +const isMacOS = Services.appinfo.OS === "Darwin"; + +/** + * Formats key for use in tooltips + * For macOS we use the following unicode + * + * cmd ⌘ = \u2318 + * shift ⇧ – \u21E7 + * option (alt) ⌥ \u2325 + * + * For Win/Lin this replaces CommandOrControl or CmdOrCtrl with Ctrl + * + * @memberof utils/text + * @static + */ +export function formatKeyShortcut(shortcut) { + if (isMacOS) { + return shortcut + .replace(/Shift\+/g, "\u21E7") + .replace(/Command\+|Cmd\+/g, "\u2318") + .replace(/CommandOrControl\+|CmdOrCtrl\+/g, "\u2318") + .replace(/Alt\+/g, "\u2325"); + } + return shortcut + .replace(/CommandOrControl\+|CmdOrCtrl\+/g, `${L10N.getStr("ctrl")}+`) + .replace(/Shift\+/g, "Shift+"); +} + +/** + * Truncates the received text to the maxLength in the format: + * Original: 'this is a very long text and ends here' + * Truncated: 'this is a ver...and ends here' + * @param {String} sourceText - Source text + * @param {Number} maxLength - Max allowed length + * @memberof utils/text + * @static + */ +export function truncateMiddleText(sourceText, maxLength) { + let truncatedText = sourceText; + if (sourceText.length > maxLength) { + truncatedText = `${sourceText.substring( + 0, + Math.round(maxLength / 2) - 2 + )}…${sourceText.substring( + sourceText.length - Math.round(maxLength / 2 - 1) + )}`; + } + return truncatedText; +} diff --git a/devtools/client/debugger/src/utils/ui.js b/devtools/client/debugger/src/utils/ui.js new file mode 100644 index 0000000000..eab5bb1e07 --- /dev/null +++ b/devtools/client/debugger/src/utils/ui.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* Checks to see if the root element is available and + * if the element is visible. We check the width of the element + * because it is more reliable than either checking a focus state or + * the visibleState or hidden property. + */ +export function isVisible() { + const el = document.querySelector("#mount"); + return !!(el && el.getBoundingClientRect().width > 0); +} + +/* Gets the line numbers width in the code editor + */ +export function getLineNumberWidth(editor) { + const { gutters } = editor.display; + const lineNumbers = gutters.querySelector(".CodeMirror-linenumbers"); + return lineNumbers?.clientWidth; +} + +/** + * Forces the breakpoint gutter to be the same size as the line + * numbers gutter. Editor CSS will absolutely position the gutter + * beneath the line numbers. This makes it easy to be flexible with + * how we overlay breakpoints. + */ +export function resizeBreakpointGutter(editor) { + const { gutters } = editor.display; + const breakpoints = gutters.querySelector(".breakpoints"); + if (breakpoints) { + breakpoints.style.width = `${getLineNumberWidth(editor)}px`; + } +} + +/** + * Forces the left toggle button in source header to be the same size + * as the line numbers gutter. + */ +export function resizeToggleButton(editor) { + const toggleButton = document.querySelector( + ".source-header .toggle-button-start" + ); + if (toggleButton) { + toggleButton.style.width = `${getLineNumberWidth(editor)}px`; + } +} diff --git a/devtools/client/debugger/src/utils/url.js b/devtools/client/debugger/src/utils/url.js new file mode 100644 index 0000000000..2aabc31258 --- /dev/null +++ b/devtools/client/debugger/src/utils/url.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const defaultUrl = { + hash: "", + host: "", + hostname: "", + href: "", + origin: "null", + password: "", + path: "", + pathname: "", + port: "", + protocol: "", + search: "", + // This should be a "URLSearchParams" object + searchParams: {}, + username: "", +}; + +const parseCache = new Map(); +export function parse(url) { + if (parseCache.has(url)) { + return parseCache.get(url); + } + + let urlObj; + try { + urlObj = new URL(url); + } catch (err) { + urlObj = { ...defaultUrl }; + // If we're given simply a filename... + if (url) { + const hashStart = url.indexOf("#"); + if (hashStart >= 0) { + urlObj.hash = url.slice(hashStart); + url = url.slice(0, hashStart); + + if (urlObj.hash === "#") { + // The standard URL parser does not include the ? unless there are + // parameters included in the search value. + urlObj.hash = ""; + } + } + + const queryStart = url.indexOf("?"); + if (queryStart >= 0) { + urlObj.search = url.slice(queryStart); + url = url.slice(0, queryStart); + + if (urlObj.search === "?") { + // The standard URL parser does not include the ? unless there are + // parameters included in the search value. + urlObj.search = ""; + } + } + + urlObj.pathname = url; + } + } + // When provided a special URL like "webpack:///webpack/foo", + // prevents passing the three slashes in the path, and pass only onea. + // This will prevent displaying modules in empty-name sub folders. + urlObj.pathname = urlObj.pathname.replace(/\/+/, "/"); + urlObj.path = urlObj.pathname + urlObj.search; + + // Cache the result + parseCache.set(url, urlObj); + return urlObj; +} + +export function sameOrigin(firstUrl, secondUrl) { + return parse(firstUrl).origin == parse(secondUrl).origin; +} diff --git a/devtools/client/debugger/src/utils/utils.js b/devtools/client/debugger/src/utils/utils.js new file mode 100644 index 0000000000..74f0f026a4 --- /dev/null +++ b/devtools/client/debugger/src/utils/utils.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +/** + * Utils for utils, by utils + * @module utils/utils + */ + +/** + * @memberof utils/utils + * @static + */ +export function handleError(err) { + console.log("ERROR: ", err); +} + +/** + * @memberof utils/utils + * @static + */ +export function promisify(context, method, ...args) { + return new Promise((resolve, reject) => { + args.push(response => { + if (response.error) { + reject(response); + } else { + resolve(response); + } + }); + method.apply(context, args); + }); +} + +/** + * @memberof utils/utils + * @static + */ +export function endTruncateStr(str, size) { + if (str.length > size) { + return `…${str.slice(str.length - size)}`; + } + return str; +} + +export function waitForMs(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function saveAsLocalFile(content, fileName) { + if (content.type !== "text") { + return null; + } + + const data = new TextEncoder().encode(content.value); + return DevToolsUtils.saveAs(window, data, fileName); +} diff --git a/devtools/client/debugger/src/utils/wasm.js b/devtools/client/debugger/src/utils/wasm.js new file mode 100644 index 0000000000..fa1c23c39c --- /dev/null +++ b/devtools/client/debugger/src/utils/wasm.js @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* */ + +import { BinaryReader } from "wasmparser/dist/cjs/WasmParser"; +import { + WasmDisassembler, + NameSectionReader, +} from "wasmparser/dist/cjs/WasmDis"; + +var wasmStates = Object.create(null); + +function maybeWasmSectionNameResolver(data) { + try { + const parser = new BinaryReader(); + parser.setData(data.buffer, 0, data.length); + const reader = new NameSectionReader(); + reader.read(parser); + return reader.hasValidNames() ? reader.getNameResolver() : null; + } catch (ex) { + // Ignoring any errors during names section retrival. + return null; + } +} + +/** + * @memberof utils/wasm + * @static + */ +export function getWasmText(sourceId, data) { + const nameResolver = maybeWasmSectionNameResolver(data); + const parser = new BinaryReader(); + let result; + parser.setData(data.buffer, 0, data.length); + const dis = new WasmDisassembler(); + if (nameResolver) { + dis.nameResolver = nameResolver; + } + dis.addOffsets = true; + try { + const done = dis.disassembleChunk(parser); + result = dis.getResult(); + if (result.lines.length === 0) { + result = { lines: ["No luck with wast conversion"], offsets: [0], done }; + } + } catch (e) { + result = { + lines: [`Error occured during wast conversion : ${e.message}`], + offsets: [0], + done: null, + }; + } + + const { offsets } = result; + const lines = []; + for (let i = 0; i < offsets.length; i++) { + lines[offsets[i]] = i; + } + + wasmStates[sourceId] = { offsets, lines }; + + return { lines: result.lines, done: result.done }; +} + +/** + * @memberof utils/wasm + * @static + */ +export function getWasmLineNumberFormatter(sourceId) { + const codeOf0 = 48, + codeOfA = 65; + const buffer = [ + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + codeOf0, + ]; + let last0 = 7; + return function (number) { + const offset = lineToWasmOffset(sourceId, number - 1); + if (offset == undefined) { + return ""; + } + let i = 7; + for (let n = offset; n !== 0 && i >= 0; n >>= 4, i--) { + const nibble = n & 15; + buffer[i] = nibble < 10 ? codeOf0 + nibble : codeOfA - 10 + nibble; + } + for (let j = i; j > last0; j--) { + buffer[j] = codeOf0; + } + last0 = i; + return String.fromCharCode.apply(null, buffer); + }; +} + +/** + * @memberof utils/wasm + * @static + */ +export function isWasm(sourceId) { + return sourceId in wasmStates; +} + +/** + * @memberof utils/wasm + * @static + */ +export function lineToWasmOffset(sourceId, number) { + const wasmState = wasmStates[sourceId]; + if (!wasmState) { + return undefined; + } + let offset = wasmState.offsets[number]; + while (offset === undefined && number > 0) { + offset = wasmState.offsets[--number]; + } + return offset; +} + +/** + * @memberof utils/wasm + * @static + */ +export function wasmOffsetToLine(sourceId, offset) { + const wasmState = wasmStates[sourceId]; + if (!wasmState) { + return undefined; + } + return wasmState.lines[offset]; +} + +/** + * @memberof utils/wasm + * @static + */ +export function clearWasmStates() { + wasmStates = Object.create(null); +} + +const wasmLines = new WeakMap(); +export function renderWasmText(sourceId, content) { + if (wasmLines.has(content)) { + return wasmLines.get(content) || []; + } + + // binary does not survive as Uint8Array, converting from string + const { binary } = content.value; + const data = new Uint8Array(binary.length); + for (let i = 0; i < data.length; i++) { + data[i] = binary.charCodeAt(i); + } + const { lines } = getWasmText(sourceId, data); + const MAX_LINES = 1000000; + if (lines.length > MAX_LINES) { + lines.splice(MAX_LINES, lines.length - MAX_LINES); + lines.push(";; .... text is truncated due to the size"); + } + + wasmLines.set(content, lines); + return lines; +} diff --git a/devtools/client/debugger/src/utils/worker.js b/devtools/client/debugger/src/utils/worker.js new file mode 100644 index 0000000000..8cd7071466 --- /dev/null +++ b/devtools/client/debugger/src/utils/worker.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +let msgId = 1; +/** + * @memberof utils/utils + * @static + */ +function workerTask(worker, method) { + return function (...args) { + return new Promise((resolve, reject) => { + const id = msgId++; + worker.postMessage({ id, method, args }); + + const listener = ({ data: result }) => { + if (result.id !== id) { + return; + } + + worker.removeEventListener("message", listener); + if (result.error) { + reject(result.error); + } else { + resolve(result.response); + } + }; + + worker.addEventListener("message", listener); + }); + }; +} + +function workerHandler(publicInterface) { + return function onTask(msg) { + const { id, method, args } = msg.data; + const response = publicInterface[method].apply(null, args); + + if (response instanceof Promise) { + response + .then(val => self.postMessage({ id, response: val })) + .catch(error => self.postMessage({ id, error })); + } else { + self.postMessage({ id, response }); + } + }; +} + +export { workerTask, workerHandler }; diff --git a/devtools/client/debugger/src/vendors.js b/devtools/client/debugger/src/vendors.js new file mode 100644 index 0000000000..c9398ac632 --- /dev/null +++ b/devtools/client/debugger/src/vendors.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Vendors.js is a file used to bundle and expose all dependencies needed to run + * the transpiled debugger modules when running in Firefox. + * + * To make transpilation easier, a vendored module should always be imported in + * same way: + * - always with destructuring (import { a } from "modA";) + * - always without destructuring (import modB from "modB") + * + * Both are fine, but cannot be mixed for the same module. + */ + +import * as fuzzaldrinPlus from "fuzzaldrin-plus"; +import * as transition from "react-transition-group/Transition"; +import * as reactAriaComponentsTabs from "react-aria-components/src/tabs"; + +// We cannot directly export literals containing special characters +// (eg. "my-module/Test") which is why they are nested in "vendored". +// The keys of the vendored object should match the module names +// !!! Should remain synchronized with .babel/transform-mc.js !!! +export const vendored = { + "fuzzaldrin-plus": fuzzaldrinPlus, + "react-aria-components/src/tabs": reactAriaComponentsTabs, + "react-transition-group/Transition": transition, +}; diff --git a/devtools/client/debugger/src/workers/moz.build b/devtools/client/debugger/src/workers/moz.build new file mode 100644 index 0000000000..12327bf177 --- /dev/null +++ b/devtools/client/debugger/src/workers/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "parser", + "pretty-print", + "search", +] + +CompiledModules() diff --git a/devtools/client/debugger/src/workers/parser/findOutOfScopeLocations.js b/devtools/client/debugger/src/workers/parser/findOutOfScopeLocations.js new file mode 100644 index 0000000000..642ec8b650 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/findOutOfScopeLocations.js @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { containsLocation, containsPosition } from "./utils/contains"; + +import { getSymbols } from "./getSymbols"; + +function findSymbols(source) { + const { functions, comments } = getSymbols(source); + return { functions, comments }; +} + +/** + * Returns the location for a given function path. If the path represents a + * function declaration, the location will begin after the function identifier + * but before the function parameters. + */ + +function getLocation(func) { + const location = { ...func.location }; + + // if the function has an identifier, start the block after it so the + // identifier is included in the "scope" of its parent + const identifierEnd = func?.identifier?.loc?.end; + if (identifierEnd) { + location.start = identifierEnd; + } + + return location; +} + +/** + * Find the nearest location containing the input position and + * return inner locations under that nearest location + * + * @param {Array<Object>} locations Notice! The locations MUST be sorted by `sortByStart` + * so that we can do linear time complexity operation. + * @returns {Array<Object>} + */ +function getInnerLocations(locations, position) { + // First, let's find the nearest position-enclosing function location, + // which is to find the last location enclosing the position. + let parentIndex; + for (let i = locations.length - 1; i >= 0; i--) { + const loc = locations[i]; + if (containsPosition(loc, position)) { + parentIndex = i; + break; + } + } + + if (parentIndex == undefined) { + return []; + } + const parentLoc = locations[parentIndex]; + + // Then, from the nearest location, loop locations again and put locations into + // the innerLocations array until we get to a location not enclosed by the nearest location. + const innerLocations = []; + for (let i = parentIndex + 1; i < locations.length; i++) { + const loc = locations[i]; + if (!containsLocation(parentLoc, loc)) { + break; + } + + innerLocations.push(loc); + } + + return innerLocations; +} + +/** + * Return an new locations array which excludes + * items that are completely enclosed by another location in the input locations + * + * @param locations Notice! The locations MUST be sorted by `sortByStart` + * so that we can do linear time complexity operation. + */ +function removeOverlaps(locations) { + if (!locations.length) { + return []; + } + const firstParent = locations[0]; + return locations.reduce(deduplicateNode, [firstParent]); +} + +function deduplicateNode(nodes, location) { + const parent = nodes[nodes.length - 1]; + if (!containsLocation(parent, location)) { + nodes.push(location); + } + return nodes; +} + +/** + * Sorts an array of locations by start position + */ +function sortByStart(a, b) { + if (a.start.line < b.start.line) { + return -1; + } else if (a.start.line === b.start.line) { + return a.start.column - b.start.column; + } + + return 1; +} + +/** + * Returns an array of locations that are considered out of scope for the given + * location. + */ +function findOutOfScopeLocations(location) { + const { functions, comments } = findSymbols(location.source.id); + const commentLocations = comments.map(c => c.location); + const locations = functions + .map(getLocation) + .concat(commentLocations) + .sort(sortByStart); + + const innerLocations = getInnerLocations(locations, location); + const outerLocations = locations.filter(loc => { + if (innerLocations.includes(loc)) { + return false; + } + + return !containsPosition(loc, location); + }); + return removeOverlaps(outerLocations); +} + +export default findOutOfScopeLocations; diff --git a/devtools/client/debugger/src/workers/parser/frameworks.js b/devtools/client/debugger/src/workers/parser/frameworks.js new file mode 100644 index 0000000000..85011c0780 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/frameworks.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 * as t from "@babel/types"; + +export function getFramework(symbols) { + if (isReactComponent(symbols)) { + return "React"; + } + if (isAngularComponent(symbols)) { + return "Angular"; + } + if (isVueComponent(symbols)) { + return "Vue"; + } + + return null; +} + +function isReactComponent({ imports, classes, callExpressions, identifiers }) { + return ( + importsReact(imports) || + requiresReact(callExpressions) || + extendsReactComponent(classes) || + isReact(identifiers) || + isRedux(identifiers) + ); +} + +function importsReact(imports) { + return imports.some( + importObj => + importObj.source === "react" && + importObj.specifiers.some(specifier => specifier === "React") + ); +} + +function requiresReact(callExpressions) { + return callExpressions.some( + callExpression => + callExpression.name === "require" && + callExpression.values.some(value => value === "react") + ); +} + +function extendsReactComponent(classes) { + return classes.some( + classObj => + t.isIdentifier(classObj.parent, { name: "Component" }) || + t.isIdentifier(classObj.parent, { name: "PureComponent" }) || + (t.isMemberExpression(classObj.parent, { computed: false }) && + t.isIdentifier(classObj.parent, { name: "Component" })) + ); +} + +function isAngularComponent({ memberExpressions }) { + return memberExpressions.some( + item => + item.expression == "angular.controller" || + item.expression == "angular.module" + ); +} + +function isVueComponent({ identifiers }) { + return identifiers.some(identifier => identifier.name == "Vue"); +} + +/* This identifies the react lib file */ +function isReact(identifiers) { + return identifiers.some(identifier => identifier.name == "isReactComponent"); +} + +/* This identifies the redux lib file */ +function isRedux(identifiers) { + return identifiers.some(identifier => identifier.name == "Redux"); +} diff --git a/devtools/client/debugger/src/workers/parser/getScopes/index.js b/devtools/client/debugger/src/workers/parser/getScopes/index.js new file mode 100644 index 0000000000..cd2503791c --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/getScopes/index.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { buildScopeList, parseSourceScopes } from "./visitor"; + +let parsedScopesCache = new Map(); + +export default function getScopes(location) { + const { sourceId } = location; + let parsedScopes = parsedScopesCache.get(sourceId); + if (!parsedScopes) { + parsedScopes = parseSourceScopes(sourceId); + parsedScopesCache.set(sourceId, parsedScopes); + } + return parsedScopes ? findScopes(parsedScopes, location) : []; +} + +export function clearScopes() { + parsedScopesCache = new Map(); +} + +export { buildScopeList }; + +/** + * Searches all scopes and their bindings at the specific location. + */ +function findScopes(scopes, location) { + // Find inner most in the tree structure. + let searchInScopes = scopes; + const found = []; + while (searchInScopes) { + const foundOne = searchInScopes.some(s => { + if ( + compareLocations(s.start, location) <= 0 && + compareLocations(location, s.end) < 0 + ) { + // Found the next scope, trying to search recusevly in its children. + found.unshift(s); + searchInScopes = s.children; + return true; + } + return false; + }); + if (!foundOne) { + break; + } + } + return found.map(i => ({ + type: i.type, + scopeKind: i.scopeKind, + displayName: i.displayName, + start: i.start, + end: i.end, + bindings: i.bindings, + })); +} + +function compareLocations(a, b) { + // According to type of Location.column can be undefined, if will not be the + // case here, ignoring flow error. + return a.line == b.line ? a.column - b.column : a.line - b.line; +} diff --git a/devtools/client/debugger/src/workers/parser/getScopes/visitor.js b/devtools/client/debugger/src/workers/parser/getScopes/visitor.js new file mode 100644 index 0000000000..13c7f0bcfc --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/getScopes/visitor.js @@ -0,0 +1,869 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 * as t from "@babel/types"; + +import getFunctionName from "../utils/getFunctionName"; +import { getAst } from "../utils/ast"; + +/** + * "implicit" + * Variables added automaticly like "this" and "arguments" + * + * "var" + * Variables declared with "var" or non-block function declarations + * + * "let" + * Variables declared with "let". + * + * "const" + * Variables declared with "const", or added as const + * bindings like inner function expressions and inner class names. + * + * "import" + * Imported binding names exposed from other modules. + * + * "global" + * Variables that reference undeclared global values. + */ + +// Location information about the expression immediartely surrounding a +// given binding reference. + +function isGeneratedId(id) { + return !/\/originalSource/.test(id); +} + +export function parseSourceScopes(sourceId) { + const ast = getAst(sourceId); + if (!ast || !Object.keys(ast).length) { + return null; + } + + return buildScopeList(ast, sourceId); +} + +export function buildScopeList(ast, sourceId) { + const { global, lexical } = createGlobalScope(ast, sourceId); + + const state = { + sourceId, + freeVariables: new Map(), + freeVariableStack: [], + inType: null, + scope: lexical, + scopeStack: [], + declarationBindingIds: new Set(), + }; + t.traverse(ast, scopeCollectionVisitor, state); + + for (const [key, freeVariables] of state.freeVariables) { + let binding = global.bindings[key]; + if (!binding) { + binding = { + type: "global", + refs: [], + }; + global.bindings[key] = binding; + } + + binding.refs = freeVariables.concat(binding.refs); + } + + // TODO: This should probably check for ".mjs" extension on the + // original file, and should also be skipped if the the generated + // code is an ES6 module rather than a script. + if ( + isGeneratedId(sourceId) || + (ast.program.sourceType === "script" && !looksLikeCommonJS(global)) + ) { + stripModuleScope(global); + } + + return toParsedScopes([global], sourceId) || []; +} + +function toParsedScopes(children, sourceId) { + if (!children || children.length === 0) { + return undefined; + } + return children.map(scope => ({ + // Removing unneed information from TempScope such as parent reference. + // We also need to convert BabelLocation to the Location type. + start: scope.loc.start, + end: scope.loc.end, + type: + scope.type === "module" || scope.type === "function-body" + ? "block" + : scope.type, + scopeKind: "", + displayName: scope.displayName, + bindings: scope.bindings, + children: toParsedScopes(scope.children, sourceId), + })); +} + +function createTempScope(type, displayName, parent, loc) { + const result = { + type, + displayName, + parent, + children: [], + loc, + bindings: Object.create(null), + }; + if (parent) { + parent.children.push(result); + } + return result; +} +function pushTempScope(state, type, displayName, loc) { + const scope = createTempScope(type, displayName, state.scope, loc); + + state.scope = scope; + + state.freeVariableStack.push(state.freeVariables); + state.freeVariables = new Map(); + return scope; +} + +function isNode(node, type) { + return node ? node.type === type : false; +} + +function getVarScope(scope) { + let s = scope; + while (s.type !== "function" && s.type !== "module") { + if (!s.parent) { + return s; + } + s = s.parent; + } + return s; +} + +function fromBabelLocation(location, sourceId) { + return { + sourceId, + line: location.line, + column: location.column, + }; +} + +function parseDeclarator( + declaratorId, + targetScope, + type, + locationType, + declaration, + state +) { + if (isNode(declaratorId, "Identifier")) { + let existing = targetScope.bindings[declaratorId.name]; + if (!existing) { + existing = { + type, + refs: [], + }; + targetScope.bindings[declaratorId.name] = existing; + } + state.declarationBindingIds.add(declaratorId); + existing.refs.push({ + type: locationType, + start: fromBabelLocation(declaratorId.loc.start, state.sourceId), + end: fromBabelLocation(declaratorId.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(declaration.loc.start, state.sourceId), + end: fromBabelLocation(declaration.loc.end, state.sourceId), + }, + }); + } else if (isNode(declaratorId, "ObjectPattern")) { + declaratorId.properties.forEach(prop => { + parseDeclarator( + prop.value, + targetScope, + type, + locationType, + declaration, + state + ); + }); + } else if (isNode(declaratorId, "ArrayPattern")) { + declaratorId.elements.forEach(item => { + parseDeclarator( + item, + targetScope, + type, + locationType, + declaration, + state + ); + }); + } else if (isNode(declaratorId, "AssignmentPattern")) { + parseDeclarator( + declaratorId.left, + targetScope, + type, + locationType, + declaration, + state + ); + } else if (isNode(declaratorId, "RestElement")) { + parseDeclarator( + declaratorId.argument, + targetScope, + type, + locationType, + declaration, + state + ); + } else if (t.isTSParameterProperty(declaratorId)) { + parseDeclarator( + declaratorId.parameter, + targetScope, + type, + locationType, + declaration, + state + ); + } +} + +function isLetOrConst(node) { + return node.kind === "let" || node.kind === "const"; +} + +function hasLexicalDeclaration(node, parent) { + const nodes = []; + if (t.isSwitchStatement(node)) { + for (const caseNode of node.cases) { + nodes.push(...caseNode.consequent); + } + } else { + nodes.push(...node.body); + } + + const isFunctionBody = t.isFunction(parent, { body: node }); + + return nodes.some( + child => + isLexicalVariable(child) || + t.isClassDeclaration(child) || + (!isFunctionBody && t.isFunctionDeclaration(child)) + ); +} +function isLexicalVariable(node) { + return isNode(node, "VariableDeclaration") && isLetOrConst(node); +} + +function createGlobalScope(ast, sourceId) { + const global = createTempScope("object", "Global", null, { + start: fromBabelLocation(ast.loc.start, sourceId), + end: fromBabelLocation(ast.loc.end, sourceId), + }); + + const lexical = createTempScope("block", "Lexical Global", global, { + start: fromBabelLocation(ast.loc.start, sourceId), + end: fromBabelLocation(ast.loc.end, sourceId), + }); + + return { global, lexical }; +} + +const scopeCollectionVisitor = { + // eslint-disable-next-line complexity + enter(node, ancestors, state) { + state.scopeStack.push(state.scope); + + const parentNode = + ancestors.length === 0 ? null : ancestors[ancestors.length - 1].node; + + if (state.inType) { + return; + } + + if (t.isProgram(node)) { + const scope = pushTempScope(state, "module", "Module", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + scope.bindings.this = { + type: "implicit", + refs: [], + }; + } else if (t.isFunction(node)) { + let { scope } = state; + if (t.isFunctionExpression(node) && isNode(node.id, "Identifier")) { + scope = pushTempScope(state, "block", "Function Expression", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + state.declarationBindingIds.add(node.id); + scope.bindings[node.id.name] = { + type: "const", + refs: [ + { + type: "fn-expr", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ], + }; + } + + if (t.isFunctionDeclaration(node) && isNode(node.id, "Identifier")) { + // This ignores Annex B function declaration hoisting, which + // is probably a fine assumption. + state.declarationBindingIds.add(node.id); + const refs = [ + { + type: "fn-decl", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ]; + + if (scope.type === "block") { + scope.bindings[node.id.name] = { + type: "let", + refs, + }; + } else { + getVarScope(scope).bindings[node.id.name] = { + type: "var", + refs, + }; + } + } + + scope = pushTempScope( + state, + "function", + getFunctionName(node, parentNode), + { + // Being at the start of a function doesn't count as + // being inside of it. + start: fromBabelLocation( + node.params[0] ? node.params[0].loc.start : node.loc.start, + state.sourceId + ), + end: fromBabelLocation(node.loc.end, state.sourceId), + } + ); + + node.params.forEach(param => + parseDeclarator(param, scope, "var", "fn-param", node, state) + ); + + if (!t.isArrowFunctionExpression(node)) { + scope.bindings.this = { + type: "implicit", + refs: [], + }; + scope.bindings.arguments = { + type: "implicit", + refs: [], + }; + } + + if ( + t.isBlockStatement(node.body) && + hasLexicalDeclaration(node.body, node) + ) { + scope = pushTempScope(state, "function-body", "Function Body", { + start: fromBabelLocation(node.body.loc.start, state.sourceId), + end: fromBabelLocation(node.body.loc.end, state.sourceId), + }); + } + } else if (t.isClass(node)) { + if (t.isIdentifier(node.id)) { + // For decorated classes, the AST considers the first the decorator + // to be the start of the class. For the purposes of mapping class + // declarations however, we really want to look for the "class Foo" + // piece. To achieve that, we estimate the location of the declaration + // instead. + let declStart = node.loc.start; + if (node.decorators && node.decorators.length) { + // Estimate the location of the "class" keyword since it + // is unlikely to be a different line than the class name. + declStart = { + line: node.id.loc.start.line, + column: node.id.loc.start.column - "class ".length, + }; + } + + const declaration = { + start: fromBabelLocation(declStart, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }; + + if (t.isClassDeclaration(node)) { + state.declarationBindingIds.add(node.id); + state.scope.bindings[node.id.name] = { + type: "let", + refs: [ + { + type: "class-decl", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration, + }, + ], + }; + } + + const scope = pushTempScope(state, "block", "Class", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + + state.declarationBindingIds.add(node.id); + scope.bindings[node.id.name] = { + type: "const", + refs: [ + { + type: "class-inner", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration, + }, + ], + }; + } + } else if (t.isForXStatement(node) || t.isForStatement(node)) { + const init = node.init || node.left; + if (isNode(init, "VariableDeclaration") && isLetOrConst(init)) { + // Debugger will create new lexical environment for the for. + pushTempScope(state, "block", "For", { + // Being at the start of a for loop doesn't count as + // being inside it. + start: fromBabelLocation(init.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + } + } else if (t.isCatchClause(node)) { + const scope = pushTempScope(state, "block", "Catch", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + parseDeclarator(node.param, scope, "var", "catch", node, state); + } else if ( + t.isBlockStatement(node) && + // Function body's are handled in the function logic above. + !t.isFunction(parentNode) && + hasLexicalDeclaration(node, parentNode) + ) { + // Debugger will create new lexical environment for the block. + pushTempScope(state, "block", "Block", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + } else if ( + t.isVariableDeclaration(node) && + (node.kind === "var" || + // Lexical declarations in for statements are handled above. + !t.isForStatement(parentNode, { init: node }) || + !t.isForXStatement(parentNode, { left: node })) + ) { + // Finds right lexical environment + const hoistAt = !isLetOrConst(node) + ? getVarScope(state.scope) + : state.scope; + node.declarations.forEach(declarator => { + parseDeclarator( + declarator.id, + hoistAt, + node.kind, + node.kind, + node, + state + ); + }); + } else if ( + t.isImportDeclaration(node) && + (!node.importKind || node.importKind === "value") + ) { + node.specifiers.forEach(spec => { + if (spec.importKind && spec.importKind !== "value") { + return; + } + + if (t.isImportNamespaceSpecifier(spec)) { + state.declarationBindingIds.add(spec.local); + + state.scope.bindings[spec.local.name] = { + // Imported namespaces aren't live import bindings, they are + // just normal const bindings. + type: "const", + refs: [ + { + type: "import-ns-decl", + start: fromBabelLocation(spec.local.loc.start, state.sourceId), + end: fromBabelLocation(spec.local.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ], + }; + } else { + state.declarationBindingIds.add(spec.local); + + state.scope.bindings[spec.local.name] = { + type: "import", + refs: [ + { + type: "import-decl", + start: fromBabelLocation(spec.local.loc.start, state.sourceId), + end: fromBabelLocation(spec.local.loc.end, state.sourceId), + importName: t.isImportDefaultSpecifier(spec) + ? "default" + : spec.imported.name, + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ], + }; + } + }); + } else if (t.isTSEnumDeclaration(node)) { + state.declarationBindingIds.add(node.id); + state.scope.bindings[node.id.name] = { + type: "const", + refs: [ + { + type: "ts-enum-decl", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ], + }; + } else if (t.isTSModuleDeclaration(node)) { + state.declarationBindingIds.add(node.id); + state.scope.bindings[node.id.name] = { + type: "const", + refs: [ + { + type: "ts-namespace-decl", + start: fromBabelLocation(node.id.loc.start, state.sourceId), + end: fromBabelLocation(node.id.loc.end, state.sourceId), + declaration: { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }, + }, + ], + }; + } else if (t.isTSModuleBlock(node)) { + pushTempScope(state, "block", "TypeScript Namespace", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + } else if ( + t.isIdentifier(node) && + t.isReferenced(node, parentNode) && + // Babel doesn't cover this in 'isReferenced' yet, but it should + // eventually. + !t.isTSEnumMember(parentNode, { id: node }) && + !t.isTSModuleDeclaration(parentNode, { id: node }) && + // isReferenced above fails to see `var { foo } = ...` as a non-reference + // because the direct parent is not enough to know that the pattern is + // used within a variable declaration. + !state.declarationBindingIds.has(node) + ) { + let freeVariables = state.freeVariables.get(node.name); + if (!freeVariables) { + freeVariables = []; + state.freeVariables.set(node.name, freeVariables); + } + + freeVariables.push({ + type: "ref", + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + meta: buildMetaBindings(state.sourceId, node, ancestors), + }); + } else if (isOpeningJSXIdentifier(node, ancestors)) { + let freeVariables = state.freeVariables.get(node.name); + if (!freeVariables) { + freeVariables = []; + state.freeVariables.set(node.name, freeVariables); + } + + freeVariables.push({ + type: "ref", + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + meta: buildMetaBindings(state.sourceId, node, ancestors), + }); + } else if (t.isThisExpression(node)) { + let freeVariables = state.freeVariables.get("this"); + if (!freeVariables) { + freeVariables = []; + state.freeVariables.set("this", freeVariables); + } + + freeVariables.push({ + type: "ref", + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + meta: buildMetaBindings(state.sourceId, node, ancestors), + }); + } else if (t.isClassProperty(parentNode, { value: node })) { + const scope = pushTempScope(state, "function", "Class Field", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + scope.bindings.this = { + type: "implicit", + refs: [], + }; + scope.bindings.arguments = { + type: "implicit", + refs: [], + }; + } else if ( + t.isSwitchStatement(node) && + hasLexicalDeclaration(node, parentNode) + ) { + pushTempScope(state, "block", "Switch", { + start: fromBabelLocation(node.loc.start, state.sourceId), + end: fromBabelLocation(node.loc.end, state.sourceId), + }); + } + + if ( + // In general Flow expressions are deleted, so they can't contain + // runtime bindings, but typecasts are the one exception there. + (t.isFlow(node) && !t.isTypeCastExpression(node)) || + // In general TS items are deleted, but TS has a few wrapper node + // types that can contain general JS expressions. + (node.type.startsWith("TS") && + !t.isTSTypeAssertion(node) && + !t.isTSAsExpression(node) && + !t.isTSNonNullExpression(node) && + !t.isTSModuleDeclaration(node) && + !t.isTSModuleBlock(node) && + !t.isTSParameterProperty(node) && + !t.isTSExportAssignment(node)) + ) { + // Flag this node as a root "type" node. All items inside of this + // will be skipped entirely. + state.inType = node; + } + }, + exit(node, ancestors, state) { + const currentScope = state.scope; + const parentScope = state.scopeStack.pop(); + if (!parentScope) { + throw new Error("Assertion failure - unsynchronized pop"); + } + state.scope = parentScope; + + // It is possible, as in the case of function expressions, that a single + // node has added multiple scopes, so we need to traverse upward here + // rather than jumping stright to 'parentScope'. + for ( + let scope = currentScope; + scope && scope !== parentScope; + scope = scope.parent + ) { + const { freeVariables } = state; + state.freeVariables = state.freeVariableStack.pop(); + const parentFreeVariables = state.freeVariables; + + // Match up any free variables that match this scope's bindings and + // merge then into the refs. + for (const key of Object.keys(scope.bindings)) { + const binding = scope.bindings[key]; + + const freeVars = freeVariables.get(key); + if (freeVars) { + binding.refs.push(...freeVars); + freeVariables.delete(key); + } + } + + // Move any undeclared references in this scope into the parent for + // processing in higher scopes. + for (const [key, value] of freeVariables) { + let refs = parentFreeVariables.get(key); + if (!refs) { + refs = []; + parentFreeVariables.set(key, refs); + } + + refs.push(...value); + } + } + + if (state.inType === node) { + state.inType = null; + } + }, +}; + +function isOpeningJSXIdentifier(node, ancestors) { + if (!t.isJSXIdentifier(node)) { + return false; + } + + for (let i = ancestors.length - 1; i >= 0; i--) { + const { node: parent, key } = ancestors[i]; + + if (t.isJSXOpeningElement(parent) && key === "name") { + return true; + } else if (!t.isJSXMemberExpression(parent) || key !== "object") { + break; + } + } + + return false; +} + +function buildMetaBindings( + sourceId, + node, + ancestors, + parentIndex = ancestors.length - 1 +) { + if (parentIndex <= 1) { + return null; + } + const parent = ancestors[parentIndex].node; + const grandparent = ancestors[parentIndex - 1].node; + + // Consider "0, foo" to be equivalent to "foo". + if ( + t.isSequenceExpression(parent) && + parent.expressions.length === 2 && + t.isNumericLiteral(parent.expressions[0]) && + parent.expressions[1] === node + ) { + let { start, end } = parent.loc; + + if (t.isCallExpression(grandparent, { callee: parent })) { + // Attempt to expand the range around parentheses, e.g. + // (0, foo.bar)() + start = grandparent.loc.start; + end = Object.assign({}, end); + end.column += 1; + } + + return { + type: "inherit", + start: fromBabelLocation(start, sourceId), + end: fromBabelLocation(end, sourceId), + parent: buildMetaBindings(sourceId, parent, ancestors, parentIndex - 1), + }; + } + + // Consider "Object(foo)", and "__webpack_require__.i(foo)" to be + // equivalent to "foo" since they are essentially identity functions. + if ( + t.isCallExpression(parent) && + (t.isIdentifier(parent.callee, { name: "Object" }) || + (t.isMemberExpression(parent.callee, { computed: false }) && + t.isIdentifier(parent.callee.object, { name: "__webpack_require__" }) && + t.isIdentifier(parent.callee.property, { name: "i" }))) && + parent.arguments.length === 1 && + parent.arguments[0] === node + ) { + return { + type: "inherit", + start: fromBabelLocation(parent.loc.start, sourceId), + end: fromBabelLocation(parent.loc.end, sourceId), + parent: buildMetaBindings(sourceId, parent, ancestors, parentIndex - 1), + }; + } + + if (t.isMemberExpression(parent, { object: node })) { + if (parent.computed) { + if (t.isStringLiteral(parent.property)) { + return { + type: "member", + start: fromBabelLocation(parent.loc.start, sourceId), + end: fromBabelLocation(parent.loc.end, sourceId), + property: parent.property.value, + parent: buildMetaBindings( + sourceId, + parent, + ancestors, + parentIndex - 1 + ), + }; + } + } else { + return { + type: "member", + start: fromBabelLocation(parent.loc.start, sourceId), + end: fromBabelLocation(parent.loc.end, sourceId), + property: parent.property.name, + parent: buildMetaBindings(sourceId, parent, ancestors, parentIndex - 1), + }; + } + } + if ( + t.isCallExpression(parent, { callee: node }) && + !parent.arguments.length + ) { + return { + type: "call", + start: fromBabelLocation(parent.loc.start, sourceId), + end: fromBabelLocation(parent.loc.end, sourceId), + parent: buildMetaBindings(sourceId, parent, ancestors, parentIndex - 1), + }; + } + + return null; +} + +function looksLikeCommonJS(rootScope) { + const hasRefs = name => + rootScope.bindings[name] && !!rootScope.bindings[name].refs.length; + + return ( + hasRefs("__dirname") || + hasRefs("__filename") || + hasRefs("require") || + hasRefs("exports") || + hasRefs("module") + ); +} + +function stripModuleScope(rootScope) { + const rootLexicalScope = rootScope.children[0]; + const moduleScope = rootLexicalScope.children[0]; + if (moduleScope.type !== "module") { + throw new Error("Assertion failure - should be module"); + } + + Object.keys(moduleScope.bindings).forEach(name => { + const binding = moduleScope.bindings[name]; + if (binding.type === "let" || binding.type === "const") { + rootLexicalScope.bindings[name] = binding; + } else { + rootScope.bindings[name] = binding; + } + }); + rootLexicalScope.children = moduleScope.children; + rootLexicalScope.children.forEach(child => { + child.parent = rootLexicalScope; + }); +} diff --git a/devtools/client/debugger/src/workers/parser/getSymbols.js b/devtools/client/debugger/src/workers/parser/getSymbols.js new file mode 100644 index 0000000000..f9ba3be73a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/getSymbols.js @@ -0,0 +1,473 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 * as t from "@babel/types"; + +import createSimplePath from "./utils/simple-path"; +import { traverseAst } from "./utils/ast"; +import { + isFunction, + isObjectShorthand, + isComputedExpression, + getObjectExpressionValue, + getPatternIdentifiers, + getComments, + getSpecifiers, + getCode, + nodeLocationKey, + getFunctionParameterNames, +} from "./utils/helpers"; + +import { inferClassName } from "./utils/inferClassName"; +import getFunctionName from "./utils/getFunctionName"; +import { getFramework } from "./frameworks"; + +let symbolDeclarations = new Map(); + +function extractFunctionSymbol(path, state, symbols) { + const name = getFunctionName(path.node, path.parent); + + if (!state.fnCounts[name]) { + state.fnCounts[name] = 0; + } + const index = state.fnCounts[name]++; + symbols.functions.push({ + name, + klass: inferClassName(path), + location: path.node.loc, + parameterNames: getFunctionParameterNames(path), + identifier: path.node.id, + // indicates the occurence of the function in a file + // e.g { name: foo, ... index: 4 } is the 4th foo function + // in the file + index, + }); +} + +function extractSymbol(path, symbols, state) { + if (isFunction(path)) { + extractFunctionSymbol(path, state, symbols); + } + + if (t.isJSXElement(path)) { + symbols.hasJsx = true; + } + + if (t.isGenericTypeAnnotation(path)) { + symbols.hasTypes = true; + } + + if (t.isClassDeclaration(path)) { + symbols.classes.push(getClassDeclarationSymbol(path.node)); + } + + if (t.isImportDeclaration(path)) { + symbols.imports.push(getImportDeclarationSymbol(path.node)); + } + + if (t.isObjectProperty(path)) { + symbols.objectProperties.push(getObjectPropertySymbol(path)); + } + + if (t.isMemberExpression(path) || t.isOptionalMemberExpression(path)) { + symbols.memberExpressions.push(getMemberExpressionSymbol(path)); + } + + if ( + (t.isStringLiteral(path) || t.isNumericLiteral(path)) && + t.isMemberExpression(path.parentPath) + ) { + // We only need literals that are part of computed memeber expressions + const { start, end } = path.node.loc; + symbols.literals.push({ + name: path.node.value, + location: { start, end }, + expression: getSnippet(path.parentPath), + }); + } + + if (t.isCallExpression(path)) { + symbols.callExpressions.push(getCallExpressionSymbol(path.node)); + } + + symbols.identifiers.push(...getIdentifierSymbols(path)); +} + +function extractSymbols(sourceId) { + const symbols = { + functions: [], + callExpressions: [], + memberExpressions: [], + objectProperties: [], + comments: [], + identifiers: [], + classes: [], + imports: [], + literals: [], + hasJsx: false, + hasTypes: false, + framework: undefined, + }; + + const state = { + fnCounts: Object.create(null), + }; + + const ast = traverseAst(sourceId, { + enter(node, ancestors) { + try { + const path = createSimplePath(ancestors); + if (path) { + extractSymbol(path, symbols, state); + } + } catch (e) { + console.error(e); + } + }, + }); + + // comments are extracted separately from the AST + symbols.comments = getComments(ast); + symbols.identifiers = getUniqueIdentifiers(symbols.identifiers); + symbols.framework = getFramework(symbols); + + return symbols; +} + +function extendSnippet(name, expression, path, prevPath) { + const computed = path?.node.computed; + const optional = path?.node.optional; + const prevComputed = prevPath?.node.computed; + const prevArray = t.isArrayExpression(prevPath); + const array = t.isArrayExpression(path); + const value = path?.node.property?.extra?.raw || ""; + + if (expression === "") { + if (computed) { + return name === undefined ? `[${value}]` : `[${name}]`; + } + return name; + } + + if (computed || array) { + if (prevComputed || prevArray) { + return `[${name}]${expression}`; + } + return `[${name === undefined ? value : name}].${expression}`; + } + + if (prevComputed || prevArray) { + return `${name}${expression}`; + } + + if (isComputedExpression(expression) && name !== undefined) { + return `${name}${expression}`; + } + + if (optional) { + return `${name}?.${expression}`; + } + + return `${name}.${expression}`; +} + +function getMemberSnippet(node, expression = "", optional = false) { + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + const name = t.isPrivateName(node.property) + ? `#${node.property.id.name}` + : node.property.name; + const snippet = getMemberSnippet( + node.object, + extendSnippet(name, expression, { node }), + node.optional + ); + return snippet; + } + + if (t.isCallExpression(node)) { + return ""; + } + + if (t.isThisExpression(node)) { + return `this.${expression}`; + } + + if (t.isIdentifier(node)) { + if (isComputedExpression(expression)) { + return `${node.name}${expression}`; + } + if (optional) { + return `${node.name}?.${expression}`; + } + return `${node.name}.${expression}`; + } + + return expression; +} + +function getObjectSnippet(path, prevPath, expression = "") { + if (!path) { + return expression; + } + + const { name } = path.node.key; + + const extendedExpression = extendSnippet(name, expression, path, prevPath); + + const nextPrevPath = path; + const nextPath = path.parentPath && path.parentPath.parentPath; + + return getSnippet(nextPath, nextPrevPath, extendedExpression); +} + +function getArraySnippet(path, prevPath, expression) { + if (!prevPath.parentPath) { + throw new Error("Assertion failure - path should exist"); + } + + const index = `${prevPath.parentPath.containerIndex}`; + const extendedExpression = extendSnippet(index, expression, path, prevPath); + + const nextPrevPath = path; + const nextPath = path.parentPath && path.parentPath.parentPath; + + return getSnippet(nextPath, nextPrevPath, extendedExpression); +} + +function getSnippet(path, prevPath, expression = "") { + if (!path) { + return expression; + } + + if (t.isVariableDeclaration(path)) { + const node = path.node.declarations[0]; + const { name } = node.id; + return extendSnippet(name, expression, path, prevPath); + } + + if (t.isVariableDeclarator(path)) { + const node = path.node.id; + if (t.isObjectPattern(node)) { + return expression; + } + + const prop = extendSnippet(node.name, expression, path, prevPath); + return prop; + } + + if (t.isAssignmentExpression(path)) { + const node = path.node.left; + const name = t.isMemberExpression(node) + ? getMemberSnippet(node) + : node.name; + + const prop = extendSnippet(name, expression, path, prevPath); + return prop; + } + + if (isFunction(path)) { + return expression; + } + + if (t.isIdentifier(path)) { + return `${path.node.name}.${expression}`; + } + + if (t.isObjectProperty(path)) { + return getObjectSnippet(path, prevPath, expression); + } + + if (t.isObjectExpression(path)) { + const parentPath = prevPath?.parentPath; + return getObjectSnippet(parentPath, prevPath, expression); + } + + if (t.isMemberExpression(path) || t.isOptionalMemberExpression(path)) { + return getMemberSnippet(path.node, expression); + } + + if (t.isArrayExpression(path)) { + if (!prevPath) { + throw new Error("Assertion failure - path should exist"); + } + + return getArraySnippet(path, prevPath, expression); + } + + return ""; +} + +export function clearSymbols() { + symbolDeclarations = new Map(); +} + +export function getSymbols(sourceId) { + if (symbolDeclarations.has(sourceId)) { + const symbols = symbolDeclarations.get(sourceId); + if (symbols) { + return symbols; + } + } + + const symbols = extractSymbols(sourceId); + + symbolDeclarations.set(sourceId, symbols); + return symbols; +} + +function getUniqueIdentifiers(identifiers) { + const newIdentifiers = []; + const locationKeys = new Set(); + for (const newId of identifiers) { + const key = nodeLocationKey(newId); + if (!locationKeys.has(key)) { + locationKeys.add(key); + newIdentifiers.push(newId); + } + } + + return newIdentifiers; +} + +function getMemberExpressionSymbol(path) { + const { start, end } = path.node.property.loc; + return { + name: t.isPrivateName(path.node.property) + ? `#${path.node.property.id.name}` + : path.node.property.name, + location: { start, end }, + expression: getSnippet(path), + computed: path.node.computed, + }; +} + +function getImportDeclarationSymbol(node) { + return { + source: node.source.value, + location: node.loc, + specifiers: getSpecifiers(node.specifiers), + }; +} + +function getObjectPropertySymbol(path) { + const { start, end, identifierName } = path.node.key.loc; + return { + name: identifierName, + location: { start, end }, + expression: getSnippet(path), + }; +} + +function getCallExpressionSymbol(node) { + const { callee, arguments: args } = node; + const values = args.filter(arg => arg.value).map(arg => arg.value); + if (t.isMemberExpression(callee)) { + const { + property: { name, loc }, + } = callee; + return { + name, + values, + location: loc, + }; + } + const { start, end, identifierName } = callee.loc; + return { + name: identifierName, + values, + location: { start, end }, + }; +} + +function getClassParentName(superClass) { + return t.isMemberExpression(superClass) + ? getCode(superClass) + : superClass.name; +} + +function getClassParentSymbol(superClass) { + if (!superClass) { + return null; + } + return { + name: getClassParentName(superClass), + location: superClass.loc, + }; +} + +function getClassDeclarationSymbol(node) { + const { loc, superClass } = node; + return { + name: node.id.name, + parent: getClassParentSymbol(superClass), + location: loc, + }; +} + +/** + * Get a list of identifiers that are part of the given path. + * + * @param {Object} path + * @returns {Array.<Object>} a list of identifiers + */ +function getIdentifierSymbols(path) { + if (t.isStringLiteral(path) && t.isProperty(path.parentPath)) { + const { start, end } = path.node.loc; + return [ + { + name: path.node.value, + expression: getObjectExpressionValue(path.parent), + location: { start, end }, + }, + ]; + } + + const identifiers = []; + if (t.isIdentifier(path) && !t.isGenericTypeAnnotation(path.parent)) { + // We want to include function params, but exclude the function name + if (t.isClassMethod(path.parent) && !path.inList) { + return []; + } + + if (t.isProperty(path.parentPath) && !isObjectShorthand(path.parent)) { + const { start, end } = path.node.loc; + return [ + { + name: path.node.name, + expression: getObjectExpressionValue(path.parent), + location: { start, end }, + }, + ]; + } + + let { start, end } = path.node.loc; + if (path.node.typeAnnotation) { + const { column } = path.node.typeAnnotation.loc.start; + end = { ...end, column }; + } + + identifiers.push({ + name: path.node.name, + expression: path.node.name, + location: { start, end }, + }); + } + + if (t.isThisExpression(path.node)) { + const { start, end } = path.node.loc; + identifiers.push({ + name: "this", + location: { start, end }, + expression: "this", + }); + } + + if (t.isVariableDeclarator(path)) { + const nodeId = path.node.id; + + identifiers.push(...getPatternIdentifiers(nodeId)); + } + + return identifiers; +} diff --git a/devtools/client/debugger/src/workers/parser/index.js b/devtools/client/debugger/src/workers/parser/index.js new file mode 100644 index 0000000000..5f038179d3 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/index.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { WorkerDispatcher } from "devtools/client/shared/worker-utils"; + +const WORKER_URL = "resource://devtools/client/debugger/dist/parser-worker.js"; + +export class ParserDispatcher extends WorkerDispatcher { + constructor(jestUrl) { + super(jestUrl || WORKER_URL); + } + + findOutOfScopeLocations = this.task("findOutOfScopeLocations"); + + getScopes = this.task("getScopes"); + + getSymbols = this.task("getSymbols"); + + async setSource(sourceId, content) { + const astSource = { + id: sourceId, + text: content.type === "wasm" ? "" : content.value, + contentType: content.contentType || null, + isWasm: content.type === "wasm", + }; + + return this.invoke("setSource", astSource); + } + + hasSyntaxError = this.task("hasSyntaxError"); + + mapExpression = this.task("mapExpression"); + + clear = this.task("clearState"); + + /** + * Reports if the location's source can be parsed by this worker. + * + * @param {Object} location + * A debugger frontend location object. See createLocation(). + * @return {Boolean} + * True, if the worker may be able to parse this source. + */ + isLocationSupported(location) { + // There might be more sources that the worker doesn't support, + // like original sources which aren't JavaScript. + // But we can only know with the source's content type, + // which isn't available right away. + // These source will be ignored from within the worker. + return !location.source.isWasm; + } +} diff --git a/devtools/client/debugger/src/workers/parser/mapAwaitExpression.js b/devtools/client/debugger/src/workers/parser/mapAwaitExpression.js new file mode 100644 index 0000000000..d9f99169c0 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/mapAwaitExpression.js @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 generate from "@babel/generator"; +import * as t from "@babel/types"; + +import { hasNode, replaceNode } from "./utils/ast"; +import { isTopLevel } from "./utils/helpers"; + +function hasTopLevelAwait(ast) { + const hasAwait = hasNode( + ast, + (node, ancestors, b) => t.isAwaitExpression(node) && isTopLevel(ancestors) + ); + + return hasAwait; +} + +// translates new bindings `var a = 3` into `a = 3`. +function translateDeclarationIntoAssignment(node) { + return node.declarations.reduce((acc, declaration) => { + // Don't translate declaration without initial assignment (e.g. `var a;`) + if (!declaration.init) { + return acc; + } + acc.push( + t.expressionStatement( + t.assignmentExpression("=", declaration.id, declaration.init) + ) + ); + return acc; + }, []); +} + +/** + * Given an AST, compute its last statement and replace it with a + * return statement. + */ +function addReturnNode(ast) { + const statements = ast.program.body; + const lastStatement = statements.pop(); + + // if the last expression is an awaitExpression, strip the `await` part and directly + // return the argument to avoid calling the argument's `then` function twice when the + // mapped expression gets evaluated (See Bug 1771428) + if (t.isAwaitExpression(lastStatement.expression)) { + lastStatement.expression = lastStatement.expression.argument; + } + statements.push(t.returnStatement(lastStatement.expression)); + return statements; +} + +function getDeclarations(node) { + const { kind, declarations } = node; + const declaratorNodes = declarations.reduce((acc, d) => { + const declarators = getVariableDeclarators(d.id); + return acc.concat(declarators); + }, []); + + // We can't declare const variables outside of the async iife because we + // wouldn't be able to re-assign them. As a workaround, we transform them + // to `let` which should be good enough for those case. + return t.variableDeclaration( + kind === "const" ? "let" : kind, + declaratorNodes + ); +} + +function getVariableDeclarators(node) { + if (t.isIdentifier(node)) { + return t.variableDeclarator(t.identifier(node.name)); + } + + if (t.isObjectProperty(node)) { + return getVariableDeclarators(node.value); + } + if (t.isRestElement(node)) { + return getVariableDeclarators(node.argument); + } + + if (t.isAssignmentPattern(node)) { + return getVariableDeclarators(node.left); + } + + if (t.isArrayPattern(node)) { + return node.elements.reduce( + (acc, element) => acc.concat(getVariableDeclarators(element)), + [] + ); + } + if (t.isObjectPattern(node)) { + return node.properties.reduce( + (acc, property) => acc.concat(getVariableDeclarators(property)), + [] + ); + } + return []; +} + +/** + * Given an AST and an array of variableDeclaration nodes, return a new AST with + * all the declarations at the top of the AST. + */ +function addTopDeclarationNodes(ast, declarationNodes) { + const statements = []; + declarationNodes.forEach(declarationNode => { + statements.push(getDeclarations(declarationNode)); + }); + statements.push(ast); + return t.program(statements); +} + +/** + * Given an AST, return an object of the following shape: + * - newAst: {AST} the AST where variable declarations were transformed into + * variable assignments + * - declarations: {Array<Node>} An array of all the declaration nodes needed + * outside of the async iife. + */ +function translateDeclarationsIntoAssignment(ast) { + const declarations = []; + t.traverse(ast, (node, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + + if ( + t.isWithStatement(node) || + !isTopLevel(ancestors) || + t.isAssignmentExpression(node) || + !t.isVariableDeclaration(node) || + t.isForStatement(parent.node) || + t.isForXStatement(parent.node) || + !Array.isArray(node.declarations) || + node.declarations.length === 0 + ) { + return; + } + + const newNodes = translateDeclarationIntoAssignment(node); + replaceNode(ancestors, newNodes); + declarations.push(node); + }); + + return { + newAst: ast, + declarations, + }; +} + +/** + * Given an AST, wrap its body in an async iife, transform variable declarations + * in assignments and move the variable declarations outside of the async iife. + * Example: With the AST for the following expression: `let a = await 123`, the + * function will return: + * let a; + * (async => { + * return a = await 123; + * })(); + */ +function wrapExpressionFromAst(ast) { + // Transform let and var declarations into assignments, and get back an array + // of variable declarations. + let { newAst, declarations } = translateDeclarationsIntoAssignment(ast); + const body = addReturnNode(newAst); + + // Create the async iife. + newAst = t.expressionStatement( + t.callExpression( + t.arrowFunctionExpression([], t.blockStatement(body), true), + [] + ) + ); + + // Now let's put all the variable declarations at the top of the async iife. + newAst = addTopDeclarationNodes(newAst, declarations); + + return generate(newAst).code; +} + +export default function mapTopLevelAwait(expression, ast) { + if (!ast) { + // If there's no ast this means the expression is malformed. And if the + // expression contains the await keyword, we still want to wrap it in an + // async iife in order to get a meaningful message (without this, the + // engine will throw an Error stating that await keywords are only valid + // in async functions and generators). + if (expression.includes("await ")) { + return `(async () => { ${expression} })();`; + } + + return expression; + } + + if (!hasTopLevelAwait(ast)) { + return expression; + } + + return wrapExpressionFromAst(ast); +} diff --git a/devtools/client/debugger/src/workers/parser/mapBindings.js b/devtools/client/debugger/src/workers/parser/mapBindings.js new file mode 100644 index 0000000000..f8260787f1 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/mapBindings.js @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { replaceNode } from "./utils/ast"; +import { isTopLevel } from "./utils/helpers"; + +import generate from "@babel/generator"; +import * as t from "@babel/types"; + +function getAssignmentTarget(node, bindings) { + if (t.isObjectPattern(node)) { + for (const property of node.properties) { + if (t.isRestElement(property)) { + property.argument = getAssignmentTarget(property.argument, bindings); + } else { + property.value = getAssignmentTarget(property.value, bindings); + } + } + + return node; + } + + if (t.isArrayPattern(node)) { + for (const [i, element] of node.elements.entries()) { + node.elements[i] = getAssignmentTarget(element, bindings); + } + + return node; + } + + if (t.isAssignmentPattern(node)) { + node.left = getAssignmentTarget(node.left, bindings); + + return node; + } + + if (t.isRestElement(node)) { + node.argument = getAssignmentTarget(node.argument, bindings); + + return node; + } + + if (t.isIdentifier(node)) { + return bindings.includes(node.name) + ? node + : t.memberExpression(t.identifier("self"), node); + } + + return node; +} + +// translates new bindings `var a = 3` into `self.a = 3` +// and existing bindings `var a = 3` into `a = 3` for re-assignments +function globalizeDeclaration(node, bindings) { + return node.declarations.map(declaration => + t.expressionStatement( + t.assignmentExpression( + "=", + getAssignmentTarget(declaration.id, bindings), + declaration.init || t.unaryExpression("void", t.numericLiteral(0)) + ) + ) + ); +} + +// translates new bindings `a = 3` into `self.a = 3` +// and keeps assignments the same for existing bindings. +function globalizeAssignment(node, bindings) { + return t.assignmentExpression( + node.operator, + getAssignmentTarget(node.left, bindings), + node.right + ); +} + +export default function mapExpressionBindings(expression, ast, bindings = []) { + let isMapped = false; + let shouldUpdate = true; + + t.traverse(ast, (node, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + + if (t.isWithStatement(node)) { + shouldUpdate = false; + return; + } + + if (!isTopLevel(ancestors)) { + return; + } + + if (t.isAssignmentExpression(node)) { + if (t.isIdentifier(node.left) || t.isPattern(node.left)) { + const newNode = globalizeAssignment(node, bindings); + isMapped = true; + replaceNode(ancestors, newNode); + return; + } + + return; + } + + if (!t.isVariableDeclaration(node)) { + return; + } + + if (!t.isForStatement(parent.node)) { + const newNodes = globalizeDeclaration(node, bindings); + isMapped = true; + replaceNode(ancestors, newNodes); + } + }); + + if (!shouldUpdate || !isMapped) { + return expression; + } + + return generate(ast).code; +} diff --git a/devtools/client/debugger/src/workers/parser/mapExpression.js b/devtools/client/debugger/src/workers/parser/mapExpression.js new file mode 100644 index 0000000000..71f3b016fc --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/mapExpression.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { parseConsoleScript } from "./utils/ast"; +import mapOriginalExpression from "./mapOriginalExpression"; +import mapExpressionBindings from "./mapBindings"; +import mapTopLevelAwait from "./mapAwaitExpression"; + +export default function mapExpression( + expression, + mappings, + bindings, + shouldMapBindings = true, + shouldMapAwait = true +) { + const mapped = { + await: false, + bindings: false, + originalExpression: false, + }; + + const ast = parseConsoleScript(expression); + try { + if (mappings && ast) { + const beforeOriginalExpression = expression; + expression = mapOriginalExpression(expression, ast, mappings); + mapped.originalExpression = beforeOriginalExpression !== expression; + } + + if (shouldMapBindings && ast) { + const beforeBindings = expression; + expression = mapExpressionBindings(expression, ast, bindings); + mapped.bindings = beforeBindings !== expression; + } + + if (shouldMapAwait) { + const beforeAwait = expression; + expression = mapTopLevelAwait(expression, ast); + mapped.await = beforeAwait !== expression; + } + } catch (e) { + console.warn(`Error when mapping ${expression} expression:`, e); + } + + return { + expression, + mapped, + }; +} diff --git a/devtools/client/debugger/src/workers/parser/mapOriginalExpression.js b/devtools/client/debugger/src/workers/parser/mapOriginalExpression.js new file mode 100644 index 0000000000..1724db9838 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/mapOriginalExpression.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { parseScript } from "./utils/ast"; +import { buildScopeList } from "./getScopes"; +import generate from "@babel/generator"; +import * as t from "@babel/types"; + +// NOTE: this will only work if we are replacing an original identifier +function replaceNode(ancestors, node) { + const ancestor = ancestors[ancestors.length - 1]; + + if (typeof ancestor.index === "number") { + ancestor.node[ancestor.key][ancestor.index] = node; + } else { + ancestor.node[ancestor.key] = node; + } +} + +function getFirstExpression(ast) { + const statements = ast.program.body; + if (!statements.length) { + return null; + } + + return statements[0].expression; +} + +function locationKey(start) { + return `${start.line}:${start.column}`; +} + +export default function mapOriginalExpression(expression, ast, mappings) { + const scopes = buildScopeList(ast, ""); + let shouldUpdate = false; + + const nodes = new Map(); + const replacements = new Map(); + + // The ref-only global bindings are the ones that are accessed, but not + // declared anywhere in the parsed code, meaning they are either global, + // or declared somewhere in a scope outside the parsed code, so we + // rewrite all of those specifically to avoid rewritting declarations that + // shadow outer mappings. + for (const name of Object.keys(scopes[0].bindings)) { + const { refs } = scopes[0].bindings[name]; + const mapping = mappings[name]; + + if ( + !refs.every(ref => ref.type === "ref") || + !mapping || + mapping === name + ) { + continue; + } + + let node = nodes.get(name); + if (!node) { + node = getFirstExpression(parseScript(mapping)); + nodes.set(name, node); + } + + for (const ref of refs) { + let { line, column } = ref.start; + + // This shouldn't happen, just keeping Flow happy. + if (typeof column !== "number") { + column = 0; + } + + replacements.set(locationKey({ line, column }), node); + } + } + + if (replacements.size === 0) { + // Avoid the extra code generation work and also avoid potentially + // reformatting the user's code unnecessarily. + return expression; + } + + t.traverse(ast, (node, ancestors) => { + if (!t.isIdentifier(node) && !t.isThisExpression(node)) { + return; + } + + const ancestor = ancestors[ancestors.length - 1]; + // Shorthand properties can have a key and value with `node.loc.start` value + // and we only want to replace the value. + if (t.isObjectProperty(ancestor.node) && ancestor.key !== "value") { + return; + } + + const replacement = replacements.get(locationKey(node.loc.start)); + if (replacement) { + replaceNode(ancestors, t.cloneNode(replacement)); + shouldUpdate = true; + } + }); + + if (shouldUpdate) { + return generate(ast).code; + } + + return expression; +} diff --git a/devtools/client/debugger/src/workers/parser/moz.build b/devtools/client/debugger/src/workers/parser/moz.build new file mode 100644 index 0000000000..b7223ac81a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/moz.build @@ -0,0 +1,10 @@ +# 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( + "index.js", +) diff --git a/devtools/client/debugger/src/workers/parser/sources.js b/devtools/client/debugger/src/workers/parser/sources.js new file mode 100644 index 0000000000..59293c9e0a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/sources.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/>. */ + +const cachedSources = new Map(); + +export function setSource(source) { + cachedSources.set(source.id, source); +} + +export function getSource(sourceId) { + const source = cachedSources.get(sourceId); + if (!source) { + throw new Error(`Parser: source ${sourceId} was not provided.`); + } + + return source; +} + +export function clearSources() { + cachedSources.clear(); +} diff --git a/devtools/client/debugger/src/workers/parser/tests/__snapshots__/findOutOfScopeLocations.spec.js.snap b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/findOutOfScopeLocations.spec.js.snap new file mode 100644 index 0000000000..4689f0c824 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/findOutOfScopeLocations.spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Parser.findOutOfScopeLocations should exclude function for locations on declaration 1`] = ` +"(1, 0) -> (1, 16) +(3, 14) -> (27, 1) +(29, 16) -> (33, 1) +(35, 20) -> (37, 1) +(39, 26) -> (41, 1) +(43, 20) -> (45, 1) +(47, 31) -> (49, 1) +(51, 19) -> (62, 1)" +`; + +exports[`Parser.findOutOfScopeLocations should exclude non-enclosing function blocks 1`] = ` +"(1, 0) -> (1, 16) +(8, 16) -> (10, 3) +(12, 22) -> (14, 3) +(16, 16) -> (18, 3) +(20, 27) -> (22, 3) +(24, 9) -> (26, 3) +(29, 16) -> (33, 1) +(35, 20) -> (37, 1) +(39, 26) -> (41, 1) +(43, 20) -> (45, 1) +(47, 31) -> (49, 1) +(51, 19) -> (62, 1)" +`; + +exports[`Parser.findOutOfScopeLocations should not exclude in-scope inner locations 1`] = ` +"(1, 0) -> (1, 16) +(3, 14) -> (27, 1) +(29, 16) -> (33, 1) +(35, 20) -> (37, 1) +(39, 26) -> (41, 1) +(43, 20) -> (45, 1) +(47, 31) -> (49, 1)" +`; + +exports[`Parser.findOutOfScopeLocations should roll up function blocks 1`] = ` +"(1, 0) -> (1, 16) +(29, 16) -> (33, 1) +(35, 20) -> (37, 1) +(39, 26) -> (41, 1) +(43, 20) -> (45, 1) +(47, 31) -> (49, 1) +(51, 19) -> (62, 1)" +`; diff --git a/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getScopes.spec.js.snap b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getScopes.spec.js.snap new file mode 100644 index 0000000000..f1ea6c9d15 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getScopes.spec.js.snap @@ -0,0 +1,18767 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Parser.getScopes finds scope bindings and exclude Flowtype: getScopes finds scope bindings and exclude Flowtype at line 8 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 22, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings and exclude Flowtype: getScopes finds scope bindings and exclude Flowtype at line 10 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 9, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 22, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 7, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/flowtype-bindings/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for arrow functions: getScopes finds scope bindings for arrow functions at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for arrow functions: getScopes finds scope bindings for arrow functions at line 4 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "p1": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + }, + "displayName": "outer", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for arrow functions: getScopes finds scope bindings for arrow functions at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 22, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "anonymous", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 3, + "line": 6, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "p1": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + }, + "displayName": "outer", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for arrow functions: getScopes finds scope bindings for arrow functions at line 8 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "p2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 16, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 17, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + }, + "displayName": "inner", + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 17, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 22, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "anonymous", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 3, + "line": 6, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "p1": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + }, + "displayName": "outer", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/arrow-function/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for block statements: getScopes finds scope bindings for block statements at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "first": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seventh": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for block statements: getScopes finds scope bindings for block statements at line 6 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Fourth": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 8, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 8, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "fifth": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 9, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 9, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "sixth": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 10, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 10, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "third": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 7, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 16, + "line": 7, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "first": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seventh": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/block-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class declarations: getScopes finds scope bindings for class declarations at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "Second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 15, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "decorator": Object { + "refs": Array [ + Object { + "end": Object { + "column": 10, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class declarations: getScopes finds scope bindings for class declarations at line 5 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "Second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 15, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "decorator": Object { + "refs": Array [ + Object { + "end": Object { + "column": 10, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class declarations: getScopes finds scope bindings for class declarations at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 24, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 20, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "m", + "end": Object { + "column": 7, + "line": 8, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "Second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 15, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 14, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "decorator": Object { + "refs": Array [ + Object { + "end": Object { + "column": 10, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class expressions: getScopes finds scope bindings for class expressions at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class expressions: getScopes finds scope bindings for class expressions at line 5 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 23, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class expressions: getScopes finds scope bindings for class expressions at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 24, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 20, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "m", + "end": Object { + "column": 7, + "line": 8, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 23, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 19, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class properties: getScopes finds scope bindings for class properties at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class properties: getScopes finds scope bindings for class properties at line 4 column 16 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 13, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 20, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "parent": null, + "start": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "call", + }, + "property": "init", + "start": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "Class Field", + "end": Object { + "column": 20, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class properties: getScopes finds scope bindings for class properties at line 6 column 12 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Class Field", + "end": Object { + "column": 3, + "line": 9, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 10, + "line": 6, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for class properties: getScopes finds scope bindings for class properties at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 8, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 8, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 8, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 8, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 3, + "line": 9, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Class Field", + "end": Object { + "column": 3, + "line": 9, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 10, + "line": 6, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 10, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/class-property/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/class-property/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for complex binding nesting: getScopes finds scope bindings for complex binding nesting at line 16 column 4 1`] = ` +Array [ + Object { + "bindings": Object { + "_arguments": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 31, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 35, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "_this": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 31, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 23, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "arg": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [ + Object { + "end": Object { + "column": 30, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 21, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 31, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 22, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "arrow": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 9, + "line": 22, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 20, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 20, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 32, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 30, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 34, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 32, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 4, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 9, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "call", + "start": Object { + "column": 2, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 26, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "__webpack_require__": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 51, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 32, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "exports": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 30, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 23, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + Object { + "end": Object { + "column": 29, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 22, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "default", + "start": Object { + "column": 0, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 24, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 35, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "default", + "start": Object { + "column": 17, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 17, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "module": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + Object { + "end": Object { + "column": 6, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 14, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "exports", + "start": Object { + "column": 0, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 26, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 22, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 38, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 34, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 40, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 36, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "named", + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 30, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Object": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 21, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "defineProperty", + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 30, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for complex binding nesting: getScopes finds scope bindings for complex binding nesting at line 20 column 6 1`] = ` +Array [ + Object { + "bindings": Object { + "argArrow": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 16, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 39, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 31, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "arrow", + "end": Object { + "column": 5, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 31, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arrow": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 5, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 16, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 30, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 25, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-expr", + }, + ], + "type": "const", + }, + }, + "displayName": "Function Expression", + "end": Object { + "column": 5, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 16, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "_arguments": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 31, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 35, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "_this": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 31, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 23, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "arg": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [ + Object { + "end": Object { + "column": 30, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 21, + "line": 13, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 31, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 22, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "arrow": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 6, + "line": 21, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 18, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 9, + "line": 22, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 20, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 12, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 20, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 23, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 32, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 30, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 34, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 32, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 4, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 9, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "call", + "start": Object { + "column": 2, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 25, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 26, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "__webpack_require__": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 51, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 32, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "exports": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 30, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 23, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + Object { + "end": Object { + "column": 29, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 22, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "default", + "start": Object { + "column": 0, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 24, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 35, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "default", + "start": Object { + "column": 17, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 17, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "module": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-param", + }, + Object { + "end": Object { + "column": 6, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 14, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "exports", + "start": Object { + "column": 0, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 27, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 26, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 10, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 22, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 18, + "line": 9, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 38, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 34, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 40, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 36, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "named", + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 30, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Object": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 21, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "defineProperty", + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 16, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 19, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 13, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 17, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 6, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 6, + "line": 20, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 29, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 30, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/complex-nesting/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for declarations with patterns: getScopes finds scope bindings for declarations with patterns at line 1 column 0 1`] = ` +Array [ + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 23, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 17, + "line": 2, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 2, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 2, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 2, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/pattern-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 3 column 17 1`] = ` +Array [ + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 4 column 17 1`] = ` +Array [ + Object { + "bindings": Object { + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "For", + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 4, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 5 column 25 1`] = ` +Array [ + Object { + "bindings": Object { + "three": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 20, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 16, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + }, + "displayName": "For", + "end": Object { + "column": 26, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 5, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 7 column 22 1`] = ` +Array [ + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 8 column 22 1`] = ` +Array [ + Object { + "bindings": Object { + "five": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "For", + "end": Object { + "column": 23, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 8, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 9 column 23 1`] = ` +Array [ + Object { + "bindings": Object { + "six": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + }, + "displayName": "For", + "end": Object { + "column": 24, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 9, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 11 column 23 1`] = ` +Array [ + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 12 column 23 1`] = ` +Array [ + Object { + "bindings": Object { + "eight": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "For", + "end": Object { + "column": 24, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 12, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for for loops: getScopes finds scope bindings for for loops at line 13 column 24 1`] = ` +Array [ + Object { + "bindings": Object { + "nine": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 15, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + }, + "displayName": "For", + "end": Object { + "column": 25, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 5, + "line": 13, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "four": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "seven": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 11, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/for-loops/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function declarations: getScopes finds scope bindings for function declarations at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function declarations: getScopes finds scope bindings for function declarations at line 3 column 20 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "p1": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "outer", + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function declarations: getScopes finds scope bindings for function declarations at line 5 column 1 1`] = ` +Array [ + Object { + "bindings": Object { + "middle": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function declarations: getScopes finds scope bindings for function declarations at line 9 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 7, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 7, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 7, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "p2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 20, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 18, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 20, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "middle", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 18, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "middle": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "outer": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-declaration/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function expressions: getScopes finds scope bindings for function expressions at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function expressions: getScopes finds scope bindings for function expressions at line 3 column 23 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "p1": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 20, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for function expressions: getScopes finds scope bindings for function expressions at line 6 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "p2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 30, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 28, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 18, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "withName", + "end": Object { + "column": 1, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 28, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "withName": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 27, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 19, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "fn-expr", + }, + ], + "type": "const", + }, + }, + "displayName": "Function Expression", + "end": Object { + "column": 1, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 13, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/function-expression/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for out of order declarations: getScopes finds scope bindings for out of order declarations at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "aDefault": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 33, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 39, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 35, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 8, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for out of order declarations: getScopes finds scope bindings for out of order declarations at line 5 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "callback": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 19, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 33, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 10, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 12, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "aDefault": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 33, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 39, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 35, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 8, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for out of order declarations: getScopes finds scope bindings for out of order declarations at line 11 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 23, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 21, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 45, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 41, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 19, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + }, + "displayName": "callback", + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "callback": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 19, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 33, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 10, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 12, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "aDefault": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 33, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 39, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 35, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 8, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for out of order declarations: getScopes finds scope bindings for out of order declarations at line 14 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 15, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 23, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 21, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 45, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 41, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + Object { + "end": Object { + "column": 19, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 16, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + }, + "displayName": "callback", + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "callback": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 19, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 33, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 10, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 12, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "aDefault": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 33, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 39, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 35, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 8, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for out of order declarations: getScopes finds scope bindings for out of order declarations at line 17 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "callback": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 16, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 19, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 10, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 33, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 25, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 10, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 12, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 6, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "root", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "aDefault": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 33, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 21, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "root": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 24, + "line": 3, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "fn-decl", + }, + Object { + "end": Object { + "column": 39, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 35, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "val": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 8, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 15, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/out-of-order-declarations/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 5 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 9 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 11 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "three": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 14, + "line": 11, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 11, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 11, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 3, + "line": 12, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 16, + "line": 10, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 7, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 9, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 13, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 17 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 20, + "line": 17, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 17, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 16, + "line": 17, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 13, + "line": 17, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 18, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for switch statements: getScopes finds scope bindings for switch statements at line 21 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "three": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 21, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 21, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 21, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 21, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Switch", + "end": Object { + "column": 1, + "line": 22, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "zero": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 5, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 3, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 8, + "line": 19, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 23, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/switch-statement/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for try..catch: getScopes finds scope bindings for try..catch at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "first": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "third": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for try..catch: getScopes finds scope bindings for try..catch at line 4 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "second": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 5, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 5, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 5, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 4, + "line": 3, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "first": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "third": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings for try..catch: getScopes finds scope bindings for try..catch at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "fourth": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 8, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 8, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 9, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "err": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 9, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "catch", + }, + ], + "type": "var", + }, + }, + "displayName": "Catch", + "end": Object { + "column": 1, + "line": 9, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 6, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "first": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 4, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "third": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/try-catch/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a JSX element: getScopes finds scope bindings in a JSX element at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "SomeComponent": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 29, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + }, + "end": Object { + "column": 20, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "import-decl", + }, + Object { + "end": Object { + "column": 14, + "line": 3, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 3, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 4, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 14, + "line": 5, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 14, + "line": 6, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 6, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "import", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/jsx-component/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a module: getScopes finds scope bindings in a module at line 7 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 9, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 9, + "sourceId": "scopes/simple-module/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 9, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 9, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "import-decl", + }, + Object { + "end": Object { + "column": 15, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 12, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "import", + }, + "one": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 5, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/simple-module/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 5, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 4, + "line": 11, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 0, + "line": 11, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "three": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 7, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/simple-module/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 7, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "two": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 12, + "line": 6, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/simple-module/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 6, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 6, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "console": Object { + "refs": Array [ + Object { + "end": Object { + "column": 7, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 11, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "parent": null, + "property": "log", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 12, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/simple-module/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript file: getScopes finds scope bindings in a typescript file at line 9 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 21, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 15, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript file: getScopes finds scope bindings in a typescript file at line 13 column 4 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 14, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 12, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 24, + "line": 12, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 21, + "line": 12, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "constructor", + "end": Object { + "column": 3, + "line": 14, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 12, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 21, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 15, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript file: getScopes finds scope bindings in a typescript file at line 17 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 18, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 16, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 21, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 15, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript file: getScopes finds scope bindings in a typescript file at line 33 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 3, + "line": 34, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 32, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 34, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 32, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 32, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 32, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "TypeScript Namespace", + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 19, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 21, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 15, + "line": 22, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/ts-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript-jsx file: getScopes finds scope bindings in a typescript-jsx file at line 9 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "any": Object { + "refs": Array [ + Object { + "end": Object { + "column": 14, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 11, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 28, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript-jsx file: getScopes finds scope bindings in a typescript-jsx file at line 13 column 4 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 14, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 12, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 24, + "line": 12, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 21, + "line": 12, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "fn-param", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "constructor", + "end": Object { + "column": 3, + "line": 14, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 12, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "any": Object { + "refs": Array [ + Object { + "end": Object { + "column": 14, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 11, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 28, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript-jsx file: getScopes finds scope bindings in a typescript-jsx file at line 17 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 18, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 16, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "any": Object { + "refs": Array [ + Object { + "end": Object { + "column": 14, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 11, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 28, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a typescript-jsx file: getScopes finds scope bindings in a typescript-jsx file at line 33 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 3, + "line": 34, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 32, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 3, + "line": 34, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 32, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 32, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 32, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "TypeScript Namespace", + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 19, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Color": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 8, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 5, + "line": 3, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-enum-decl", + }, + ], + "type": "const", + }, + "Example": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 19, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 10, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "class-decl", + }, + Object { + "end": Object { + "column": 16, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 9, + "line": 41, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "let", + }, + "TheSpace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 35, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 31, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ts-namespace-decl", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Error": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 14, + "line": 17, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "any": Object { + "refs": Array [ + Object { + "end": Object { + "column": 14, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 11, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 28, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + }, + "end": Object { + "column": 7, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 22, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + "window": Object { + "refs": Array [ + Object { + "end": Object { + "column": 7, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 25, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 1, + "line": 28, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 42, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/tsx-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in a vue file: getScopes finds scope bindings in a vue file at line 14 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "fnVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 13, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 13, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "data", + "end": Object { + "column": 3, + "line": 18, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 12, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "moduleVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 23, + "line": 8, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 8, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 8, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 8, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 1, + "line": 27, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 1, + "line": 27, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 1, + "line": 27, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/vue-sample/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in fn body with both lex and non-lex items: getScopes finds scope bindings in fn body with both lex and non-lex items at line 4 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "lex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 3, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 3, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 3, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 14, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "nonlex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 2, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 2, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 2, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 2, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn", + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn3": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn4": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in fn body with both lex and non-lex items: getScopes finds scope bindings in fn body with both lex and non-lex items at line 10 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "lex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 10, + "line": 9, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 9, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 9, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 9, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "nonlex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 8, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 8, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 8, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn2", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn3": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn4": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in fn body with both lex and non-lex items: getScopes finds scope bindings in fn body with both lex and non-lex items at line 16 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Thing": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 15, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 15, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 15, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 15, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "nonlex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 13, + "line": 14, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 14, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 14, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 14, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn3", + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn3": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn4": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings in fn body with both lex and non-lex items: getScopes finds scope bindings in fn body with both lex and non-lex items at line 22 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "Thing": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 21, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 21, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 13, + "line": 21, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 8, + "line": 21, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Function Body", + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 15, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "nonlex": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 21, + "line": 20, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 20, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 17, + "line": 20, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 20, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "fn4", + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 5, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn2": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn3": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "fn4": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 23, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 19, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 24, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/fn-body-lex-and-nonlex/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings with expression metadata: getScopes finds scope bindings with expression metadata at line 2 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "foo": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 15, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "const", + }, + Object { + "end": Object { + "column": 3, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 7, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 9, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 13, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": null, + "property": "baz", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "call", + }, + "property": "bar", + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 7, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 11, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 14, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 18, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": null, + "property": "baz", + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "inherit", + }, + "property": "bar", + "start": Object { + "column": 4, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 4, + "line": 4, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 10, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 14, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 15, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 17, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 21, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": null, + "property": "baz", + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "inherit", + }, + "property": "bar", + "start": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 7, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + Object { + "end": Object { + "column": 25, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 29, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 30, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 32, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": Object { + "end": Object { + "column": 36, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": null, + "property": "baz", + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "call", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "inherit", + }, + "property": "bar", + "start": Object { + "column": 22, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 22, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "const", + }, + }, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/expressions/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "Object": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 0, + "line": 5, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "__webpack_require__": Object { + "refs": Array [ + Object { + "end": Object { + "column": 19, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "meta": Object { + "end": Object { + "column": 21, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "parent": null, + "property": "i", + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "member", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "global", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/expressions/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/expressions/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings with proper types: getScopes finds scope bindings with proper types at line 5 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "aLet": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "aVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "cls": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "def": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "named", + "start": Object { + "column": 9, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "namespace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 30, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-ns-decl", + }, + ], + "type": "const", + }, + "otherNamed": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "thing", + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings with proper types: getScopes finds scope bindings with proper types at line 9 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [], + "type": "implicit", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "method", + "end": Object { + "column": 3, + "line": 10, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 2, + "line": 8, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "cls": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "class-inner", + }, + ], + "type": "const", + }, + }, + "displayName": "Class", + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "aLet": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "aVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "cls": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "def": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "named", + "start": Object { + "column": 9, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "namespace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 30, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-ns-decl", + }, + ], + "type": "const", + }, + "otherNamed": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "thing", + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings with proper types: getScopes finds scope bindings with proper types at line 18 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "arguments": Object { + "refs": Array [ + Object { + "end": Object { + "column": 11, + "line": 19, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 19, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + "this": Object { + "refs": Array [ + Object { + "end": Object { + "column": 6, + "line": 18, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "meta": null, + "start": Object { + "column": 2, + "line": 18, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "ref", + }, + ], + "type": "implicit", + }, + }, + "displayName": "inner", + "end": Object { + "column": 1, + "line": 20, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "function", + }, + Object { + "bindings": Object { + "inner": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 20, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 15, + "line": 17, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 10, + "line": 17, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-expr", + }, + ], + "type": "const", + }, + }, + "displayName": "Function Expression", + "end": Object { + "column": 1, + "line": 20, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 1, + "line": 17, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "aLet": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "aVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "cls": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "def": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "named", + "start": Object { + "column": 9, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "namespace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 30, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-ns-decl", + }, + ], + "type": "const", + }, + "otherNamed": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "thing", + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "object", + }, +] +`; + +exports[`Parser.getScopes finds scope bindings with proper types: getScopes finds scope bindings with proper types at line 23 column 0 1`] = ` +Array [ + Object { + "bindings": Object { + "blockFn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 22, + "line": 23, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 2, + "line": 23, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 18, + "line": 23, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 11, + "line": 23, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "let", + }, + }, + "displayName": "Block", + "end": Object { + "column": 1, + "line": 24, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 22, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object { + "aConst": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 18, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 12, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 15, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "const", + }, + ], + "type": "const", + }, + "aLet": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 14, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "let", + }, + ], + "type": "let", + }, + "aVar": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 9, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 8, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 4, + "line": 13, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "var", + }, + ], + "type": "var", + }, + "cls": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 1, + "line": 11, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 9, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 6, + "line": 7, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "class-decl", + }, + ], + "type": "let", + }, + "def": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 19, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 10, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "default", + "start": Object { + "column": 7, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "fn": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 16, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 11, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 9, + "line": 6, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "fn-decl", + }, + ], + "type": "var", + }, + "named": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 25, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 14, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "named", + "start": Object { + "column": 9, + "line": 2, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "namespace": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 30, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 21, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 12, + "line": 4, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-ns-decl", + }, + ], + "type": "const", + }, + "otherNamed": Object { + "refs": Array [ + Object { + "declaration": Object { + "end": Object { + "column": 39, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "start": Object { + "column": 0, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + }, + "end": Object { + "column": 28, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "importName": "thing", + "start": Object { + "column": 18, + "line": 3, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "import-decl", + }, + ], + "type": "import", + }, + "this": Object { + "refs": Array [], + "type": "implicit", + }, + }, + "displayName": "Module", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Lexical Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "block", + }, + Object { + "bindings": Object {}, + "displayName": "Global", + "end": Object { + "column": 0, + "line": 25, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "scopeKind": "", + "start": Object { + "column": 0, + "line": 1, + "sourceId": "scopes/binding-types/originalSource-1", + }, + "type": "object", + }, +] +`; diff --git a/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getSymbols.spec.js.snap b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getSymbols.spec.js.snap new file mode 100644 index 0000000000..aa1a79686b --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/getSymbols.spec.js.snap @@ -0,0 +1,1522 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Parser.getSymbols allSymbols 1`] = ` +"functions: +[(4, 0), (6, 1)] incrementCounter(counter) +[(8, 12), (8, 27)] sum(a, b) +[(12, 2), (14, 3)] doThing() +[(15, 16), (17, 3)] doOtherThing() +[(20, 15), (20, 23)] property() +[(24, 2), (26, 3)] constructor() Ultra +[(28, 2), (30, 3)] beAwesome(person) Ultra + +callExpressions: +[(13, 12), (13, 15)] log hey +[(29, 12), (29, 15)] log +[(33, 19), (33, 23)] push + +memberExpressions: +[(13, 12), (13, 15)] console.log log +[(20, 4), (20, 12)] Obj.property property +[(21, 4), (21, 17)] Obj.otherProperty otherProperty +[(25, 9), (25, 16)] this.awesome awesome +[(29, 12), (29, 15)] console.log log +[(33, 19), (33, 23)] this.props.history.push push +[(33, 11), (33, 18)] this.props.history history +[(33, 5), (33, 10)] this.props props +[(33, 48), (33, 50)] this.props.dac.id id +[(33, 44), (33, 47)] this.props.dac dac +[(33, 38), (33, 43)] this.props props + +objectProperties: +[(11, 2), (11, 5)] Obj.foo foo +[(15, 2), (15, 14)] Obj.doOtherThing doOtherThing + +comments: + + +identifiers: +[(1, 6), (1, 10)] TIME TIME +[(2, 4), (2, 9)] count count +[(4, 9), (4, 25)] incrementCounter incrementCounter +[(4, 26), (4, 33)] counter counter +[(5, 9), (5, 16)] counter counter +[(8, 6), (8, 9)] sum sum +[(8, 13), (8, 14)] a a +[(8, 16), (8, 17)] b b +[(8, 22), (8, 23)] a a +[(8, 26), (8, 27)] b b +[(10, 6), (10, 9)] Obj Obj +[(11, 2), (11, 5)] 1 foo +[(12, 2), (12, 9)] doThing doThing +[(13, 4), (13, 11)] console console +[(13, 12), (13, 15)] log log +[(15, 2), (15, 14)] doOtherThing +[(20, 0), (20, 3)] Obj Obj +[(20, 4), (20, 12)] property property +[(21, 0), (21, 3)] Obj Obj +[(21, 4), (21, 17)] otherProperty otherProperty +[(23, 6), (23, 11)] Ultra Ultra +[(25, 4), (25, 8)] this this +[(25, 9), (25, 16)] awesome awesome +[(28, 12), (28, 18)] person person +[(29, 4), (29, 11)] console console +[(29, 12), (29, 15)] log log +[(29, 19), (29, 25)] person person +[(33, 0), (33, 4)] this this +[(33, 5), (33, 10)] props props +[(33, 11), (33, 18)] history history +[(33, 19), (33, 23)] push push +[(33, 33), (33, 37)] this this +[(33, 38), (33, 43)] props props +[(33, 44), (33, 47)] dac dac +[(33, 48), (33, 50)] id id + +classes: +[(23, 0), (31, 1)] Ultra + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols call expression 1`] = ` +"functions: +[(2, 0), (2, 70)] evaluate(script, , ) + +callExpressions: +[(1, 0), (1, 8)] dispatch +[(4, 0), (4, 1)] a +[(4, 2), (4, 3)] b +[(4, 4), (4, 5)] c +[(6, 6), (6, 7)] c +[(6, 2), (6, 3)] b +[(7, 6), (7, 7)] d + +memberExpressions: +[(6, 6), (6, 7)] c +[(6, 2), (6, 3)] a.b b +[(7, 6), (7, 7)] a.b.c.d d +[(7, 4), (7, 5)] a.b.c c +[(7, 2), (7, 3)] a.b b + +objectProperties: +[(1, 11), (1, 12)] d +[(2, 28), (2, 35)] frameId +[(2, 41), (2, 48)] frameId +[(2, 55), (2, 56)] c +[(2, 61), (2, 62)] c + +comments: + + +identifiers: +[(1, 0), (1, 8)] dispatch dispatch +[(1, 11), (1, 12)] d d +[(2, 9), (2, 17)] evaluate evaluate +[(2, 18), (2, 24)] script script +[(2, 28), (2, 35)] frameId frameId +[(2, 41), (2, 48)] 3 frameId +[(2, 55), (2, 56)] c c +[(2, 61), (2, 62)] 2 c +[(4, 0), (4, 1)] a a +[(4, 2), (4, 3)] b b +[(4, 4), (4, 5)] c c +[(6, 0), (6, 1)] a a +[(6, 2), (6, 3)] b b +[(6, 6), (6, 7)] c c +[(7, 0), (7, 1)] a a +[(7, 2), (7, 3)] b b +[(7, 4), (7, 5)] c c +[(7, 6), (7, 7)] d d + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols call sites 1`] = ` +"functions: + + +callExpressions: +[(1, 0), (1, 3)] aaa +[(1, 4), (1, 7)] bbb +[(1, 11), (1, 14)] ccc +[(4, 3), (4, 7)] ffff +[(3, 3), (3, 6)] eee +[(2, 0), (2, 4)] dddd + +memberExpressions: +[(4, 3), (4, 7)] ffff +[(3, 3), (3, 6)] eee + +objectProperties: + + +comments: + + +identifiers: +[(1, 0), (1, 3)] aaa aaa +[(1, 4), (1, 7)] bbb bbb +[(1, 11), (1, 14)] ccc ccc +[(2, 0), (2, 4)] dddd dddd +[(3, 3), (3, 6)] eee eee +[(4, 3), (4, 7)] ffff ffff + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols class 1`] = ` +"functions: +[(5, 2), (7, 3)] hello() Test +[(12, 2), (15, 3)] constructor() Test +[(17, 2), (19, 3)] bar(a) Test +[(21, 8), (23, 3)] baz(b) Test + +callExpressions: +[(18, 12), (18, 15)] log bar + +memberExpressions: +[(6, 27), (6, 35)] this.myStatic myStatic +[(9, 19), (9, 27)] this.myStatic myStatic +[(13, 9), (13, 23)] this.publicProperty publicProperty +[(14, 9), (14, 25)] this.#privateProperty #privateProperty +[(18, 12), (18, 15)] console.log log + +objectProperties: + + +comments: + + +identifiers: +[(1, 6), (1, 10)] Test Test +[(2, 2), (2, 16)] publicProperty +[(3, 3), (3, 18)] privateProperty privateProperty +[(3, 21), (3, 30)] \\"default\\" default +[(4, 9), (4, 17)] \\"static\\" myStatic +[(4, 20), (4, 28)] \\"static\\" static +[(6, 22), (6, 26)] this this +[(6, 27), (6, 35)] myStatic myStatic +[(9, 10), (9, 11)] x x +[(9, 14), (9, 18)] this this +[(9, 19), (9, 27)] myStatic myStatic +[(13, 4), (13, 8)] this this +[(13, 9), (13, 23)] publicProperty publicProperty +[(14, 4), (14, 8)] this this +[(14, 10), (14, 25)] privateProperty privateProperty +[(17, 6), (17, 7)] a a +[(18, 4), (18, 11)] console console +[(18, 12), (18, 15)] log log +[(18, 23), (18, 24)] a a +[(21, 2), (21, 5)] b => { + return b * 2; +} baz +[(21, 8), (21, 9)] b b +[(22, 11), (22, 12)] b b +[(26, 6), (26, 11)] Test2 Test2 +[(28, 4), (28, 19)] expressiveClass expressiveClass + +classes: +[(1, 0), (24, 1)] Test +[(26, 0), (26, 14)] Test2 + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols component 1`] = ` +"functions: +[(6, 2), (9, 3)] constructor(props) Punny +[(11, 2), (11, 24)] componentDidMount() Punny +[(13, 2), (13, 14)] onClick() Punny +[(15, 2), (17, 3)] renderMe() Punny +[(19, 2), (19, 13)] render() Punny +[(29, 10), (31, 3)] render() TodoView 1 +[(37, 10), (39, 3)] render() TodoClass 2 +[(45, 10), (47, 3)] render() createClass 3 +[(53, 10), (55, 3)] render() TodoClass 4 +[(62, 0), (66, 1)] Button() +[(70, 8), (70, 21)] x() +[(72, 33), (79, 1)] createElement(color) +[(82, 26), (84, 1)] update(newColor) + +callExpressions: +[(7, 4), (7, 9)] +[(8, 32), (8, 36)] bind +[(26, 31), (26, 37)] extend +[(30, 12), (30, 15)] log yo +[(34, 18), (34, 29)] createClass +[(38, 12), (38, 15)] log yo +[(42, 12), (42, 23)] createClass +[(46, 12), (46, 15)] log yo +[(50, 16), (50, 27)] createClass +[(54, 12), (54, 15)] log yo +[(65, 16), (65, 20)] call +[(68, 26), (68, 32)] create + +memberExpressions: +[(8, 9), (8, 16)] this.onClick onClick +[(8, 32), (8, 36)] this.onClick.bind bind +[(8, 24), (8, 31)] this.onClick onClick +[(16, 30), (16, 37)] this.onClick onClick +[(26, 31), (26, 37)] Backbone.View.extend extend +[(26, 26), (26, 30)] Backbone.View View +[(30, 12), (30, 15)] console.log log +[(38, 12), (38, 15)] console.log log +[(46, 12), (46, 15)] console.log log +[(50, 4), (50, 13)] app.TodoClass TodoClass +[(54, 12), (54, 15)] console.log log +[(64, 7), (64, 12)] this.color color +[(65, 16), (65, 20)] Nanocomponent.call call +[(68, 7), (68, 16)] Button.prototype prototype +[(68, 26), (68, 32)] Object.create create +[(68, 47), (68, 56)] Nanocomponent.prototype prototype +[(72, 17), (72, 30)] Button.prototype.createElement createElement +[(72, 7), (72, 16)] Button.prototype prototype +[(73, 7), (73, 12)] this.color color +[(82, 17), (82, 23)] Button.prototype.update update +[(82, 7), (82, 16)] Button.prototype prototype +[(83, 27), (83, 32)] this.color color + +objectProperties: +[(27, 2), (27, 9)] tagName +[(29, 2), (29, 8)] render +[(35, 2), (35, 9)] tagName +[(37, 2), (37, 8)] render +[(43, 2), (43, 9)] tagName +[(45, 2), (45, 8)] render +[(51, 2), (51, 9)] tagName +[(53, 2), (53, 8)] render + +comments: +[(1, 0), (3, 3)] +[(22, 0), (24, 3)] +[(58, 0), (60, 3)] +[(81, 0), (81, 34)] + +identifiers: +[(5, 6), (5, 11)] Punny Punny +[(6, 14), (6, 19)] props props +[(8, 4), (8, 8)] this this +[(8, 9), (8, 16)] onClick onClick +[(8, 19), (8, 23)] this this +[(8, 24), (8, 31)] onClick onClick +[(8, 32), (8, 36)] bind bind +[(8, 37), (8, 41)] this this +[(16, 25), (16, 29)] this this +[(16, 30), (16, 37)] onClick onClick +[(5, 20), (5, 29)] Component Component +[(26, 6), (26, 14)] TodoView TodoView +[(26, 17), (26, 25)] Backbone Backbone +[(26, 26), (26, 30)] View View +[(26, 31), (26, 37)] extend extend +[(27, 2), (27, 9)] \\"li\\" tagName +[(27, 11), (27, 15)] \\"li\\" li +[(29, 2), (29, 8)] render +[(30, 4), (30, 11)] console console +[(30, 12), (30, 15)] log log +[(34, 6), (34, 15)] TodoClass TodoClass +[(34, 18), (34, 29)] createClass createClass +[(35, 2), (35, 9)] \\"li\\" tagName +[(35, 11), (35, 15)] \\"li\\" li +[(37, 2), (37, 8)] render +[(38, 4), (38, 11)] console console +[(38, 12), (38, 15)] log log +[(42, 0), (42, 9)] TodoClass TodoClass +[(42, 12), (42, 23)] createClass createClass +[(43, 2), (43, 9)] \\"li\\" tagName +[(43, 11), (43, 15)] \\"li\\" li +[(45, 2), (45, 8)] render +[(46, 4), (46, 11)] console console +[(46, 12), (46, 15)] log log +[(50, 0), (50, 3)] app app +[(50, 4), (50, 13)] TodoClass TodoClass +[(50, 16), (50, 27)] createClass createClass +[(51, 2), (51, 9)] \\"li\\" tagName +[(51, 11), (51, 15)] \\"li\\" li +[(53, 2), (53, 8)] render +[(54, 4), (54, 11)] console console +[(54, 12), (54, 15)] log log +[(62, 9), (62, 15)] Button Button +[(63, 8), (63, 12)] this this +[(63, 24), (63, 30)] Button Button +[(63, 44), (63, 50)] Button Button +[(64, 2), (64, 6)] this this +[(64, 7), (64, 12)] color color +[(65, 2), (65, 15)] Nanocomponent Nanocomponent +[(65, 16), (65, 20)] call call +[(65, 21), (65, 25)] this this +[(68, 0), (68, 6)] Button Button +[(68, 7), (68, 16)] prototype prototype +[(68, 19), (68, 25)] Object Object +[(68, 26), (68, 32)] create create +[(68, 33), (68, 46)] Nanocomponent Nanocomponent +[(68, 47), (68, 56)] prototype prototype +[(70, 4), (70, 5)] x x +[(72, 0), (72, 6)] Button Button +[(72, 7), (72, 16)] prototype prototype +[(72, 17), (72, 30)] createElement createElement +[(72, 42), (72, 47)] color color +[(73, 2), (73, 6)] this this +[(73, 7), (73, 12)] color color +[(73, 15), (73, 20)] color color +[(74, 9), (74, 13)] html html +[(75, 39), (75, 44)] color color +[(82, 0), (82, 6)] Button Button +[(82, 7), (82, 16)] prototype prototype +[(82, 17), (82, 23)] update update +[(82, 35), (82, 43)] newColor newColor +[(83, 9), (83, 17)] newColor newColor +[(83, 22), (83, 26)] this this +[(83, 27), (83, 32)] color color + +classes: +[(5, 0), (20, 1)] Punny + +imports: + + +literals: + + +hasJsx: true + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols destruct 1`] = ` +"functions: + + +callExpressions: +[(1, 21), (1, 28)] compute +[(4, 21), (4, 28)] compute +[(7, 35), (7, 42)] entries +[(8, 10), (8, 13)] log + +memberExpressions: +[(7, 35), (7, 42)] arr.entries entries +[(8, 10), (8, 13)] console.log log +[(16, 34), (16, 46)] prefsBlueprint[accessorName] accessorName + +objectProperties: +[(1, 8), (1, 9)] b b +[(1, 11), (1, 16)] resty resty +[(2, 8), (2, 13)] first first +[(2, 18), (2, 22)] last last +[(11, 8), (11, 9)] a a +[(11, 20), (11, 21)] b b +[(11, 36), (11, 37)] a a +[(12, 8), (12, 12)] temp temp +[(12, 17), (12, 20)] foo +[(14, 7), (14, 10)] [key] key +[(14, 23), (14, 24)] z z + +comments: + + +identifiers: +[(1, 8), (1, 9)] b b +[(1, 11), (1, 16)] resty resty +[(1, 21), (1, 28)] compute compute +[(1, 29), (1, 34)] stuff stuff +[(2, 15), (2, 16)] f f +[(2, 24), (2, 25)] l l +[(2, 8), (2, 13)] f first +[(2, 18), (2, 22)] l last +[(2, 30), (2, 33)] obj obj +[(4, 7), (4, 8)] a a +[(4, 13), (4, 17)] rest rest +[(4, 21), (4, 28)] compute compute +[(4, 29), (4, 34)] stuff stuff +[(5, 7), (5, 8)] x x +[(7, 12), (7, 17)] index index +[(7, 19), (7, 26)] element element +[(7, 31), (7, 34)] arr arr +[(7, 35), (7, 42)] entries entries +[(8, 2), (8, 9)] console console +[(8, 10), (8, 13)] log log +[(8, 14), (8, 19)] index index +[(8, 21), (8, 28)] element element +[(11, 8), (11, 9)] a a +[(11, 11), (11, 13)] aa aa +[(11, 20), (11, 21)] b b +[(11, 23), (11, 25)] bb bb +[(11, 36), (11, 37)] 3 a +[(12, 22), (12, 27)] foooo foooo +[(12, 8), (12, 12)] [{ + foo: foooo +}] temp +[(12, 17), (12, 20)] foooo foo +[(12, 35), (12, 38)] obj obj +[(14, 13), (14, 16)] foo foo +[(14, 7), (14, 10)] foo key +[(14, 23), (14, 24)] \\"bar\\" z +[(14, 26), (14, 31)] \\"bar\\" bar +[(16, 7), (16, 15)] prefName prefName +[(16, 19), (16, 33)] prefsBlueprint prefsBlueprint +[(16, 34), (16, 46)] accessorName accessorName + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols es6 1`] = ` +"functions: + + +callExpressions: +[(1, 0), (1, 8)] dispatch + +memberExpressions: + + +objectProperties: +[(1, 23), (1, 30)] PROMISE + +comments: + + +identifiers: +[(1, 0), (1, 8)] dispatch dispatch +[(1, 14), (1, 20)] action action +[(1, 23), (1, 30)] promise PROMISE +[(1, 33), (1, 40)] promise promise + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols expression 1`] = ` +"functions: +[(10, 10), (10, 23)] render() TodoView +[(23, 0), (23, 28)] params() +[(24, 11), (24, 32)] pars() + +callExpressions: +[(9, 24), (9, 30)] extend +[(25, 18), (25, 24)] doEvil + +memberExpressions: +[(2, 19), (2, 33)] obj2.c.secondProperty secondProperty +[(2, 17), (2, 18)] obj2.c c +[(6, 40), (6, 46)] collection.books[1].author author +[(6, 37), (6, 38)] collection.books[1] +[(6, 31), (6, 36)] collection.books books +[(7, 66), (7, 74)] collection.genres[\\"sci-fi\\"].movies[0].director director +[(7, 63), (7, 64)] collection.genres[\\"sci-fi\\"].movies[0] +[(7, 56), (7, 62)] collection.genres[\\"sci-fi\\"].movies movies +[(7, 46), (7, 54)] collection.genres[\\"sci-fi\\"] +[(7, 39), (7, 45)] collection.genres genres +[(9, 4), (9, 12)] app.TodoView TodoView +[(9, 24), (9, 30)] Backbone.extend extend +[(14, 4), (14, 7)] obj.foo foo +[(21, 40), (21, 41)] a.b.c.v.d d +[(21, 38), (21, 39)] a.b.c.v v +[(21, 36), (21, 37)] a.b.c c +[(21, 34), (21, 35)] a.b b +[(25, 29), (25, 43)] secondProperty +[(25, 27), (25, 28)] c +[(25, 18), (25, 24)] obj2.doEvil doEvil + +objectProperties: +[(1, 14), (1, 15)] obj.a a +[(1, 19), (1, 20)] obj.a.b b +[(5, 15), (5, 16)] com[a] a +[(5, 21), (5, 22)] com[a].b b +[(5, 30), (5, 31)] com[a][d] d +[(5, 42), (5, 43)] com[b] b +[(10, 2), (10, 8)] render +[(14, 12), (14, 13)] obj.foo.a a +[(14, 17), (14, 18)] obj.foo.a.b b +[(14, 27), (14, 28)] obj.foo.b b +[(15, 8), (15, 9)] com.a a +[(15, 13), (15, 14)] com.a.b b +[(15, 23), (15, 24)] com.b b +[(18, 15), (18, 16)] res[0].a a +[(18, 25), (18, 26)] res[1].b b +[(19, 15), (19, 16)] res2.a a +[(19, 21), (19, 22)] res2.a[0].b b +[(20, 15), (20, 16)] res3.a a +[(20, 21), (20, 22)] res3.a[0].b b +[(20, 30), (20, 31)] res3.b b +[(20, 36), (20, 37)] res3.b[0].c c +[(21, 17), (21, 18)] res4[0].a a +[(21, 29), (21, 30)] res4[0].b b +[(23, 18), (23, 19)] a a +[(23, 21), (23, 22)] b b +[(24, 22), (24, 23)] a a +[(24, 25), (24, 26)] b b + +comments: +[(1, 29), (1, 44)] +[(2, 35), (2, 68)] +[(4, 0), (4, 22)] +[(5, 51), (5, 67)] +[(13, 0), (13, 14)] +[(14, 35), (14, 54)] +[(15, 31), (15, 46)] +[(17, 0), (17, 9)] +[(18, 34), (18, 50)] +[(19, 32), (19, 50)] +[(20, 47), (20, 65)] +[(21, 47), (21, 66)] +[(23, 29), (23, 38)] +[(25, 45), (25, 70)] + +identifiers: +[(1, 6), (1, 9)] obj obj +[(1, 14), (1, 15)] ({ + b: 2 +}) a +[(1, 19), (1, 20)] 2 b +[(2, 6), (2, 9)] foo foo +[(2, 12), (2, 16)] obj2 obj2 +[(2, 17), (2, 18)] c c +[(2, 19), (2, 33)] secondProperty secondProperty +[(5, 6), (5, 9)] com com +[(5, 15), (5, 16)] ({ + b: \\"c\\", + [d]: \\"e\\" +}) a +[(5, 21), (5, 22)] \\"c\\" b +[(5, 24), (5, 27)] \\"c\\" c +[(5, 30), (5, 31)] \\"e\\" d +[(5, 34), (5, 37)] \\"e\\" e +[(5, 42), (5, 43)] 3 b +[(6, 6), (6, 17)] firstAuthor firstAuthor +[(6, 20), (6, 30)] collection collection +[(6, 31), (6, 36)] books books +[(6, 40), (6, 46)] author author +[(7, 6), (7, 25)] firstActionDirector firstActionDirector +[(7, 28), (7, 38)] collection collection +[(7, 39), (7, 45)] genres genres +[(7, 56), (7, 62)] movies movies +[(7, 66), (7, 74)] director director +[(9, 0), (9, 3)] app app +[(9, 4), (9, 12)] TodoView TodoView +[(9, 15), (9, 23)] Backbone Backbone +[(9, 24), (9, 30)] extend extend +[(10, 2), (10, 8)] render +[(14, 0), (14, 3)] obj obj +[(14, 4), (14, 7)] foo foo +[(14, 12), (14, 13)] ({ + b: \\"c\\" +}) a +[(14, 17), (14, 18)] \\"c\\" b +[(14, 20), (14, 23)] \\"c\\" c +[(14, 27), (14, 28)] 3 b +[(15, 0), (15, 3)] com com +[(15, 8), (15, 9)] ({ + b: \\"c\\" +}) a +[(15, 13), (15, 14)] \\"c\\" b +[(15, 16), (15, 19)] \\"c\\" c +[(15, 23), (15, 24)] 3 b +[(18, 6), (18, 9)] res res +[(18, 15), (18, 16)] 2 a +[(18, 25), (18, 26)] 3 b +[(19, 6), (19, 10)] res2 res2 +[(19, 15), (19, 16)] [{ + b: 2 +}] a +[(19, 21), (19, 22)] 2 b +[(20, 6), (20, 10)] res3 res3 +[(20, 15), (20, 16)] [{ + b: 2 +}] a +[(20, 21), (20, 22)] 2 b +[(20, 30), (20, 31)] [{ + c: 3 +}] b +[(20, 36), (20, 37)] 3 c +[(21, 6), (21, 10)] res4 res4 +[(21, 17), (21, 18)] 3 a +[(21, 29), (21, 30)] a.b.c.v.d b +[(21, 32), (21, 33)] a a +[(21, 34), (21, 35)] b b +[(21, 36), (21, 37)] c c +[(21, 38), (21, 39)] v v +[(21, 40), (21, 41)] d d +[(23, 9), (23, 15)] params params +[(23, 18), (23, 19)] a a +[(23, 21), (23, 22)] b b +[(24, 4), (24, 8)] pars pars +[(24, 22), (24, 23)] a a +[(24, 25), (24, 26)] b b +[(25, 6), (25, 10)] evil evil +[(25, 13), (25, 17)] obj2 obj2 +[(25, 18), (25, 24)] doEvil doEvil +[(25, 27), (25, 28)] c c +[(25, 29), (25, 43)] secondProperty secondProperty + +classes: + + +imports: + + +literals: +[(6, 37), (6, 38)] collection.books[1] 1 +[(7, 46), (7, 54)] collection.genres[\\"sci-fi\\"] sci-fi +[(7, 63), (7, 64)] collection.genres[\\"sci-fi\\"].movies[0] 0 + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols finds symbols in an html file 1`] = ` +"functions: +[(8, 2), (10, 3)] sayHello(name) +[(22, 21), (24, 3)] capitalize(name) +[(36, 3), (39, 3)] iife() + +callExpressions: +[(23, 18), (23, 29)] toUpperCase +[(23, 39), (23, 48)] substring 1 +[(28, 4), (28, 8)] join +[(27, 4), (27, 7)] map +[(26, 4), (26, 7)] map +[(36, 3), (39, 3)] +[(37, 20), (37, 28)] sayHello Ryan +[(38, 11), (38, 14)] log + +memberExpressions: +[(23, 18), (23, 29)] name[0].toUpperCase toUpperCase +[(23, 15), (23, 16)] name[0] +[(23, 39), (23, 48)] name.substring substring +[(28, 4), (28, 8)] join +[(27, 4), (27, 7)] map +[(26, 4), (26, 7)] map map +[(30, 15), (30, 24)] globalObject.greetings greetings +[(38, 11), (38, 14)] console.log log + +objectProperties: +[(5, 3), (5, 8)] globalObject.first first +[(6, 3), (6, 7)] globalObject.last last + +comments: + + +identifiers: +[(4, 6), (4, 18)] globalObject globalObject +[(5, 3), (5, 8)] \\"name\\" first +[(5, 10), (5, 16)] \\"name\\" name +[(6, 3), (6, 7)] \\"words\\" last +[(6, 9), (6, 16)] \\"words\\" words +[(8, 11), (8, 19)] sayHello sayHello +[(8, 21), (8, 25)] name name +[(9, 20), (9, 24)] name name +[(22, 8), (22, 18)] capitalize capitalize +[(22, 21), (22, 25)] name name +[(23, 10), (23, 14)] name name +[(23, 18), (23, 29)] toUpperCase toUpperCase +[(23, 34), (23, 38)] name name +[(23, 39), (23, 48)] substring substring +[(25, 8), (25, 16)] greetAll greetAll +[(26, 4), (26, 7)] map map +[(26, 8), (26, 18)] capitalize capitalize +[(27, 4), (27, 7)] map map +[(27, 8), (27, 16)] sayHello sayHello +[(28, 4), (28, 8)] join join +[(30, 2), (30, 14)] globalObject globalObject +[(30, 15), (30, 24)] greetings greetings +[(30, 27), (30, 35)] greetAll greetAll +[(36, 12), (36, 16)] iife iife +[(37, 9), (37, 17)] greeting greeting +[(37, 20), (37, 28)] sayHello sayHello +[(38, 3), (38, 10)] console console +[(38, 11), (38, 14)] log log +[(38, 15), (38, 23)] greeting greeting + +classes: + + +imports: + + +literals: +[(23, 15), (23, 16)] name[0] 0 + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols flow 1`] = ` +"functions: +[(2, 2), (4, 3)] renderHello(name, action, ) App + +callExpressions: + + +memberExpressions: + + +objectProperties: +[(2, 51), (2, 56)] todos todos + +comments: + + +identifiers: +[(1, 6), (1, 9)] App App +[(2, 14), (2, 18)] name name +[(2, 28), (2, 34)] action action +[(2, 51), (2, 56)] todos todos +[(3, 20), (3, 24)] name name +[(1, 18), (1, 27)] Component Component + +classes: +[(1, 0), (5, 1)] App + +imports: + + +literals: + + +hasJsx: false + +hasTypes: true + +framework: null" +`; + +exports[`Parser.getSymbols func 1`] = ` +"functions: +[(1, 0), (3, 1)] square(n) +[(5, 7), (7, 1)] exFoo() +[(9, 0), (11, 1)] slowFoo() +[(13, 7), (15, 1)] exSlowFoo() +[(17, 0), (19, 1)] ret() +[(21, 8), (21, 21)] child() +[(23, 1), (25, 1)] anonymous() +[(28, 7), (30, 3)] name() +[(32, 2), (34, 3)] bar() +[(37, 15), (38, 1)] root() +[(40, 0), (42, 1)] test(a1, , ) +[(44, 0), (44, 13)] anonymous() 1 +[(46, 0), (50, 1)] ret2() + +callExpressions: +[(18, 9), (18, 12)] foo +[(23, 1), (25, 1)] +[(41, 10), (41, 13)] log pause next here +[(48, 4), (48, 7)] foo + +memberExpressions: +[(41, 10), (41, 13)] console.log log + +objectProperties: +[(28, 2), (28, 5)] obj.foo foo +[(40, 29), (40, 31)] a3 +[(40, 33), (40, 35)] a4 +[(40, 37), (40, 39)] a5 +[(40, 43), (40, 45)] a6 + +comments: + + +identifiers: +[(1, 9), (1, 15)] square square +[(1, 16), (1, 17)] n n +[(2, 9), (2, 10)] n n +[(2, 13), (2, 14)] n n +[(5, 16), (5, 21)] exFoo exFoo +[(9, 15), (9, 22)] slowFoo slowFoo +[(13, 22), (13, 31)] exSlowFoo exSlowFoo +[(17, 9), (17, 12)] ret ret +[(18, 9), (18, 12)] foo foo +[(21, 0), (21, 5)] child child +[(27, 6), (27, 9)] obj obj +[(28, 2), (28, 5)] foo +[(28, 16), (28, 20)] name name +[(32, 2), (32, 5)] bar bar +[(37, 24), (37, 28)] root root +[(40, 9), (40, 13)] test test +[(40, 14), (40, 16)] a1 a1 +[(40, 18), (40, 20)] a2 a2 +[(40, 29), (40, 31)] a3 a3 +[(40, 33), (40, 35)] a4 a4 +[(40, 37), (40, 39)] { + a6: a7 +} = {} a5 +[(40, 43), (40, 45)] a7 a6 +[(40, 47), (40, 49)] a7 a7 +[(41, 2), (41, 9)] console console +[(41, 10), (41, 13)] log log +[(44, 7), (44, 8)] x x +[(46, 9), (46, 13)] ret2 ret2 +[(48, 4), (48, 7)] foo foo + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols function names 1`] = ` +"functions: +[(4, 7), (4, 20)] foo() +[(5, 9), (5, 22)] foo() 1 +[(6, 6), (6, 19)] 42() +[(8, 2), (8, 10)] foo() 2 +[(9, 2), (9, 12)] foo() 3 +[(10, 2), (10, 9)] 42() 1 +[(13, 6), (13, 19)] foo() 4 +[(14, 10), (14, 23)] foo() 5 +[(16, 10), (16, 22)] foo() 6 +[(17, 11), (17, 23)] foo() 7 +[(18, 11), (18, 23)] foo() 8 +[(20, 7), (20, 19)] foo() 9 +[(21, 8), (21, 20)] foo() 10 +[(22, 13), (22, 25)] foo() 11 +[(24, 0), (24, 35)] fn() +[(24, 19), (24, 31)] foo() 12 +[(25, 0), (25, 40)] f2() +[(25, 19), (25, 31)] foo() 13 +[(26, 0), (26, 45)] f3() +[(26, 24), (26, 36)] foo() 14 +[(29, 8), (29, 21)] foo() Cls 15 +[(30, 10), (30, 23)] foo() Cls 16 +[(31, 7), (31, 20)] 42() Cls 2 +[(33, 2), (33, 10)] foo() Cls 17 +[(34, 2), (34, 12)] foo() Cls 18 +[(35, 2), (35, 9)] 42() Cls 3 +[(38, 1), (38, 13)] anonymous() +[(40, 15), (40, 28)] default() +[(44, 0), (44, 27)] a(first, second) +[(45, 0), (45, 35)] b(first = bla, second) +[(46, 0), (46, 32)] c(first = {}, second) +[(47, 0), (47, 32)] d(first = [], second) +[(48, 0), (48, 40)] e(first = defaultObj, second) +[(49, 0), (49, 40)] f(first = defaultArr, second) +[(50, 0), (50, 34)] g(first = null, second) + +callExpressions: + + +memberExpressions: +[(14, 4), (14, 7)] obj.foo foo + +objectProperties: +[(4, 2), (4, 5)] foo +[(5, 2), (5, 7)] +[(6, 2), (6, 4)] +[(18, 5), (18, 8)] foo foo +[(21, 2), (21, 5)] undefined.foo foo +[(22, 2), (22, 5)] undefined.bar bar +[(25, 13), (25, 16)] foo +[(26, 13), (26, 16)] bar +[(42, 20), (42, 21)] defaultObj.a a + +comments: +[(1, 0), (1, 20)] + +identifiers: +[(4, 2), (4, 5)] foo +[(5, 2), (5, 7)] foo +[(8, 2), (8, 5)] foo foo +[(13, 0), (13, 3)] foo foo +[(14, 0), (14, 3)] obj obj +[(14, 4), (14, 7)] foo foo +[(16, 4), (16, 7)] foo foo +[(17, 5), (17, 8)] foo foo +[(18, 5), (18, 8)] foo foo +[(20, 1), (20, 4)] foo foo +[(21, 2), (21, 5)] foo foo +[(22, 2), (22, 5)] bar bar +[(22, 7), (22, 10)] foo foo +[(24, 9), (24, 11)] fn fn +[(24, 13), (24, 16)] foo foo +[(25, 9), (25, 11)] f2 f2 +[(25, 13), (25, 16)] foo foo +[(26, 9), (26, 11)] f3 f3 +[(26, 13), (26, 16)] bar bar +[(26, 18), (26, 21)] foo foo +[(28, 6), (28, 9)] Cls Cls +[(29, 2), (29, 5)] foo +[(30, 2), (30, 7)] foo +[(42, 6), (42, 16)] defaultObj defaultObj +[(42, 20), (42, 21)] 1 a +[(43, 6), (43, 16)] defaultArr defaultArr +[(44, 9), (44, 10)] a a +[(44, 11), (44, 16)] first first +[(44, 18), (44, 24)] second second +[(45, 9), (45, 10)] b b +[(45, 11), (45, 16)] first first +[(45, 26), (45, 32)] second second +[(46, 9), (46, 10)] c c +[(46, 11), (46, 16)] first first +[(46, 23), (46, 29)] second second +[(47, 9), (47, 10)] d d +[(47, 11), (47, 16)] first first +[(47, 23), (47, 29)] second second +[(48, 9), (48, 10)] e e +[(48, 11), (48, 16)] first first +[(48, 19), (48, 29)] defaultObj defaultObj +[(48, 31), (48, 37)] second second +[(49, 9), (49, 10)] f f +[(49, 11), (49, 16)] first first +[(49, 19), (49, 29)] defaultArr defaultArr +[(49, 31), (49, 37)] second second +[(50, 9), (50, 10)] g g +[(50, 11), (50, 16)] first first +[(50, 25), (50, 31)] second second + +classes: +[(28, 0), (36, 1)] Cls + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols jsx 1`] = ` +"functions: + + +callExpressions: +[(3, 17), (3, 20)] foo +[(4, 9), (4, 12)] foo + +memberExpressions: + + +objectProperties: + + +comments: + + +identifiers: +[(1, 6), (1, 16)] jsxElement jsxElement +[(3, 17), (3, 20)] foo foo +[(4, 9), (4, 12)] foo foo + +classes: + + +imports: + + +literals: + + +hasJsx: true + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols math 1`] = ` +"functions: +[(1, 0), (12, 1)] math(n) +[(2, 2), (5, 3)] square(n) +[(14, 12), (14, 25)] child() +[(15, 9), (15, 22)] child2() + +callExpressions: +[(8, 14), (8, 20)] square 2 +[(10, 15), (10, 22)] squaare 4 + +memberExpressions: + + +objectProperties: + + +comments: +[(3, 4), (3, 21)] +[(7, 2), (7, 24)] + +identifiers: +[(1, 9), (1, 13)] math math +[(1, 14), (1, 15)] n n +[(2, 11), (2, 17)] square square +[(2, 18), (2, 19)] n n +[(4, 4), (4, 5)] n n +[(4, 8), (4, 9)] n n +[(8, 8), (8, 11)] two two +[(8, 14), (8, 20)] square square +[(10, 8), (10, 12)] four four +[(10, 15), (10, 22)] squaare squaare +[(11, 9), (11, 12)] two two +[(11, 15), (11, 19)] four four +[(14, 4), (14, 9)] child child +[(15, 0), (15, 6)] child2 child2 + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols object expressions 1`] = ` +"functions: + + +callExpressions: +[(3, 62), (3, 70)] getValue 2 + +memberExpressions: + + +objectProperties: +[(1, 13), (1, 19)] y.params params +[(2, 15), (2, 16)] foo.b b +[(3, 14), (3, 15)] bar.x x +[(3, 20), (3, 21)] bar.y y +[(3, 30), (3, 31)] bar.z z +[(3, 39), (3, 40)] bar.a a +[(3, 48), (3, 49)] bar.s s +[(3, 58), (3, 59)] bar.d d +[(4, 27), (4, 37)] frameActor +[(6, 20), (6, 26)] collection.genres genres +[(6, 29), (6, 37)] collection.genres +[(6, 40), (6, 46)] collection.genres.undefined.movies movies +[(6, 50), (6, 58)] collection.genres.undefined.movies[0].director director + +comments: + + +identifiers: +[(1, 6), (1, 7)] y y +[(1, 13), (1, 19)] params params +[(2, 6), (2, 9)] foo foo +[(2, 15), (2, 16)] b b +[(3, 6), (3, 9)] bar bar +[(3, 14), (3, 15)] 3 x +[(3, 20), (3, 21)] \\"434\\" y +[(3, 23), (3, 28)] \\"434\\" 434 +[(3, 30), (3, 31)] true z +[(3, 39), (3, 40)] null a +[(3, 48), (3, 49)] c * 2 s +[(3, 51), (3, 52)] c c +[(3, 58), (3, 59)] d +[(3, 62), (3, 70)] getValue getValue +[(4, 6), (4, 12)] params params +[(4, 15), (4, 22)] frameId frameId +[(4, 27), (4, 37)] frameId frameActor +[(4, 39), (4, 46)] frameId frameId +[(6, 6), (6, 16)] collection collection +[(6, 20), (6, 26)] ({ + \\"sci-fi\\": { + movies: [{ + director: \\"yo\\" + }] + } +}) genres +[(6, 29), (6, 37)] ({ + movies: [{ + director: \\"yo\\" + }] +}) sci-fi +[(6, 40), (6, 46)] [{ + director: \\"yo\\" +}] movies +[(6, 50), (6, 58)] \\"yo\\" director +[(6, 60), (6, 64)] \\"yo\\" yo + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols optional chaining 1`] = ` +"functions: + + +callExpressions: + + +memberExpressions: +[(2, 5), (2, 6)] obj?.a a +[(3, 8), (3, 9)] obj?.a?.b b +[(3, 5), (3, 6)] obj?.a a + +objectProperties: + + +comments: + + +identifiers: +[(1, 6), (1, 9)] obj obj +[(2, 0), (2, 3)] obj obj +[(2, 5), (2, 6)] a a +[(3, 0), (3, 3)] obj obj +[(3, 5), (3, 6)] a a +[(3, 8), (3, 9)] b b + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols private fields 1`] = ` +"functions: +[(2, 2), (6, 3)] constructor(secret, ) MyClass +[(13, 2), (15, 3)] #getSalt() MyClass +[(17, 2), (23, 3)] debug() MyClass + +callExpressions: +[(21, 22), (21, 30)] + +memberExpressions: +[(4, 9), (4, 16)] this.#secret #secret +[(5, 9), (5, 20)] self.#restParams #restParams +[(14, 16), (14, 21)] this.#salt #salt +[(19, 30), (19, 42)] this.creationDate creationDate +[(20, 24), (20, 31)] this.#secret #secret +[(21, 22), (21, 30)] self.#getSalt #getSalt + +objectProperties: + + +comments: + + +identifiers: +[(1, 6), (1, 13)] MyClass MyClass +[(2, 14), (2, 20)] secret secret +[(2, 25), (2, 29)] rest rest +[(3, 10), (3, 14)] self self +[(3, 17), (3, 21)] this this +[(4, 4), (4, 8)] this this +[(4, 10), (4, 16)] secret secret +[(4, 19), (4, 25)] secret secret +[(5, 4), (5, 8)] self self +[(5, 10), (5, 20)] restParams restParams +[(5, 23), (5, 27)] rest rest +[(8, 3), (8, 9)] secret secret +[(9, 3), (9, 13)] restParams restParams +[(10, 3), (10, 7)] salt salt +[(10, 10), (10, 17)] \\"bloup\\" bloup +[(11, 2), (11, 14)] new Date() creationDate +[(11, 21), (11, 25)] Date Date +[(13, 3), (13, 10)] getSalt getSalt +[(14, 11), (14, 15)] this this +[(14, 17), (14, 21)] salt salt +[(18, 10), (18, 14)] self self +[(18, 17), (18, 21)] this this +[(19, 10), (19, 22)] creationDate creationDate +[(19, 25), (19, 29)] this this +[(19, 30), (19, 42)] creationDate creationDate +[(20, 10), (20, 16)] secret secret +[(20, 19), (20, 23)] this this +[(20, 25), (20, 31)] secret secret +[(21, 10), (21, 14)] salt salt +[(21, 17), (21, 21)] self self +[(21, 23), (21, 30)] getSalt getSalt +[(22, 14), (22, 26)] creationDate creationDate +[(22, 30), (22, 34)] salt salt +[(22, 38), (22, 44)] secret secret + +classes: +[(1, 0), (24, 1)] MyClass + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols proto 1`] = ` +"functions: +[(1, 12), (1, 25)] foo() +[(3, 12), (3, 20)] bar() +[(7, 14), (7, 27)] initialize() TodoView +[(8, 2), (10, 3)] doThing(b) TodoView +[(11, 10), (13, 3)] render() TodoView + +callExpressions: +[(5, 31), (5, 37)] extend +[(9, 12), (9, 15)] log hi + +memberExpressions: +[(5, 31), (5, 37)] Backbone.View.extend extend +[(5, 26), (5, 30)] Backbone.View View +[(9, 12), (9, 15)] console.log log + +objectProperties: +[(6, 2), (6, 9)] tagName +[(7, 2), (7, 12)] initialize +[(11, 2), (11, 8)] render + +comments: + + +identifiers: +[(1, 6), (1, 9)] foo foo +[(3, 6), (3, 9)] bar bar +[(5, 6), (5, 14)] TodoView TodoView +[(5, 17), (5, 25)] Backbone Backbone +[(5, 26), (5, 30)] View View +[(5, 31), (5, 37)] extend extend +[(6, 2), (6, 9)] \\"li\\" tagName +[(6, 11), (6, 15)] \\"li\\" li +[(7, 2), (7, 12)] initialize +[(8, 2), (8, 9)] doThing doThing +[(8, 10), (8, 11)] b b +[(9, 4), (9, 11)] console console +[(9, 12), (9, 15)] log log +[(9, 22), (9, 23)] b b +[(11, 2), (11, 8)] render +[(12, 11), (12, 15)] this this + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; + +exports[`Parser.getSymbols react component 1`] = ` +"functions: + + +callExpressions: + + +memberExpressions: + + +objectProperties: + + +comments: + + +identifiers: +[(1, 7), (1, 12)] React React +[(1, 16), (1, 25)] Component Component +[(3, 6), (3, 18)] PrimaryPanes PrimaryPanes +[(3, 27), (3, 36)] Component Component + +classes: +[(3, 0), (3, 39)] PrimaryPanes + +imports: +[(1, 0), (1, 41)] React, Component + +literals: + + +hasJsx: false + +hasTypes: false + +framework: React" +`; + +exports[`Parser.getSymbols var 1`] = ` +"functions: + + +callExpressions: + + +memberExpressions: + + +objectProperties: +[(8, 6), (8, 9)] foo foo +[(8, 13), (8, 16)] foo.baw baw +[(9, 5), (9, 8)] bap bap +[(10, 5), (10, 7)] ll ll +[(15, 6), (15, 7)] a a +[(17, 10), (17, 12)] my +[(19, 13), (19, 15)] oy +[(19, 17), (19, 20)] vey +[(19, 28), (19, 35)] mitzvot + +comments: + + +identifiers: +[(1, 4), (1, 7)] foo foo +[(2, 4), (2, 7)] bar bar +[(3, 6), (3, 9)] baz baz +[(4, 6), (4, 7)] a a +[(5, 2), (5, 3)] b b +[(6, 0), (6, 1)] a a +[(8, 13), (8, 16)] baw baw +[(8, 6), (8, 9)] { + baw +} foo +[(9, 5), (9, 8)] bap bap +[(10, 5), (10, 7)] ll ll +[(13, 5), (13, 10)] first first +[(15, 9), (15, 11)] _a _a +[(15, 6), (15, 7)] _a a +[(17, 5), (17, 7)] oh oh +[(17, 14), (17, 17)] god god +[(17, 10), (17, 12)] god my +[(19, 6), (19, 8)] oj oj +[(19, 13), (19, 15)] oy oy +[(19, 22), (19, 26)] _vey _vey +[(19, 17), (19, 20)] _vey vey +[(19, 28), (19, 35)] mitzvot mitzvot +[(19, 37), (19, 42)] _mitz _mitz +[(21, 5), (21, 8)] one one +[(21, 13), (21, 18)] stuff stuff + +classes: + + +imports: + + +literals: + + +hasJsx: false + +hasTypes: false + +framework: null" +`; diff --git a/devtools/client/debugger/src/workers/parser/tests/__snapshots__/validate.spec.js.snap b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/validate.spec.js.snap new file mode 100644 index 0000000000..a341538a5d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/__snapshots__/validate.spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`has syntax error should return the error object for the invalid expression 1`] = `"SyntaxError : Missing semicolon. (1:3)"`; diff --git a/devtools/client/debugger/src/workers/parser/tests/contains.spec.js b/devtools/client/debugger/src/workers/parser/tests/contains.spec.js new file mode 100644 index 0000000000..741f1a29fc --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/contains.spec.js @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 { containsPosition, containsLocation } from "../utils/contains"; + +function getTestLoc() { + return { + start: { + line: 10, + column: 2, + }, + end: { + line: 12, + column: 10, + }, + }; +} + +// AstPosition.column is typed as a number, but many parts of this test set it +// to undefined. Using zero instead causes test failures, and allowing it to be +// undefined causes many flow errors in code manipulating AstPosition. +// Fake a coercion of undefined to number as a workaround for now. +function undefinedColumn() { + return undefined; +} + +function startPos(lineOffset, columnOffset) { + const { start } = getTestLoc(); + return { + line: start.line + lineOffset, + column: start.column + columnOffset, + }; +} + +function endPos(lineOffset, columnOffset) { + const { end } = getTestLoc(); + return { + line: end.line + lineOffset, + column: end.column + columnOffset, + }; +} + +function startLine(lineOffset = 0) { + const { start } = getTestLoc(); + return { + line: start.line + lineOffset, + column: undefinedColumn(), + }; +} + +function endLine(lineOffset = 0) { + const { end } = getTestLoc(); + return { + line: end.line + lineOffset, + column: undefinedColumn(), + }; +} + +function testContains(pos, bool) { + const loc = getTestLoc(); + expect(containsPosition(loc, pos)).toEqual(bool); +} + +describe("containsPosition", () => { + describe("location and postion both with the column criteria", () => { + /* eslint-disable jest/expect-expect */ + it("should contain position within the location range", () => + testContains(startPos(1, 1), true)); + + it("should not contain position out of the start line", () => + testContains(startPos(-1, 0), false)); + + it("should not contain position out of the start column", () => + testContains(startPos(0, -1), false)); + + it(`should contain position on the same start line and + within the start column`, () => testContains(startPos(0, 1), true)); + + it("should not contain position out of the end line", () => + testContains(endPos(1, 0), false)); + + it("should not contain position out of the end column", () => + testContains(endPos(0, 1), false)); + + // eslint-disable-next-line max-len + it("should contain position on the same end line and within the end column", () => + testContains(endPos(0, -1), true)); + /* eslint-enable jest/expect-expect */ + }); + + describe("position without the column criterion", () => { + /* eslint-disable jest/expect-expect */ + it("should contain position on the same start line", () => + testContains(startLine(0), true)); + + it("should contain position on the same end line", () => + testContains(endLine(0), true)); + /* eslint-enable jest/expect-expect */ + }); + + describe("location without the column criterion", () => { + it("should contain position on the same start line", () => { + const loc = getTestLoc(); + loc.start.column = undefinedColumn(); + const pos = { + line: loc.start.line, + column: 1, + }; + expect(containsPosition(loc, pos)).toEqual(true); + }); + + it("should contain position on the same end line", () => { + const loc = getTestLoc(); + loc.end.column = undefinedColumn(); + const pos = { + line: loc.end.line, + column: 1, + }; + expect(containsPosition(loc, pos)).toEqual(true); + }); + }); + + describe("location and postion both without the column criterion", () => { + it("should contain position on the same start line", () => { + const loc = getTestLoc(); + loc.start.column = undefinedColumn(); + const pos = startLine(); + expect(containsPosition(loc, pos)).toEqual(true); + }); + + it("should contain position on the same end line", () => { + const loc = getTestLoc(); + loc.end.column = undefinedColumn(); + const pos = endLine(); + expect(containsPosition(loc, pos)).toEqual(true); + }); + }); +}); + +describe("containsLocation", () => { + describe("locations both with the column criteria", () => { + it("should contian location within the range", () => { + const locA = getTestLoc(); + const locB = { + start: startPos(1, 1), + end: endPos(-1, -1), + }; + expect(containsLocation(locA, locB)).toEqual(true); + }); + + it("should not contian location out of the start line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.start.line--; + expect(containsLocation(locA, locB)).toEqual(false); + }); + + it("should not contian location out of the start column", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.start.column--; + expect(containsLocation(locA, locB)).toEqual(false); + }); + + it("should not contian location out of the end line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.end.line++; + expect(containsLocation(locA, locB)).toEqual(false); + }); + + it("should not contian location out of the end column", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.end.column++; + expect(containsLocation(locA, locB)).toEqual(false); + }); + + it(`should contain location on the same start line and + within the start column`, () => { + const locA = getTestLoc(); + const locB = { + start: startPos(0, 1), + end: endPos(-1, -1), + }; + expect(containsLocation(locA, locB)).toEqual(true); + }); + + it(`should contain location on the same end line and + within the end column`, () => { + const locA = getTestLoc(); + const locB = { + start: startPos(1, 1), + end: endPos(0, -1), + }; + expect(containsLocation(locA, locB)).toEqual(true); + }); + }); + + describe("location A without the column criterion", () => { + it("should contain location on the same start line", () => { + const locA = getTestLoc(); + locA.start.column = undefinedColumn(); + const locB = getTestLoc(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + + it("should contain location on the same end line", () => { + const locA = getTestLoc(); + locA.end.column = undefinedColumn(); + const locB = getTestLoc(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + }); + + describe("location B without the column criterion", () => { + it("should contain location on the same start line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.start.column = undefinedColumn(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + + it("should contain location on the same end line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locB.end.column = undefinedColumn(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + }); + + describe("locations both without the column criteria", () => { + it("should contain location on the same start line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locA.start.column = undefinedColumn(); + locB.start.column = undefinedColumn(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + + it("should contain location on the same end line", () => { + const locA = getTestLoc(); + const locB = getTestLoc(); + locA.end.column = undefinedColumn(); + locB.end.column = undefinedColumn(); + expect(containsLocation(locA, locB)).toEqual(true); + }); + }); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/findOutOfScopeLocations.spec.js b/devtools/client/debugger/src/workers/parser/tests/findOutOfScopeLocations.spec.js new file mode 100644 index 0000000000..797443fbf9 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/findOutOfScopeLocations.spec.js @@ -0,0 +1,75 @@ +/* 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 findOutOfScopeLocations from "../findOutOfScopeLocations"; + +import { populateSource } from "./helpers"; + +function formatLines(actual) { + return actual + .map( + ({ start, end }) => + `(${start.line}, ${start.column}) -> (${end.line}, ${end.column})` + ) + .join("\n"); +} + +describe("Parser.findOutOfScopeLocations", () => { + it("should exclude non-enclosing function blocks", () => { + const source = populateSource("outOfScope"); + const actual = findOutOfScopeLocations({ + source, + line: 5, + column: 5, + }); + + expect(formatLines(actual)).toMatchSnapshot(); + }); + + it("should roll up function blocks", () => { + const source = populateSource("outOfScope"); + const actual = findOutOfScopeLocations({ + source, + line: 24, + column: 0, + }); + + expect(formatLines(actual)).toMatchSnapshot(); + }); + + it("should exclude function for locations on declaration", () => { + const source = populateSource("outOfScope"); + const actual = findOutOfScopeLocations({ + source, + line: 3, + column: 12, + }); + + expect(formatLines(actual)).toMatchSnapshot(); + }); + + it("should treat comments as out of scope", () => { + const source = populateSource("outOfScopeComment"); + const actual = findOutOfScopeLocations({ + source, + line: 3, + column: 2, + }); + + expect(actual).toEqual([ + { end: { column: 15, line: 1 }, start: { column: 0, line: 1 } }, + ]); + }); + + it("should not exclude in-scope inner locations", () => { + const source = populateSource("outOfScope"); + const actual = findOutOfScopeLocations({ + source, + line: 61, + column: 0, + }); + expect(formatLines(actual)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/allSymbols.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/allSymbols.js new file mode 100644 index 0000000000..bebda9f36a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/allSymbols.js @@ -0,0 +1,33 @@ +const TIME = 60; +let count = 0; + +function incrementCounter(counter) { + return counter++; +} + +const sum = (a, b) => a + b; + +const Obj = { + foo: 1, + doThing() { + console.log("hey"); + }, + doOtherThing: function() { + return 42; + } +}; + +Obj.property = () => {}; +Obj.otherProperty = 1; + +class Ultra { + constructor() { + this.awesome = true; + } + + beAwesome(person) { + console.log(`${person} is Awesome!`); + } +} + +this.props.history.push(`/dacs/${this.props.dac.id}`); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/async.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/async.js new file mode 100644 index 0000000000..43216be635 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/async.js @@ -0,0 +1,10 @@ +async function foo() { + return new Promise(resolve => { + setTimeout(resolve, 10); + }); +} + +async function stuff() { + await foo(1); + await foo(2); +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/call-sites.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/call-sites.js new file mode 100644 index 0000000000..aa73700d93 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/call-sites.js @@ -0,0 +1,4 @@ +aaa(bbb(), ccc()); +dddd() + .eee() + .ffff(); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/callExpressions.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/callExpressions.js new file mode 100644 index 0000000000..2771dede6a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/callExpressions.js @@ -0,0 +1,7 @@ +dispatch({ d }); +function evaluate(script, { frameId } = {frameId: 3}, {c} = {c: 2}) {} + +a(b(c())); + +a.b().c(); +a.b.c.d(); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/calls.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/calls.js new file mode 100644 index 0000000000..f05d445db7 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/calls.js @@ -0,0 +1,21 @@ +foo(1, '2', bar()) + +foo() + .bar() + .bazz() + +console.log('yo') + +foo( + 1, + bar() +) + +var a = 3; + +// set a step point at the first call expression in step expressions +var x = { a: a(), b: b(), c: c() }; +var b = [ foo() ]; +[ a(), b(), c() ]; +(1, a(), b()); +x(1, a(), b()); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/class.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/class.js new file mode 100644 index 0000000000..59d612ebfe --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/class.js @@ -0,0 +1,28 @@ +class Test { + publicProperty; + #privateProperty = "default"; + static myStatic = "static"; + static hello() { + return "Hello " + this.myStatic + } + static { + const x = this.myStatic; + } + + constructor() { + this.publicProperty = "foo"; + this.#privateProperty = "bar"; + } + + bar(a) { + console.log("bar", a); + } + + baz = b => { + return b * 2; + }; +} + +class Test2 {} + +let expressiveClass = class {}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/component.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/component.js new file mode 100644 index 0000000000..38d9b00096 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/component.js @@ -0,0 +1,84 @@ +/* + * class + */ + +class Punny extends Component { + constructor(props) { + super(); + this.onClick = this.onClick.bind(this); + } + + componentDidMount() {} + + onClick() {} + + renderMe() { + return <div onClick={this.onClick} />; + } + + render() {} +} + +/* + * CALL EXPRESSIONS - createClass, extend + */ + +const TodoView = Backbone.View.extend({ + tagName: "li", + + render: function() { + console.log("yo"); + } +}); + +const TodoClass = createClass({ + tagName: "li", + + render: function() { + console.log("yo"); + } +}); + +TodoClass = createClass({ + tagName: "li", + + render: function() { + console.log("yo"); + } +}); + +app.TodoClass = createClass({ + tagName: "li", + + render: function() { + console.log("yo"); + } +}); + +/* + * PROTOTYPE + */ + +function Button() { + if (!(this instanceof Button)) return new Button(); + this.color = null; + Nanocomponent.call(this); +} + +Button.prototype = Object.create(Nanocomponent.prototype); + +var x = function() {}; + +Button.prototype.createElement = function(color) { + this.color = color; + return html` + <button style="background-color: ${color}"> + Click Me + </button> + `; +}; + +// Implement conditional rendering +Button.prototype.update = function(newColor) { + return newColor !== this.color; +}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/computed-props.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/computed-props.js new file mode 100644 index 0000000000..4c0f182f33 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/computed-props.js @@ -0,0 +1,8 @@ +(function(key) { + let obj = { + b: 5 + }; + obj[key] = 0; + const c = obj.b; + return obj; +})("a"); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/control-flow.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/control-flow.js new file mode 100644 index 0000000000..b9e859ff74 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/control-flow.js @@ -0,0 +1,39 @@ + +if (x) { + foo(); +} +else if (y) { + foo(); +} +else { + foo(); +} + +for (var i=0; i< 5; i++ ) { + foo(); +} + +while (x) { + foo(); +} + +switch (c) { + case a: + console.log('hi') +} + +var a = 3; + +for (const val of [1, 2]) { + console.log("pause again", val); +} + +for (const val of vals) { + console.log("pause again", val); +} + +try { +} catch (e) { +} + +with (e) {} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/decorators.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/decorators.js new file mode 100644 index 0000000000..22c0a1398d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/decorators.js @@ -0,0 +1,2 @@ +@annotation +class MyClass { } diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/destructuring.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/destructuring.js new file mode 100644 index 0000000000..52686e6573 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/destructuring.js @@ -0,0 +1,16 @@ +const { b, resty } = compute(stuff); +const { first: f, last: l } = obj; + +const [a, ...rest] = compute(stuff); +const [x] = ["a"]; + +for (const [index, element] of arr.entries()) { + console.log(index, element); +} + +const { a: aa = 10, b: bb = 5 } = { a: 3 }; +const { temp: [{ foo: foooo }] } = obj; + +let { [key]: foo } = { z: "bar" }; + +let [, prefName] = prefsBlueprint[accessorName]; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/es6.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/es6.js new file mode 100644 index 0000000000..90b53141f4 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/es6.js @@ -0,0 +1 @@ +dispatch({ ...action, [PROMISE]: promise }); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/expression.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/expression.js new file mode 100644 index 0000000000..fa80b68add --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/expression.js @@ -0,0 +1,25 @@ +const obj = { a: { b: 2 } }; // e.g. obj.a.b +const foo = obj2.c.secondProperty; // e.g. foo.obj2.c.secondProperty + +// computed properties +const com = { [a]: { b: "c", [d]: "e" }, [b]: 3 }; // e.g. com[a].b +const firstAuthor = collection.books[1].author; +const firstActionDirector = collection.genres["sci-fi"].movies[0].director; + +app.TodoView = Backbone.extend({ + render: function() {} +}); + +// assignments +obj.foo = { a: { b: "c" }, b: 3 }; // e.g. obj.foo.a.b +com = { a: { b: "c" }, b: 3 }; // e.g. com.a.b + +// arrays +const res = [{ a: 2 }, { b: 3 }]; // e.g. res[1].b +const res2 = { a: [{ b: 2 }] }; // e.g. res.a[0].b +const res3 = { a: [{ b: 2 }], b: [{ c: 3 }] }; // e.g. res.a[0].b +const res4 = [[{ a: 3 }], [{ b: a.b.c.v.d }]]; // e.g. res[1][0].b + +function params({ a, b }) {} // e.g. a +var pars = function({ a, b }) {}; +const evil = obj2.doEvil().c.secondProperty; // e.g. obj2.doEvil or "" diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/flow.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/flow.js new file mode 100644 index 0000000000..1135bd62a4 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/flow.js @@ -0,0 +1,5 @@ +class App extends Component { + renderHello(name: string, action: ReduxAction, { todos }: Props) { + return `howdy ${name}`; + } +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1FalsePositive.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1FalsePositive.js new file mode 100644 index 0000000000..5220f26c8c --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1FalsePositive.js @@ -0,0 +1,11 @@ +const frameworkStars = { + angular: 40779, + react: 111576, + vue: 114358, +}; + +const container = new Container(); +container.module("sum", (...args) => args.reduce((s, c) => s + c, 0)); + +container.get("sum", + (sum) => sum(Object.values(frameworkStars))); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1Module.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1Module.js new file mode 100644 index 0000000000..f0e8c381ef --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/angular1Module.js @@ -0,0 +1,4 @@ +angular.module('something', ['ngRoute', 'ngResource']) + .config(function ($routeProvider) { + 'use strict'; + }); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/plainJavascript.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/plainJavascript.js new file mode 100644 index 0000000000..41609f8715 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/plainJavascript.js @@ -0,0 +1,8 @@ +"use strict" + +const a = 2; + +function test(a) { + return a; +} + diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponent.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponent.js new file mode 100644 index 0000000000..37f3ac49e5 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponent.js @@ -0,0 +1,3 @@ +import React, { Component } from "react"; + +class PrimaryPanes extends Component {} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponentEs5.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponentEs5.js new file mode 100644 index 0000000000..a4370b5369 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactComponentEs5.js @@ -0,0 +1,3 @@ +var React = require("react"); + +class PrimaryPanes extends React.Component {} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactLibrary.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactLibrary.js new file mode 100644 index 0000000000..9ca048e77f --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reactLibrary.js @@ -0,0 +1,19 @@ +/** + * Base class helpers for the updating state of a component. + */ +function Component(props, context, updater) { + this.props = props; + this.context = context; + // If a component has string refs, we will assign a different object later. + this.refs = emptyObject; + // We initialize the default updater but the real one gets injected by the + // renderer. + this.updater = updater || ReactNoopUpdateQueue; +} + +Component.prototype.isReactComponent = {}; +Component.prototype.setState = function (partialState, callback) { + !(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null) + ? invariant(false, 'setState(...): takes an object of state variables to update or a function which returns an object of state variables.') : void 0; + this.updater.enqueueSetState(this, partialState, callback, 'setState'); +}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reduxLibrary.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reduxLibrary.js new file mode 100644 index 0000000000..b86c8fb0cf --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/reduxLibrary.js @@ -0,0 +1,39 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (factory((global.Redux = {}))); + }(this, (function (exports) { 'use strict'; + + function symbolObservablePonyfill(root) { + var result; + var Symbol = root.Symbol; + + if (typeof Symbol === 'function') { + if (Symbol.observable) { + result = Symbol.observable; + } else { + result = Symbol('observable'); + Symbol.observable = result; + } + } else { + result = '@@observable'; + } + + return result; + } + + /* global window */ + + var root; + + if (typeof self !== 'undefined') { + root = self; + } else if (typeof window !== 'undefined') { + root = window; + } else if (typeof global !== 'undefined') { + root = global; + } else if (typeof module !== 'undefined') { + root = module; + } else { + root = Function('return this')(); + }
\ No newline at end of file diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileComponent.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileComponent.js new file mode 100644 index 0000000000..d41e55b72b --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileComponent.js @@ -0,0 +1,3 @@ +Vue.component('test-item', { + template: '<li>This is a test item</li>' +})
\ No newline at end of file diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileDeclarative.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileDeclarative.js new file mode 100644 index 0000000000..844fce1451 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/frameworks/vueFileDeclarative.js @@ -0,0 +1,6 @@ +var app = new Vue({ + el: '#app', + data: { + message: 'Hello Vue!' + } +})
\ No newline at end of file diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/func.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/func.js new file mode 100644 index 0000000000..bb0d4c1b05 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/func.js @@ -0,0 +1,50 @@ +function square(n) { + return n * n; +} + +export function exFoo() { + return "yay"; +} + +async function slowFoo() { + return "meh"; +} + +export async function exSlowFoo() { + return "yay in a bit"; +} + +function ret() { + return foo(); +} + +child = function() {}; + +(function() { + 2; +})(); + +const obj = { + foo: function name() { + 2 + 2; + }, + + bar() { + 2 + 2; + } +}; + +export default function root() { +} + +function test(a1, a2 = 45, { a3, a4, a5: { a6: a7 } = {} } = {}) { + console.log("pause next here"); +} + +() => (x = 4); + +function ret2() { + return ( + foo() + ); +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/functionNames.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/functionNames.js new file mode 100644 index 0000000000..2ae38fc548 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/functionNames.js @@ -0,0 +1,50 @@ +/* eslint-disable */ + +({ + foo: function() {}, + "foo": function() {}, + 42: function() {}, + + foo() {}, + "foo"() {}, + 42() {}, +}); + +foo = function() {}; +obj.foo = function() {}; + +var foo = function(){}; +var [foo = function(){}] = []; +var {foo = function(){}} = {}; + +[foo = function(){}] = []; +({foo = function(){}} = {}); +({bar: foo = function(){}} = {}); + +function fn([foo = function(){}]){} +function f2({foo = function(){}} = {}){} +function f3({bar: foo = function(){}} = {}){} + +class Cls { + foo = function() {}; + "foo" = function() {}; + 42 = function() {}; + + foo() {} + "foo"() {} + 42() {} +} + +(function(){}); + +export default function (){} + +const defaultObj = {a: 1}; +const defaultArr = ['smthng']; +function a(first, second){} +function b(first = 'bla', second){} +function c(first = {}, second){} +function d(first = [], second){} +function e(first = defaultObj, second){} +function f(first = defaultArr, second){} +function g(first = null, second){} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/generators.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/generators.js new file mode 100644 index 0000000000..7c71838827 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/generators.js @@ -0,0 +1,4 @@ +function* foo() { + yield 1; + yield 2; +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/jsx.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/jsx.js new file mode 100644 index 0000000000..c781f7666f --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/jsx.js @@ -0,0 +1,5 @@ +const jsxElement = <h1> Hi ! I'm here ! </h1>; + +<div id="3" res={foo()}> + <Item>{foo()}</Item> +</div> diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/math.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/math.js new file mode 100644 index 0000000000..508d28ca99 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/math.js @@ -0,0 +1,15 @@ +function math(n) { + function square(n) { + // inline comment + n * n; + } + + // document some lines + const two = square(2); + + const four = squaare(4); + return two * four; +} + +var child = function() {}; +child2 = function() {}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/modules.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/modules.js new file mode 100644 index 0000000000..8ba351497e --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/modules.js @@ -0,0 +1,10 @@ +import {x} from "y" +import z from "y"; + +export class AppComponent { + title = 'app' +} + +export default class AppComponent { + title = 'app' +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/object-expressions.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/object-expressions.js new file mode 100644 index 0000000000..b7f4806e04 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/object-expressions.js @@ -0,0 +1,6 @@ +const y = ({ params }); +const foo = ({ b }); +const bar = { x: 3, y: "434", z: true, a: null, s: c * 2, d : getValue(2) } +const params = frameId ? { frameActor: frameId } : {} + +const collection = {genres: {"sci-fi": {movies: [{director: "yo"}]}}} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/optional-chaining.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/optional-chaining.js new file mode 100644 index 0000000000..757335f78b --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/optional-chaining.js @@ -0,0 +1,3 @@ +const obj = {}; +obj?.a; +obj?.a?.b ?? []; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScope.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScope.js new file mode 100644 index 0000000000..30218f546b --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScope.js @@ -0,0 +1,62 @@ +// Program Scope + +function outer() { + function inner() { + const x = 1; + } + + const arrow = () => { + const x = 1; + }; + + const declaration = function() { + const x = 1; + }; + + assignment = (function() { + const x = 1; + })(); + + const iifeDeclaration = (function() { + const x = 1; + })(); + + return function() { + const x = 1; + }; +} + +function exclude() { + function another() { + const x = 1; + } +} + +const globalArrow = () => { + const x = 1; +}; + +const globalDeclaration = function() { + const x = 1; +}; + +globalAssignment = (function() { + const x = 1; +})(); + +const globalIifeDeclaration = (function() { + const x = 1; +})(); + +function parentFunc() { + let MAX = 3; + let nums = [0, 1, 2, 3]; + let x = 1; + let y = nums.find(function(n) { + return n == x; + }); + function innerFunc(a) { + return Math.max(a, MAX); + } + return innerFunc(y); +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScopeComment.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScopeComment.js new file mode 100644 index 0000000000..bc02f94671 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/outOfScopeComment.js @@ -0,0 +1,4 @@ +// Some comment +(function() { + const x = 1; +})(); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/parseScriptTags.html b/devtools/client/debugger/src/workers/parser/tests/fixtures/parseScriptTags.html new file mode 100644 index 0000000000..6283f0ee98 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/parseScriptTags.html @@ -0,0 +1,42 @@ +<html> +<head> + <script type="text/javascript"> + var globalObject = { + first: "name", + last: "words" + }; + function sayHello (name) { + return `Hello, ${name}!`; + } + </script> + <style> + BODY { + font-size: 48px; + color: rebeccapurple; + } + </style> +</head> +<body> + <h1>Testing Script Tags in HTML</h1> + <script> + const capitalize = name => { + return name[0].toUpperCase() + name.substring(1) + }; + const greetAll = ["my friend", "buddy", "world"] + .map(capitalize) + .map(sayHello) + .join("\n"); + + globalObject.greetings = greetAll; + </script> + <p> + Some arbitrary intermediate content to affect the offsets of the scripts + </p> + <script> + (function iife() { + const greeting = sayHello("Ryan"); + console.log(greeting); + })(); + </script> +</body> +</html> diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/private-fields.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/private-fields.js new file mode 100644 index 0000000000..ca9abc8f1d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/private-fields.js @@ -0,0 +1,24 @@ +class MyClass { + constructor(secret, ...rest) { + const self = this; + this.#secret = secret; + self.#restParams = rest; + } + + #secret; + #restParams; + #salt = "bloup"; + creationDate = new Date(); + + #getSalt() { + return this.#salt; + } + + debug() { + const self = this; + const creationDate = this.creationDate; + const secret = this.#secret; + const salt = self.#getSalt(); + return `${creationDate}|${salt}|${secret}`; + } +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/proto.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/proto.js new file mode 100644 index 0000000000..38c3b63ac7 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/proto.js @@ -0,0 +1,14 @@ +const foo = function() {}; + +const bar = () => {}; + +const TodoView = Backbone.View.extend({ + tagName: "li", + initialize: function() {}, + doThing(b) { + console.log("hi", b); + }, + render: function() { + return this; + } +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/resolveToken.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/resolveToken.js new file mode 100644 index 0000000000..4660f0f568 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/resolveToken.js @@ -0,0 +1,40 @@ +const a = 1; +let b = 0; + +function getA() { + return a; +} + +function setB(newB) { + b = newB; +} + +const plusAB = (function(x, y) { + const obj = { x, y }; + function insideClosure(alpha, beta) { + return alpha + beta + obj.x + obj.y; + } + + return insideClosure; +})(a, b); + +function withMultipleScopes() { + var outer = 1; + function innerScope() { + var inner = outer + 1; + return inner; + } + + const fromIIFE = (function(toIIFE) { + return innerScope() + toIIFE; + })(1); + + { + // random block + let x = outer + fromIIFE; + if (x) { + const y = x * x; + console.log(y); + } + } +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/arrow-function.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/arrow-function.js new file mode 100644 index 0000000000..4ec72fd450 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/arrow-function.js @@ -0,0 +1,11 @@ +export {}; + +let outer = (p1) => { + console.log(this); + + (function() { + var inner = (p2) => { + console.log(this); + }; + })(); +}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/binding-types.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/binding-types.js new file mode 100644 index 0000000000..8c9cc05d1d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/binding-types.js @@ -0,0 +1,24 @@ +import def from ""; +import { named } from ""; +import { thing as otherNamed } from ""; +import * as namespace from ""; + +function fn() {} +class cls { + method(){ + + } +} + +var aVar; +let aLet; +const aConst = ""; + +(function inner() { + this; + arguments; +}); + +{ + function blockFn(){} +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/block-statement.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/block-statement.js new file mode 100644 index 0000000000..5e64d1180f --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/block-statement.js @@ -0,0 +1,13 @@ +export {}; + +let first; + +{ + var second; + function third() {} + class Fourth {} + let fifth; + const sixth = 6; +} + +var seventh; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-declaration.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-declaration.js new file mode 100644 index 0000000000..1c2c74dcbc --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-declaration.js @@ -0,0 +1,14 @@ +export {}; + +class Outer { + method() { + class Inner { + m() { + console.log(this); + } + } + } +} + +@decorator +class Second {} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-expression.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-expression.js new file mode 100644 index 0000000000..16b6841d71 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-expression.js @@ -0,0 +1,11 @@ +export {}; + +var Outer = class Outer { + method() { + var Inner = class { + m() { + console.log(this); + } + }; + } +}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-property.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-property.js new file mode 100644 index 0000000000..a80cad3a87 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/class-property.js @@ -0,0 +1,10 @@ +export {}; + +class Foo { + prop = this.init(); + + other = do { + var one; + let two; + }; +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/complex-nesting.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/complex-nesting.js new file mode 100644 index 0000000000..40763f556f --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/complex-nesting.js @@ -0,0 +1,29 @@ +function named(module, exports, __webpack_require__) { + +"use strict"; + + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = root; +function root() { + function fn(arg) { + var _this = this, + _arguments = arguments; + + console.log(this, arguments); + console.log("pause here", fn, root); + + var arrow = function arrow(argArrow) { + console.log(_this, _arguments); + console.log("pause here", fn, root); + }; + arrow("arrow-arg"); + } + + fn.call("this-value", "arg-value"); +} +module.exports = exports["default"]; + +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/expressions.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/expressions.js new file mode 100644 index 0000000000..6698acd8e8 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/expressions.js @@ -0,0 +1,6 @@ +const foo = {}; + +foo.bar().baz; +(0, foo.bar)().baz; +Object(foo.bar)().baz; +__webpack_require__.i(foo.bar)().baz; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/flowtype-bindings.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/flowtype-bindings.js new file mode 100644 index 0000000000..385797c044 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/flowtype-bindings.js @@ -0,0 +1,11 @@ +import type { One, Two, Three } from "./src/mod"; + +type Other = { + root: typeof root, +}; + +const aConst = (window: Array<string>); + +export default function root() { + +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/fn-body-lex-and-nonlex.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/fn-body-lex-and-nonlex.js new file mode 100644 index 0000000000..a7f6d7670a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/fn-body-lex-and-nonlex.js @@ -0,0 +1,23 @@ +function fn() { + var nonlex; + let lex; + +} + +function fn2() { + function nonlex(){} + let lex; + +} + +function fn3() { + var nonlex; + class Thing {} + +} + +function fn4() { + function nonlex(){} + class Thing {} + +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/for-loops.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/for-loops.js new file mode 100644 index 0000000000..747eeeae7d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/for-loops.js @@ -0,0 +1,13 @@ +export {}; + +for (var one;;) {} +for (let two;;) {} +for (const three = 3;;) {} + +for (var four in {}) {} +for (let five in {}) {} +for (const six in {}) {} + +for (var seven of {}) {} +for (let eight of {}) {} +for (const nine of {}) {} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-declaration.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-declaration.js new file mode 100644 index 0000000000..759374b437 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-declaration.js @@ -0,0 +1,11 @@ +export {}; + +function outer(p1) {} + +{ + function middle(p2) { + function inner(p3) {} + + console.log(this); + } +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-expression.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-expression.js new file mode 100644 index 0000000000..436413533e --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/function-expression.js @@ -0,0 +1,7 @@ +export {}; + +let fn = function(p1) {}; + +let fn2 = function withName(p2) { + console.log(this); +}; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/jsx-component.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/jsx-component.js new file mode 100644 index 0000000000..ffe1c1dfce --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/jsx-component.js @@ -0,0 +1,6 @@ +import SomeComponent from ""; + +<SomeComponent attr="value" />; +<SomeComponent attr="value"></SomeComponent>; +<SomeComponent.prop attr="value" />; +<SomeComponent.prop.child attr="value" />; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/out-of-order-declarations.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/out-of-order-declarations.js new file mode 100644 index 0000000000..4f36d9fc38 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/out-of-order-declarations.js @@ -0,0 +1,21 @@ +var val; + +export default function root() { + var val; + + var fn; + + this; + + function callback() { + console.log(val, fn, callback, root, this); + + var val; + + function fn(){}; + } + + callback(); +} + +import aDefault from "./src/mod"; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/pattern-declarations.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/pattern-declarations.js new file mode 100644 index 0000000000..6810ef4614 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/pattern-declarations.js @@ -0,0 +1,2 @@ +var { prop: one } = {}; +var [ two ] = []; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/simple-module.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/simple-module.js new file mode 100644 index 0000000000..b25cc7a863 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/simple-module.js @@ -0,0 +1,11 @@ +import foo from "foo"; + +console.log(foo); + +var one = 1; +let two = 2; +const three = 3; + +function fn() {} + +this; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/switch-statement.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/switch-statement.js new file mode 100644 index 0000000000..7163c3811d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/switch-statement.js @@ -0,0 +1,22 @@ +export {}; + +switch (foo) { + case "zero": + var zero; + case "one": + let one; + case "two": + let two; + case "three": { + let three; + } +} + +switch (foo) { + case "": + function two(){} +} +switch (foo) { + case "": + class three {} +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/try-catch.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/try-catch.js new file mode 100644 index 0000000000..8ccf4db592 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/try-catch.js @@ -0,0 +1,9 @@ +export {}; + +try { + var first; + let second; +} catch (err) { + var third; + let fourth; +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/ts-sample.ts b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/ts-sample.ts new file mode 100644 index 0000000000..e23ff83754 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/ts-sample.ts @@ -0,0 +1,41 @@ + +// TSEnumDeclaration +enum Color { + // TSEnumMember + Red, + Green, + Blue, +} + +class Example<T> { + // TSParameterProperty + constructor(public foo) { + + } + + method(): never { + throw new Error(); + } +} + +// TSTypeAssertion +var foo = <any>window; + +// TSAsExpression +(window as any); + +// TSNonNullExpression +(window!); + +// TSModuleDeclaration +namespace TheSpace { + function fn() { + + } +} + +// TSImportEqualsDeclaration +import ImportedClass = require("mod"); + +// TSExportAssignment +export = Example; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/tsx-sample.tsx b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/tsx-sample.tsx new file mode 100644 index 0000000000..9e2b9faf8b --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/tsx-sample.tsx @@ -0,0 +1,41 @@ + +// TSEnumDeclaration +enum Color { + // TSEnumMember + Red, + Green, + Blue, +} + +class Example<T> { + // TSParameterProperty + constructor(public foo) { + + } + + method(): never { + throw new Error(); + } +} + +// JSXElement +var foo = <any>window</any>; + +// TSAsExpression +(window as any); + +// TSNonNullExpression +(window!); + +// TSModuleDeclaration +namespace TheSpace { + function fn() { + + } +} + +// TSImportEqualsDeclaration +import ImportedClass = require("mod"); + +// TSExportAssignment +export = Example; diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/vue-sample.vue b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/vue-sample.vue new file mode 100644 index 0000000000..0fceee99d1 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/scopes/vue-sample.vue @@ -0,0 +1,26 @@ +<template> + <div class="hello"> + <h1>{{ msg }}</h1> + </div> +</template> + +<script> +var moduleVar = "data"; + +export default { + name: 'HelloWorld', + data () { + var fnVar = 4; + + return { + msg: 'Welcome to Your Vue.js App' + }; + } +}; +</script> + +<style scoped> +a { + color: red; +} +</style> diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/statements.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/statements.js new file mode 100644 index 0000000000..f2c113570e --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/statements.js @@ -0,0 +1,40 @@ +debugger; debugger; +console.log("a"); console.log("a"); + +// assignments with valid pause locations +this.x = 3; +var a = 4; +var d = [foo()] +var f = 3, e = 4; +var g = [], h = {}; + +// assignments with invalid pause locations +var b = foo(); +c = foo(); + + +const arr = [ + '1', + 2, + foo() +] + +const obj = { + a: '1', + b: 2, + c: foo(), +} + +foo( + 1, + foo( + 1 + ), + 3 +) + +throw new Error("3"); +3; + +while (i < 6) { break } +while (i < 6) { continue;} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/thisExpression.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/thisExpression.js new file mode 100644 index 0000000000..dd398db426 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/thisExpression.js @@ -0,0 +1,11 @@ +class Test { + constructor() { + this.foo = { + a: "foobar" + }; + } + + bar() { + console.log(this.foo.a); + } +} diff --git a/devtools/client/debugger/src/workers/parser/tests/fixtures/var.js b/devtools/client/debugger/src/workers/parser/tests/fixtures/var.js new file mode 100644 index 0000000000..509ad368e8 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/fixtures/var.js @@ -0,0 +1,21 @@ +var foo = 1; +let bar = 2; +const baz = 3; +const a = 4, + b = 5; +a = 5; + +var { foo: { baw } } = {} +var {bap} = {} +var {ll = 3} = {} + + +var [first] = [1] + +var { a: _a } = 3 + +var [oh, {my: god}] = [{},{}] + +var [[oj], [{oy, vey: _vey, mitzvot: _mitz = 4}]] = [{},{}] + +var [one, ...stuff] = [] diff --git a/devtools/client/debugger/src/workers/parser/tests/framework.spec.js b/devtools/client/debugger/src/workers/parser/tests/framework.spec.js new file mode 100644 index 0000000000..d41f45b71c --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/framework.spec.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSymbols } from "../getSymbols"; +import { populateOriginalSource } from "./helpers"; +import cases from "jest-in-case"; + +cases( + "Parser.getFramework", + ({ name, file, value }) => { + const source = populateOriginalSource("frameworks/plainJavascript"); + const symbols = getSymbols(source.id); + expect(symbols.framework).toBeNull(); + }, + [ + { + name: "is undefined when no framework", + file: "frameworks/plainJavascript", + value: null, + }, + { + name: "does not get confused with angular (#6833)", + file: "frameworks/angular1FalsePositive", + value: null, + }, + { + name: "recognizes ES6 React component", + file: "frameworks/reactComponent", + value: "React", + }, + { + name: "recognizes ES5 React component", + file: "frameworks/reactComponentEs5", + value: "React", + }, + { + name: "recognizes Angular 1 module", + file: "frameworks/angular1Module", + value: "Angular", + }, + { + name: "recognizes declarative Vue file", + file: "frameworks/vueFileDeclarative", + value: "Vue", + }, + { + name: "recognizes component Vue file", + file: "frameworks/vueFileComponent", + value: "Vue", + }, + { + name: "recognizes the react library file", + file: "framework/reactLibrary", + value: "React", + }, + { + name: "recognizes the redux library file", + file: "framework/reduxLibrary", + value: "React", + }, + ] +); diff --git a/devtools/client/debugger/src/workers/parser/tests/getScopes.spec.js b/devtools/client/debugger/src/workers/parser/tests/getScopes.spec.js new file mode 100644 index 0000000000..fa19197a92 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/getScopes.spec.js @@ -0,0 +1,227 @@ +/* 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 getScopes from "../getScopes"; +import { populateOriginalSource } from "./helpers"; +import cases from "jest-in-case"; + +cases( + "Parser.getScopes", + ({ name, file, type, locations }) => { + const source = populateOriginalSource(file, type); + + locations.forEach(([line, column]) => { + const scopes = getScopes({ + sourceId: source.id, + line, + column, + }); + + expect(scopes).toMatchSnapshot( + `getScopes ${name} at line ${line} column ${column}` + ); + }); + }, + [ + { + name: "finds scope bindings in fn body with both lex and non-lex items", + file: "scopes/fn-body-lex-and-nonlex", + locations: [ + [4, 0], + [10, 0], + [16, 0], + [22, 0], + ], + }, + { + name: "finds scope bindings in a vue file", + file: "scopes/vue-sample", + type: "vue", + locations: [[14, 0]], + }, + { + name: "finds scope bindings in a typescript file", + file: "scopes/ts-sample", + type: "ts", + locations: [ + [9, 0], + [13, 4], + [17, 0], + [33, 0], + ], + }, + { + name: "finds scope bindings in a typescript-jsx file", + file: "scopes/tsx-sample", + type: "tsx", + locations: [ + [9, 0], + [13, 4], + [17, 0], + [33, 0], + ], + }, + { + name: "finds scope bindings in a module", + file: "scopes/simple-module", + locations: [[7, 0]], + }, + { + name: "finds scope bindings in a JSX element", + file: "scopes/jsx-component", + locations: [[2, 0]], + }, + { + name: "finds scope bindings for complex binding nesting", + file: "scopes/complex-nesting", + locations: [ + [16, 4], + [20, 6], + ], + }, + { + name: "finds scope bindings for function declarations", + file: "scopes/function-declaration", + locations: [ + [2, 0], + [3, 20], + [5, 1], + [9, 0], + ], + }, + { + name: "finds scope bindings for function expressions", + file: "scopes/function-expression", + locations: [ + [2, 0], + [3, 23], + [6, 0], + ], + }, + { + name: "finds scope bindings for arrow functions", + file: "scopes/arrow-function", + locations: [ + [2, 0], + [4, 0], + [7, 0], + [8, 0], + ], + }, + { + name: "finds scope bindings for class declarations", + file: "scopes/class-declaration", + locations: [ + [2, 0], + [5, 0], + [7, 0], + ], + }, + { + name: "finds scope bindings for class expressions", + file: "scopes/class-expression", + locations: [ + [2, 0], + [5, 0], + [7, 0], + ], + }, + { + name: "finds scope bindings for for loops", + file: "scopes/for-loops", + locations: [ + [2, 0], + [3, 17], + [4, 17], + [5, 25], + [7, 22], + [8, 22], + [9, 23], + [11, 23], + [12, 23], + [13, 24], + ], + }, + { + name: "finds scope bindings for try..catch", + file: "scopes/try-catch", + locations: [ + [2, 0], + [4, 0], + [7, 0], + ], + }, + { + name: "finds scope bindings for out of order declarations", + file: "scopes/out-of-order-declarations", + locations: [ + [2, 0], + [5, 0], + [11, 0], + [14, 0], + [17, 0], + ], + }, + { + name: "finds scope bindings for block statements", + file: "scopes/block-statement", + locations: [ + [2, 0], + [6, 0], + ], + }, + { + name: "finds scope bindings for class properties", + file: "scopes/class-property", + locations: [ + [2, 0], + [4, 16], + [6, 12], + [7, 0], + ], + }, + { + name: "finds scope bindings and exclude Flowtype", + file: "scopes/flowtype-bindings", + locations: [ + [8, 0], + [10, 0], + ], + }, + { + name: "finds scope bindings for declarations with patterns", + file: "scopes/pattern-declarations", + locations: [[1, 0]], + }, + { + name: "finds scope bindings for switch statements", + file: "scopes/switch-statement", + locations: [ + [2, 0], + [5, 0], + [7, 0], + [9, 0], + [11, 0], + [17, 0], + [21, 0], + ], + }, + { + name: "finds scope bindings with proper types", + file: "scopes/binding-types", + locations: [ + [5, 0], + [9, 0], + [18, 0], + [23, 0], + ], + }, + { + name: "finds scope bindings with expression metadata", + file: "scopes/expressions", + locations: [[2, 0]], + }, + ] +); diff --git a/devtools/client/debugger/src/workers/parser/tests/getSymbols.spec.js b/devtools/client/debugger/src/workers/parser/tests/getSymbols.spec.js new file mode 100644 index 0000000000..6a7cccce49 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/getSymbols.spec.js @@ -0,0 +1,49 @@ +/* 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 { formatSymbols } from "../utils/formatSymbols"; +import { populateSource, populateOriginalSource } from "./helpers"; +import cases from "jest-in-case"; + +cases( + "Parser.getSymbols", + ({ name, file, original, type }) => { + const source = original + ? populateOriginalSource(file, type) + : populateSource(file, type); + + expect(formatSymbols(source.id)).toMatchSnapshot(); + }, + [ + { name: "es6", file: "es6", original: true }, + { name: "func", file: "func", original: true }, + { name: "function names", file: "functionNames", original: true }, + { name: "math", file: "math" }, + { name: "proto", file: "proto" }, + { name: "class", file: "class", original: true }, + { name: "var", file: "var" }, + { name: "expression", file: "expression" }, + { name: "allSymbols", file: "allSymbols" }, + { name: "call sites", file: "call-sites" }, + { name: "call expression", file: "callExpressions" }, + { name: "object expressions", file: "object-expressions" }, + { name: "optional chaining", file: "optional-chaining" }, + { name: "private fields", file: "private-fields" }, + { + name: "finds symbols in an html file", + file: "parseScriptTags", + type: "html", + }, + { name: "component", file: "component", original: true }, + { + name: "react component", + file: "frameworks/reactComponent", + original: true, + }, + { name: "flow", file: "flow", original: true }, + { name: "jsx", file: "jsx", original: true }, + { name: "destruct", file: "destructuring" }, + ] +); diff --git a/devtools/client/debugger/src/workers/parser/tests/helpers/index.js b/devtools/client/debugger/src/workers/parser/tests/helpers/index.js new file mode 100644 index 0000000000..47c358ae66 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/helpers/index.js @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 fs from "fs"; +import path from "path"; + +import { makeMockSourceAndContent } from "../../../../utils/test-mockup"; +import { setSource } from "../../sources"; +import * as asyncValue from "../../../../utils/async-value"; + +export function getFixture(name, type = "js") { + return fs.readFileSync( + path.join(__dirname, `../fixtures/${name}.${type}`), + "utf8" + ); +} + +function getSourceContent(name, type = "js") { + const text = getFixture(name, type); + let contentType = "text/javascript"; + if (type === "html") { + contentType = "text/html"; + } else if (type === "vue") { + contentType = "text/vue"; + } else if (type === "ts") { + contentType = "text/typescript"; + } else if (type === "tsx") { + contentType = "text/typescript-jsx"; + } + + return { + type: "text", + value: text, + contentType, + }; +} + +export function getSource(name, type) { + const { value: text, contentType } = getSourceContent(name, type); + + return makeMockSourceAndContent(undefined, name, contentType, text); +} + +export function populateSource(name, type) { + const { content, ...source } = getSource(name, type); + setSource({ + id: source.id, + text: content.value, + contentType: content.contentType, + isWasm: false, + }); + return { + ...source, + content: asyncValue.fulfilled(content), + }; +} + +export function getOriginalSource(name, type) { + return getOriginalSourceWithContent(name, type); +} + +export function getOriginalSourceWithContent(name, type) { + const { value: text, contentType } = getSourceContent(name, type); + + return makeMockSourceAndContent( + undefined, + `${name}/originalSource-1`, + contentType, + text + ); +} + +export function populateOriginalSource(name, type) { + const { content, ...source } = getOriginalSourceWithContent(name, type); + setSource({ + id: source.id, + text: content.value, + contentType: content.contentType, + isWasm: false, + }); + return { + ...source, + content: asyncValue.fulfilled(content), + }; +} diff --git a/devtools/client/debugger/src/workers/parser/tests/mapBindings.spec.js b/devtools/client/debugger/src/workers/parser/tests/mapBindings.spec.js new file mode 100644 index 0000000000..8c23ab5873 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/mapBindings.spec.js @@ -0,0 +1,161 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 mapExpressionBindings from "../mapBindings"; +import { parseConsoleScript } from "../utils/ast"; +import cases from "jest-in-case"; + +const prettier = require("prettier"); + +function format(code) { + return prettier.format(code, { semi: false, parser: "babel" }); +} + +function excludedTest({ name, expression, bindings = [] }) { + const safeExpression = mapExpressionBindings( + expression, + parseConsoleScript(expression), + bindings + ); + expect(format(safeExpression)).toEqual(format(expression)); +} + +function includedTest({ name, expression, newExpression, bindings }) { + const safeExpression = mapExpressionBindings( + expression, + parseConsoleScript(expression), + bindings + ); + expect(format(safeExpression)).toEqual(format(newExpression)); +} + +describe("mapExpressionBindings", () => { + cases("included cases", includedTest, [ + { + name: "single declaration", + expression: "const a = 2; let b = 3; var c = 4;", + newExpression: "self.a = 2; self.b = 3; self.c = 4;", + }, + { + name: "multiple declarations", + expression: "const a = 2, b = 3", + newExpression: "self.a = 2; self.b = 3", + }, + { + name: "declaration with separate assignment", + expression: "let a; a = 2;", + newExpression: "self.a = void 0; self.a = 2;", + }, + { + name: "multiple declarations with no assignment", + expression: "let a = 2, b;", + newExpression: "self.a = 2; self.b = void 0;", + }, + { + name: "local bindings become assignments", + bindings: ["a"], + expression: "var a = 2;", + newExpression: "a = 2;", + }, + { + name: "assignments", + expression: "a = 2;", + newExpression: "self.a = 2;", + }, + { + name: "assignments with +=", + expression: "a += 2;", + newExpression: "self.a += 2;", + }, + { + name: "destructuring (objects)", + expression: "const { a } = {}; ", + newExpression: "({ a: self.a } = {})", + }, + { + name: "destructuring (arrays)", + expression: " var [a, ...foo] = [];", + newExpression: "([self.a, ...self.foo] = [])", + }, + { + name: "destructuring (declarations)", + expression: "var {d,e} = {}, {f} = {}; ", + newExpression: `({ d: self.d, e: self.e } = {}); + ({ f: self.f } = {}) + `, + }, + { + name: "destructuring & declaration", + expression: "const { a } = {}; var b = 3", + newExpression: `({ a: self.a } = {}); + self.b = 3 + `, + }, + { + name: "destructuring assignment", + expression: "[a] = [3]", + newExpression: "[self.a] = [3]", + }, + { + name: "destructuring assignment (declarations)", + expression: "[a] = [3]; var b = 4", + newExpression: "[self.a] = [3];\n self.b = 4", + }, + ]); + + cases("excluded cases", excludedTest, [ + { name: "local variables", expression: "function a() { var b = 2}" }, + { name: "functions", expression: "function a() {}" }, + { name: "classes", expression: "class a {}" }, + + { name: "with", expression: "with (obj) {var a = 2;}" }, + { + name: "with & declaration", + expression: "with (obj) {var a = 2;}; ; var b = 3", + }, + { + name: "hoisting", + expression: "{ const h = 3; }", + }, + { + name: "assignments", + bindings: ["a"], + expression: "a = 2;", + }, + { + name: "identifier", + expression: "a", + }, + ]); + + cases("cases that we should map in the future", excludedTest, [ + { name: "blocks (IF)", expression: "if (true) { var a = 3; }" }, + { + name: "hoisting", + expression: "{ var g = 5; }", + }, + { + name: "for loops bindings", + expression: "for (let foo = 4; false;){}", + }, + ]); + + cases("cases that we shouldn't map in the future", includedTest, [ + { + name: "window properties", + expression: "var innerHeight = 3; var location = 5;", + newExpression: "self.innerHeight = 3; self.location = 5;", + }, + { + name: "self declaration", + expression: "var self = 3", + newExpression: "self.self = 3", + }, + { + name: "self assignment", + expression: "self = 3", + newExpression: "self.self = 3", + }, + ]); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/mapExpression.spec.js b/devtools/client/debugger/src/workers/parser/tests/mapExpression.spec.js new file mode 100644 index 0000000000..27cc86b039 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/mapExpression.spec.js @@ -0,0 +1,785 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 mapExpression from "../mapExpression"; +import { format } from "prettier"; +import cases from "jest-in-case"; + +function test({ + expression, + newExpression, + bindings, + mappings, + shouldMapBindings, + expectedMapped, + parseExpression = true, +}) { + const res = mapExpression(expression, mappings, bindings, shouldMapBindings); + + if (parseExpression) { + expect( + format(res.expression, { + parser: "babel", + }) + ).toEqual(format(newExpression, { parser: "babel" })); + } else { + expect(res.expression).toEqual(newExpression); + } + + expect(res.mapped).toEqual(expectedMapped); +} + +function formatAwait(body) { + return `(async () => { ${body} })();`; +} + +describe("mapExpression", () => { + cases("mapExpressions", test, [ + { + name: "await", + expression: "await a()", + newExpression: formatAwait("return a()"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (multiple statements)", + expression: "const x = await a(); x + x", + newExpression: formatAwait("self.x = await a(); return x + x;"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (inner)", + expression: "async () => await a();", + newExpression: "async () => await a();", + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (multiple awaits)", + expression: "const x = await a(); await b(x)", + newExpression: formatAwait("self.x = await a(); return b(x);"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (assignment)", + expression: "let x = await sleep(100, 2)", + newExpression: formatAwait("return (self.x = await sleep(100, 2))"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (destructuring)", + expression: "const { a, c: y } = await b()", + newExpression: formatAwait( + "return ({ a: self.a, c: self.y } = await b())" + ), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (array destructuring)", + expression: "const [a, y] = await b();", + newExpression: formatAwait("return ([self.a, self.y] = await b())"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (mixed destructuring)", + expression: "const [{ a }] = await b();", + newExpression: formatAwait("return ([{ a: self.a }] = await b())"), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (destructuring, multiple statements)", + expression: "const { a, c: y } = await b(), { x } = await y()", + newExpression: formatAwait(` + ({ a: self.a, c: self.y } = await b()) + return ({ x: self.x } = await y()); + `), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (destructuring, bindings)", + expression: "const { a, c: y } = await b();", + newExpression: formatAwait("return ({ a, c: y } = await b())"), + bindings: ["a", "y"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (array destructuring, bindings)", + expression: "const [a, y] = await b();", + newExpression: formatAwait("return ([a, y] = await b())"), + bindings: ["a", "y"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (mixed destructuring, bindings)", + expression: "const [{ a }] = await b();", + newExpression: formatAwait("return ([{ a }] = await b())"), + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (destructuring with defaults, bindings)", + expression: "const { c, a = 5 } = await b();", + newExpression: formatAwait("return ({ c: self.c, a = 5 } = await b())"), + bindings: ["a", "y"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (array destructuring with defaults, bindings)", + expression: "const [a, y = 10] = await b();", + newExpression: formatAwait("return ([a, y = 10] = await b())"), + bindings: ["a", "y"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (mixed destructuring with defaults, bindings)", + expression: "const [{ c = 5 }, a = 5] = await b();", + newExpression: formatAwait( + "return ([ { c: self.c = 5 }, a = 5] = await b())" + ), + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (nested destructuring, bindings)", + expression: "const { a, c: { y } } = await b();", + newExpression: formatAwait(` + return ({ + a, + c: { y } + } = await b()); + `), + bindings: ["a", "y"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (nested destructuring with defaults)", + expression: "const { a, c: { y = 5 } = {} } = await b();", + newExpression: formatAwait(`return ({ + a: self.a, + c: { y: self.y = 5 } = {}, + } = await b()); + `), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (very nested destructuring with defaults)", + expression: + "const { a, c: { y: { z = 10, b } = { b: 5 } } } = await b();", + newExpression: formatAwait(` + return ({ + a: self.a, + c: { + y: { z: self.z = 10, b: self.b } = { + b: 5 + } + } + } = await b()); + `), + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: true, + originalExpression: false, + }, + }, + { + name: "await (with SyntaxError)", + expression: "await new Promise())", + newExpression: formatAwait("await new Promise())"), + parseExpression: false, + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, let assignment)", + expression: "let a = await 123;", + newExpression: `let a; + + (async () => { + return a = await 123; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, var assignment)", + expression: "var a = await 123;", + newExpression: `var a; + + (async () => { + return a = await 123; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, const assignment)", + expression: "const a = await 123;", + newExpression: `let a; + + (async () => { + return a = await 123; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, multiple assignments)", + expression: "let a = 1, b, c = 3; b = await 123; a + b + c", + newExpression: `let a, b, c; + + (async () => { + a = 1; + c = 3; + b = await 123; + return a + b + c; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, object destructuring)", + expression: "let {a, b, c} = await x;", + newExpression: `let a, b, c; + + (async () => { + return ({a, b, c} = await x); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, object destructuring with rest)", + expression: "let {a, ...rest} = await x;", + newExpression: `let a, rest; + + (async () => { + return ({a, ...rest} = await x); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, object destructuring with renaming and default)", + expression: "let {a: hello, b, c: world, d: $ = 4} = await x;", + newExpression: `let hello, b, world, $; + + (async () => { + return ({a: hello, b, c: world, d: $ = 4} = await x); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, nested object destructuring + renaming + default)", + expression: `let { + a: hello, c: { y: { z = 10, b: bill, d: [e, f = 20] }} + } = await x; z;`, + newExpression: `let hello, z, bill, e, f; + + (async () => { + ({ a: hello, c: { y: { z = 10, b: bill, d: [e, f = 20] }}} = await x); + return z; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, array destructuring)", + expression: "let [a, b, c] = await x; c;", + newExpression: `let a, b, c; + + (async () => { + [a, b, c] = await x; + return c; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, array destructuring with default)", + expression: "let [a, b = 1, c = 2] = await x; c;", + newExpression: `let a, b, c; + + (async () => { + [a, b = 1, c = 2] = await x; + return c; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, array destructuring with default and rest)", + expression: "let [a, b = 1, c = 2, ...rest] = await x; rest;", + newExpression: `let a, b, c, rest; + + (async () => { + [a, b = 1, c = 2, ...rest] = await x; + return rest; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, nested array destructuring with default)", + expression: "let [a, b = 1, [c = 2, [d = 3, e = 4]]] = await x; c;", + newExpression: `let a, b, c, d, e; + + (async () => { + [a, b = 1, [c = 2, [d = 3, e = 4]]] = await x; + return c; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (no bindings, dynamic import)", + expression: ` + var {rainbowLog} = await import("./cool-module.js"); + rainbowLog("dynamic");`, + newExpression: `var rainbowLog; + + (async () => { + ({rainbowLog} = await import("./cool-module.js")); + return rainbowLog("dynamic"); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (nullish coalesce operator)", + expression: "await x; true ?? false", + newExpression: `(async () => { + await x; + return true ?? false; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (optional chaining operator)", + expression: "await x; x?.y?.z", + newExpression: `(async () => { + await x; + return x?.y?.z; + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (async function declaration with nullish coalesce operator)", + expression: "async function coalesce(x) { await x; return x ?? false; }", + newExpression: + "async function coalesce(x) { await x; return x ?? false; }", + shouldMapBindings: false, + expectedMapped: { + await: false, + bindings: false, + originalExpression: false, + }, + }, + { + name: "await (async function declaration with optional chaining operator)", + expression: "async function chain(x) { await x; return x?.y?.z; }", + newExpression: "async function chain(x) { await x; return x?.y?.z; }", + shouldMapBindings: false, + expectedMapped: { + await: false, + bindings: false, + originalExpression: false, + }, + }, + { + // check that variable declaration in for loop is not put outside of the async iife + name: "await (for loop)", + expression: "for (let i=0;i<2;i++) {}; var b = await 1;", + newExpression: `var b; + + (async () => { + for (let i=0;i<2;i++) {} + + return (b = await 1); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + // check that variable declaration in for-in loop is not put outside of the async iife + name: "await (for..in loop)", + expression: "for (let i in {}) {}; var b = await 1;", + newExpression: `var b; + + (async () => { + for (let i in {}) {} + + return (b = await 1); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + // check that variable declaration in for-of loop is not put outside of the async iife + name: "await (for..of loop)", + expression: "for (let i of []) {}; var b = await 1;", + newExpression: `var b; + + (async () => { + for (let i of []) {} + + return (b = await 1); + })()`, + shouldMapBindings: false, + expectedMapped: { + await: true, + bindings: false, + originalExpression: false, + }, + }, + { + name: "simple", + expression: "a", + newExpression: "a", + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: false, + originalExpression: false, + }, + }, + { + name: "mappings", + expression: "a", + newExpression: "_a", + bindings: [], + mappings: { + a: "_a", + }, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: false, + originalExpression: true, + }, + }, + { + name: "declaration", + expression: "var a = 3;", + newExpression: "self.a = 3", + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "declaration + destructuring", + expression: "var { a } = { a: 3 };", + newExpression: "({ a: self.a } = {\n a: 3 \n})", + bindings: [], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings", + expression: "var a = 3;", + newExpression: "a = 3", + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings + destructuring", + expression: "var { a } = { a: 3 };", + newExpression: "({ a } = { \n a: 3 \n })", + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings + destructuring + rest", + expression: "var { a, ...foo } = {}", + newExpression: "({ a, ...self.foo } = {})", + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings + array destructuring + rest", + expression: "var [a, ...foo] = []", + newExpression: "([a, ...self.foo] = [])", + bindings: ["a"], + mappings: {}, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings + mappings", + expression: "a = 3;", + newExpression: "self.a = 3", + bindings: ["_a"], + mappings: { a: "_a" }, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings + mappings + destructuring", + expression: "var { a } = { a: 4 }", + newExpression: "({ a: self.a } = {\n a: 4 \n})", + bindings: ["_a"], + mappings: { a: "_a" }, + shouldMapBindings: true, + expectedMapped: { + await: false, + bindings: true, + originalExpression: false, + }, + }, + { + name: "bindings without mappings", + expression: "a = 3;", + newExpression: "a = 3", + bindings: [], + mappings: { a: "_a" }, + shouldMapBindings: false, + expectedMapped: { + await: false, + bindings: false, + originalExpression: false, + }, + }, + { + name: "object destructuring + bindings without mappings", + expression: "({ a } = {});", + newExpression: "({ a: _a } = {})", + bindings: [], + mappings: { a: "_a" }, + shouldMapBindings: false, + expectedMapped: { + await: false, + bindings: false, + originalExpression: true, + }, + }, + ]); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/mapOriginalExpression.spec.js b/devtools/client/debugger/src/workers/parser/tests/mapOriginalExpression.spec.js new file mode 100644 index 0000000000..028c86b5fe --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/mapOriginalExpression.spec.js @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 mapExpression from "../mapExpression"; +import { format } from "prettier"; + +const formatOutput = output => + format(output, { + parser: "babel", + }); + +const mapOriginalExpression = (expression, mappings) => + mapExpression(expression, mappings, [], false, false).expression; + +describe("mapOriginalExpression", () => { + it("simple", () => { + const generatedExpression = mapOriginalExpression("a + b;", { + a: "foo", + b: "bar", + }); + expect(generatedExpression).toEqual("foo + bar;"); + }); + + it("this", () => { + const generatedExpression = mapOriginalExpression("this.prop;", { + this: "_this", + }); + expect(generatedExpression).toEqual("_this.prop;"); + }); + + it("member expressions", () => { + const generatedExpression = mapOriginalExpression("a + b", { + a: "_mod.foo", + b: "_mod.bar", + }); + expect(generatedExpression).toEqual("_mod.foo + _mod.bar;"); + }); + + it("block", () => { + // todo: maybe wrap with parens () + const generatedExpression = mapOriginalExpression("{a}", { + a: "_mod.foo", + b: "_mod.bar", + }); + expect(generatedExpression).toEqual("{\n _mod.foo;\n}"); + }); + + it("skips codegen with no mappings", () => { + const generatedExpression = mapOriginalExpression("a + b", { + a: "a", + c: "_c", + }); + expect(generatedExpression).toEqual("a + b"); + }); + + it("object destructuring", () => { + const generatedExpression = mapOriginalExpression("({ a } = { a: 4 })", { + a: "_mod.foo", + }); + + expect(formatOutput(generatedExpression)).toEqual( + formatOutput("({ a: _mod.foo } = {\n a: 4 \n})") + ); + }); + + it("nested object destructuring", () => { + const generatedExpression = mapOriginalExpression( + "({ a: { b, c } } = { a: 4 })", + { + a: "_mod.foo", + b: "_mod.bar", + } + ); + + expect(formatOutput(generatedExpression)).toEqual( + formatOutput("({ a: { b: _mod.bar, c } } = {\n a: 4 \n})") + ); + }); + + it("shadowed bindings", () => { + const generatedExpression = mapOriginalExpression( + "window.thing = function fn(){ var a; a; b; }; a; b; ", + { + a: "_a", + b: "_b", + } + ); + expect(generatedExpression).toEqual( + "window.thing = function fn() {\n var a;\n a;\n _b;\n};\n\n_a;\n_b;" + ); + }); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/sources.spec.js b/devtools/client/debugger/src/workers/parser/tests/sources.spec.js new file mode 100644 index 0000000000..e84ae4ad22 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/sources.spec.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSource } from "../sources"; + +describe("sources", () => { + it("fail getSource", () => { + const sourceId = "some.nonexistent.source.id"; + expect(() => { + getSource(sourceId); + }).toThrow(); + }); +}); diff --git a/devtools/client/debugger/src/workers/parser/tests/validate.spec.js b/devtools/client/debugger/src/workers/parser/tests/validate.spec.js new file mode 100644 index 0000000000..e7edefe472 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/tests/validate.spec.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { hasSyntaxError } from "../validate"; + +describe("has syntax error", () => { + it("should return false", () => { + expect(hasSyntaxError("foo")).toEqual(false); + }); + + it("should return the error object for the invalid expression", () => { + expect(hasSyntaxError("foo)(")).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/workers/parser/utils/ast.js b/devtools/client/debugger/src/workers/parser/utils/ast.js new file mode 100644 index 0000000000..2adbcd9c7c --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/ast.js @@ -0,0 +1,225 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 parseScriptTags from "parse-script-tags"; +import * as babelParser from "@babel/parser"; +import * as t from "@babel/types"; +import { getSource } from "../sources"; + +let ASTs = new Map(); + +function _parse(code, opts) { + return babelParser.parse(code, { + ...opts, + tokens: true, + }); +} + +const sourceOptions = { + generated: { + sourceType: "unambiguous", + tokens: true, + plugins: [ + "classStaticBlock", + "classPrivateProperties", + "classPrivateMethods", + "classProperties", + "objectRestSpread", + "optionalChaining", + "privateIn", + "nullishCoalescingOperator", + ], + }, + original: { + sourceType: "unambiguous", + tokens: true, + plugins: [ + "jsx", + "flow", + "doExpressions", + "optionalChaining", + "nullishCoalescingOperator", + "decorators-legacy", + "objectRestSpread", + "classStaticBlock", + "classPrivateProperties", + "classPrivateMethods", + "classProperties", + "exportDefaultFrom", + "exportNamespaceFrom", + "asyncGenerators", + "functionBind", + "functionSent", + "dynamicImport", + "react-jsx", + ], + }, +}; + +export function parse(text, opts) { + let ast = {}; + if (!text) { + return ast; + } + + try { + ast = _parse(text, opts); + } catch (error) { + console.error(error); + } + + return ast; +} + +// Custom parser for parse-script-tags that adapts its input structure to +// our parser's signature +function htmlParser({ source, line }) { + return parse(source, { startLine: line, ...sourceOptions.generated }); +} + +const VUE_COMPONENT_START = /^\s*</; +function vueParser({ source, line }) { + return parse(source, { + startLine: line, + ...sourceOptions.original, + }); +} +function parseVueScript(code) { + if (typeof code !== "string") { + return {}; + } + + let ast; + + // .vue files go through several passes, so while there is a + // single-file-component Vue template, there are also generally .vue files + // that are still just JS as well. + if (code.match(VUE_COMPONENT_START)) { + ast = parseScriptTags(code, vueParser); + if (t.isFile(ast)) { + // parseScriptTags is currently hard-coded to return scripts, but Vue + // always expects ESM syntax, so we just hard-code it. + ast.program.sourceType = "module"; + } + } else { + ast = parse(code, sourceOptions.original); + } + return ast; +} + +export function parseConsoleScript(text, opts) { + try { + return _parse(text, { + plugins: [ + "classStaticBlock", + "classPrivateProperties", + "classPrivateMethods", + "objectRestSpread", + "dynamicImport", + "nullishCoalescingOperator", + "optionalChaining", + ], + ...opts, + allowAwaitOutsideFunction: true, + }); + } catch (e) { + return null; + } +} + +export function parseScript(text, opts) { + return _parse(text, opts); +} + +export function getAst(sourceId) { + if (ASTs.has(sourceId)) { + return ASTs.get(sourceId); + } + + const source = getSource(sourceId); + + if (source.isWasm) { + return null; + } + + let ast = {}; + const { contentType } = source; + if (contentType == "text/html") { + ast = parseScriptTags(source.text, htmlParser) || {}; + } else if (contentType && contentType === "text/vue") { + ast = parseVueScript(source.text) || {}; + } else if ( + contentType && + contentType.match(/(javascript|jsx)/) && + !contentType.match(/typescript-jsx/) + ) { + const type = source.id.includes("original") ? "original" : "generated"; + const options = sourceOptions[type]; + ast = parse(source.text, options); + } else if (contentType && contentType.match(/typescript/)) { + const options = { + ...sourceOptions.original, + plugins: [ + ...sourceOptions.original.plugins.filter( + p => + p !== "flow" && + p !== "decorators" && + p !== "decorators2" && + (p !== "jsx" || contentType.match(/typescript-jsx/)) + ), + "decorators-legacy", + "typescript", + ], + }; + ast = parse(source.text, options); + } + + ASTs.set(source.id, ast); + return ast; +} + +export function clearASTs() { + ASTs = new Map(); +} + +export function traverseAst(sourceId, visitor, state) { + const ast = getAst(sourceId); + if (!ast || !Object.keys(ast).length) { + return null; + } + + t.traverse(ast, visitor, state); + return ast; +} + +export function hasNode(rootNode, predicate) { + try { + t.traverse(rootNode, { + enter: (node, ancestors) => { + if (predicate(node, ancestors)) { + throw new Error("MATCH"); + } + }, + }); + } catch (e) { + if (e.message === "MATCH") { + return true; + } + } + return false; +} + +export function replaceNode(ancestors, node) { + const parent = ancestors[ancestors.length - 1]; + + if (typeof parent.index === "number") { + if (Array.isArray(node)) { + parent.node[parent.key].splice(parent.index, 1, ...node); + } else { + parent.node[parent.key][parent.index] = node; + } + } else { + parent.node[parent.key] = node; + } +} diff --git a/devtools/client/debugger/src/workers/parser/utils/contains.js b/devtools/client/debugger/src/workers/parser/utils/contains.js new file mode 100644 index 0000000000..ed4cb31c1d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/contains.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +function startsBefore(a, b) { + let before = a.start.line < b.line; + if (a.start.line === b.line) { + before = + a.start.column >= 0 && b.column >= 0 ? a.start.column <= b.column : true; + } + return before; +} + +function endsAfter(a, b) { + let after = a.end.line > b.line; + if (a.end.line === b.line) { + after = + a.end.column >= 0 && b.column >= 0 ? a.end.column >= b.column : true; + } + return after; +} + +export function containsPosition(a, b) { + return startsBefore(a, b) && endsAfter(a, b); +} + +export function containsLocation(a, b) { + return containsPosition(a, b.start) && containsPosition(a, b.end); +} diff --git a/devtools/client/debugger/src/workers/parser/utils/formatSymbols.js b/devtools/client/debugger/src/workers/parser/utils/formatSymbols.js new file mode 100644 index 0000000000..3bcf37e7c4 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/formatSymbols.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSymbols } from "../getSymbols"; + +function formatLocation(loc) { + if (!loc) { + return ""; + } + + const { start, end } = loc; + const startLoc = `(${start.line}, ${start.column})`; + const endLoc = `(${end.line}, ${end.column})`; + + return `[${startLoc}, ${endLoc}]`; +} + +function summarize(symbol) { + if (typeof symbol == "boolean") { + return symbol ? "true" : "false"; + } + + const loc = formatLocation(symbol.location); + const params = symbol.parameterNames + ? `(${symbol.parameterNames.join(", ")})` + : ""; + const expression = symbol.expression || ""; + const klass = symbol.klass || ""; + const name = symbol.name == undefined ? "" : symbol.name; + const names = symbol.specifiers ? symbol.specifiers.join(", ") : ""; + const values = symbol.values ? symbol.values.join(", ") : ""; + const index = symbol.index ? symbol.index : ""; + + return `${loc} ${expression} ${name}${params} ${klass} ${names} ${values} ${index}`.trim(); // eslint-disable-line max-len +} +const bools = ["hasJsx", "hasTypes"]; +const strings = ["framework"]; +function formatBool(name, symbols) { + return `${name}: ${symbols[name] ? "true" : "false"}`; +} + +function formatString(name, symbols) { + return `${name}: ${symbols[name]}`; +} + +function formatKey(name, symbols) { + if (bools.includes(name)) { + return formatBool(name, symbols); + } + + if (strings.includes(name)) { + return formatString(name, symbols); + } + + return `${name}:\n${symbols[name].map(summarize).join("\n")}`; +} + +export function formatSymbols(sourceId) { + const symbols = getSymbols(sourceId); + + return Object.keys(symbols) + .map(name => formatKey(name, symbols)) + .join("\n\n"); +} diff --git a/devtools/client/debugger/src/workers/parser/utils/getFunctionName.js b/devtools/client/debugger/src/workers/parser/utils/getFunctionName.js new file mode 100644 index 0000000000..1fe85a5c69 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/getFunctionName.js @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import * as t from "@babel/types"; + +// Perform ES6's anonymous function name inference for all +// locations where static analysis is possible. +// eslint-disable-next-line complexity +export default function getFunctionName(node, parent) { + if (t.isIdentifier(node.id)) { + return node.id.name; + } + + if ( + t.isObjectMethod(node, { computed: false }) || + t.isClassMethod(node, { computed: false }) || + t.isClassPrivateMethod(node) + ) { + const { key } = node; + + if (t.isIdentifier(key)) { + return key.name; + } + if (t.isStringLiteral(key)) { + return key.value; + } + if (t.isNumericLiteral(key)) { + return `${key.value}`; + } + + if (t.isPrivateName(key)) { + return `#${key.id.name}`; + } + } + + if ( + t.isObjectProperty(parent, { computed: false, value: node }) || + // TODO: Babylon 6 doesn't support computed class props. It is included + // here so that it is most flexible. Once Babylon 7 is used, this + // can change to use computed: false like ObjectProperty. + (t.isClassProperty(parent, { value: node }) && !parent.computed) || + (t.isClassPrivateProperty(parent, { value: node }) && !parent.computed) + ) { + const { key } = parent; + + if (t.isIdentifier(key)) { + return key.name; + } + if (t.isStringLiteral(key)) { + return key.value; + } + if (t.isNumericLiteral(key)) { + return `${key.value}`; + } + + if (t.isPrivateName(key)) { + return `#${key.id.name}`; + } + } + + if (t.isAssignmentExpression(parent, { operator: "=", right: node })) { + if (t.isIdentifier(parent.left)) { + return parent.left.name; + } + + // This case is not supported in standard ES6 name inference, but it + // is included here since it is still a helpful case during debugging. + if (t.isMemberExpression(parent.left, { computed: false })) { + return parent.left.property.name; + } + } + + if ( + t.isAssignmentPattern(parent, { right: node }) && + t.isIdentifier(parent.left) + ) { + return parent.left.name; + } + + if ( + t.isVariableDeclarator(parent, { init: node }) && + t.isIdentifier(parent.id) + ) { + return parent.id.name; + } + + if ( + t.isExportDefaultDeclaration(parent, { declaration: node }) && + t.isFunctionDeclaration(node) + ) { + return "default"; + } + + return "anonymous"; +} diff --git a/devtools/client/debugger/src/workers/parser/utils/helpers.js b/devtools/client/debugger/src/workers/parser/utils/helpers.js new file mode 100644 index 0000000000..0850ea678c --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/helpers.js @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import * as t from "@babel/types"; +import generate from "@babel/generator"; + +export function isFunction(node) { + return ( + t.isFunction(node) || + t.isArrowFunctionExpression(node) || + t.isObjectMethod(node) || + t.isClassMethod(node) + ); +} + +export function isAwaitExpression(path) { + const { node, parent } = path; + return ( + t.isAwaitExpression(node) || + t.isAwaitExpression(parent.init) || + t.isAwaitExpression(parent) + ); +} + +export function isYieldExpression(path) { + const { node, parent } = path; + return ( + t.isYieldExpression(node) || + t.isYieldExpression(parent.init) || + t.isYieldExpression(parent) + ); +} + +export function isObjectShorthand(parent) { + if (!t.isObjectProperty(parent)) { + return false; + } + + if (parent.value && parent.value.left) { + return ( + parent.value.type === "AssignmentPattern" && + parent.value.left.type === "Identifier" + ); + } + + return ( + parent.value && + parent.key.start == parent.value.start && + parent.key.loc.identifierName === parent.value.loc.identifierName + ); +} + +export function getObjectExpressionValue(node) { + const { value } = node; + + if (t.isIdentifier(value)) { + return value.name; + } + + if (t.isCallExpression(value) || t.isFunctionExpression(value)) { + return ""; + } + const code = generate(value).code; + + const shouldWrap = t.isObjectExpression(value); + return shouldWrap ? `(${code})` : code; +} + +export function getCode(node) { + return generate(node).code; +} + +export function getComments(ast) { + if (!ast || !ast.comments) { + return []; + } + return ast.comments.map(comment => ({ + name: comment.location, + location: comment.loc, + })); +} + +export function getSpecifiers(specifiers) { + if (!specifiers) { + return []; + } + + return specifiers.map(specifier => specifier.local?.name); +} + +export function isComputedExpression(expression) { + return /^\[/m.test(expression); +} + +export function getMemberExpression(root) { + function _getMemberExpression(node, expr) { + if (t.isMemberExpression(node)) { + expr = [node.property.name].concat(expr); + return _getMemberExpression(node.object, expr); + } + + if (t.isCallExpression(node)) { + return []; + } + + if (t.isThisExpression(node)) { + return ["this"].concat(expr); + } + + return [node.name].concat(expr); + } + + const expr = _getMemberExpression(root, []); + return expr.join("."); +} + +export function getVariables(dec) { + if (!dec.id) { + return []; + } + + if (t.isArrayPattern(dec.id)) { + if (!dec.id.elements) { + return []; + } + + // NOTE: it's possible that an element is empty or has several variables + // e.g. const [, a] = arr + // e.g. const [{a, b }] = 2 + return dec.id.elements + .filter(Boolean) + .map(element => ({ + name: t.isAssignmentPattern(element) + ? element.left.name + : element.name || element.argument?.name, + location: element.loc, + })) + .filter(({ name }) => name); + } + + return [ + { + name: dec.id.name, + location: dec.loc, + }, + ]; +} + +export function getPatternIdentifiers(pattern) { + let items = []; + if (t.isObjectPattern(pattern)) { + items = pattern.properties.map(({ value }) => value); + } + + if (t.isArrayPattern(pattern)) { + items = pattern.elements; + } + + return getIdentifiers(items); +} + +function getIdentifiers(items) { + let ids = []; + items.forEach(function (item) { + if (t.isObjectPattern(item) || t.isArrayPattern(item)) { + ids = ids.concat(getPatternIdentifiers(item)); + } else if (t.isIdentifier(item)) { + const { start, end } = item.loc; + ids.push({ + name: item.name, + expression: item.name, + location: { start, end }, + }); + } + }); + return ids; +} + +// Top Level checks the number of "body" nodes in the ancestor chain +// if the node is top-level, then it shoul only have one body. +export function isTopLevel(ancestors) { + return ancestors.filter(ancestor => ancestor.key == "body").length == 1; +} + +export function nodeLocationKey(a) { + const { start, end } = a.location; + return `${start.line}:${start.column}:${end.line}:${end.column}`; +} + +export function getFunctionParameterNames(path) { + if (path.node.params != null) { + return path.node.params.map(param => { + if (param.type !== "AssignmentPattern") { + return param.name; + } + + // Parameter with default value + if ( + param.left.type === "Identifier" && + param.right.type === "Identifier" + ) { + return `${param.left.name} = ${param.right.name}`; + } else if ( + param.left.type === "Identifier" && + param.right.type === "StringLiteral" + ) { + return `${param.left.name} = ${param.right.value}`; + } else if ( + param.left.type === "Identifier" && + param.right.type === "ObjectExpression" + ) { + return `${param.left.name} = {}`; + } else if ( + param.left.type === "Identifier" && + param.right.type === "ArrayExpression" + ) { + return `${param.left.name} = []`; + } else if ( + param.left.type === "Identifier" && + param.right.type === "NullLiteral" + ) { + return `${param.left.name} = null`; + } + + return null; + }); + } + return []; +} diff --git a/devtools/client/debugger/src/workers/parser/utils/inferClassName.js b/devtools/client/debugger/src/workers/parser/utils/inferClassName.js new file mode 100644 index 0000000000..09d25f275d --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/inferClassName.js @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 * as t from "@babel/types"; + +// the function class is inferred from a call like +// createClass or extend +function fromCallExpression(callExpression) { + const allowlist = ["extend", "createClass"]; + const { callee } = callExpression.node; + if (!callee) { + return null; + } + + const name = t.isMemberExpression(callee) + ? callee.property.name + : callee.name; + + if (!allowlist.includes(name)) { + return null; + } + + const variable = callExpression.findParent(p => + t.isVariableDeclarator(p.node) + ); + if (variable) { + return variable.node.id.name; + } + + const assignment = callExpression.findParent(p => + t.isAssignmentExpression(p.node) + ); + + if (!assignment) { + return null; + } + + const { left } = assignment.node; + + if (left.name) { + return name; + } + + if (t.isMemberExpression(left)) { + return left.property.name; + } + + return null; +} + +// the function class is inferred from a prototype assignment +// e.g. TodoClass.prototype.render = function() {} +function fromPrototype(assignment) { + const { left } = assignment.node; + if (!left) { + return null; + } + + if ( + t.isMemberExpression(left) && + left.object && + t.isMemberExpression(left.object) && + left.object.property.identifier === "prototype" + ) { + return left.object.object.name; + } + + return null; +} + +// infer class finds an appropriate class for functions +// that are defined inside of a class like thing. +// e.g. `class Foo`, `TodoClass.prototype.foo`, +// `Todo = createClass({ foo: () => {}})` +export function inferClassName(path) { + const classDeclaration = path.findParent(p => t.isClassDeclaration(p.node)); + if (classDeclaration) { + return classDeclaration.node.id.name; + } + + const callExpression = path.findParent(p => t.isCallExpression(p.node)); + if (callExpression) { + return fromCallExpression(callExpression); + } + + const assignment = path.findParent(p => t.isAssignmentExpression(p.node)); + if (assignment) { + return fromPrototype(assignment); + } + + return null; +} diff --git a/devtools/client/debugger/src/workers/parser/utils/simple-path.js b/devtools/client/debugger/src/workers/parser/utils/simple-path.js new file mode 100644 index 0000000000..c167103566 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/simple-path.js @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 default function createSimplePath(ancestors) { + if (ancestors.length === 0) { + return null; + } + + // Slice the array because babel-types traverse may continue mutating + // the ancestors array in later traversal logic. + return new SimplePath(ancestors.slice()); +} + +/** + * Mimics @babel/traverse's NodePath API in a simpler fashion that isn't as + * heavy, but still allows the ease of passing paths around to process nested + * AST structures. + */ +class SimplePath { + _index; + _ancestors; + _ancestor; + + _parentPath; + + constructor(ancestors, index = ancestors.length - 1) { + if (index < 0 || index >= ancestors.length) { + console.error(ancestors); + throw new Error("Created invalid path"); + } + + this._ancestors = ancestors; + this._ancestor = ancestors[index]; + this._index = index; + } + + get parentPath() { + let path = this._parentPath; + if (path === undefined) { + if (this._index === 0) { + path = null; + } else { + path = new SimplePath(this._ancestors, this._index - 1); + } + this._parentPath = path; + } + + return path; + } + + get parent() { + return this._ancestor.node; + } + + get node() { + const { node, key, index } = this._ancestor; + + if (typeof index === "number") { + return node[key][index]; + } + + return node[key]; + } + + get key() { + return this._ancestor.key; + } + + set node(replacement) { + if (this.type !== "Identifier") { + throw new Error( + "Replacing anything other than leaf nodes is undefined behavior " + + "in t.traverse()" + ); + } + + const { node, key, index } = this._ancestor; + if (typeof index === "number") { + node[key][index] = replacement; + } else { + node[key] = replacement; + } + } + + get type() { + return this.node.type; + } + + get inList() { + return typeof this._ancestor.index === "number"; + } + + get containerIndex() { + const { index } = this._ancestor; + + if (typeof index !== "number") { + throw new Error("Cannot get index of non-array node"); + } + + return index; + } + + get depth() { + return this._index; + } + + replace(node) { + this.node = node; + } + + find(predicate) { + for (let path = this; path; path = path.parentPath) { + if (predicate(path)) { + return path; + } + } + return null; + } + + findParent(predicate) { + if (!this.parentPath) { + throw new Error("Cannot use findParent on root path"); + } + + return this.parentPath.find(predicate); + } + + getSibling(offset) { + const { node, key, index } = this._ancestor; + + if (typeof index !== "number") { + throw new Error("Non-array nodes do not have siblings"); + } + + const container = node[key]; + + const siblingIndex = index + offset; + if (siblingIndex < 0 || siblingIndex >= container.length) { + return null; + } + + return new SimplePath( + this._ancestors.slice(0, -1).concat([{ node, key, index: siblingIndex }]) + ); + } +} diff --git a/devtools/client/debugger/src/workers/parser/utils/tests/ast.spec.js b/devtools/client/debugger/src/workers/parser/utils/tests/ast.spec.js new file mode 100644 index 0000000000..e8d2964205 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/utils/tests/ast.spec.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getAst } from "../ast"; +import { setSource } from "../../sources"; +import cases from "jest-in-case"; + +import { makeMockSourceAndContent } from "../../../../utils/test-mockup"; + +const astKeys = [ + "type", + "start", + "end", + "loc", + "errors", + "program", + "comments", + "tokens", +]; + +cases( + "ast.getAst", + ({ name }) => { + const source = makeMockSourceAndContent(undefined, "foo", name, "2"); + setSource({ + id: source.id, + text: source.content.value || "", + contentType: source.content.contentType, + isWasm: false, + }); + const ast = getAst("foo"); + expect(ast && Object.keys(ast)).toEqual(astKeys); + }, + [ + { name: "text/javascript" }, + { name: "application/javascript" }, + { name: "application/x-javascript" }, + { name: "text/jsx" }, + ] +); diff --git a/devtools/client/debugger/src/workers/parser/validate.js b/devtools/client/debugger/src/workers/parser/validate.js new file mode 100644 index 0000000000..d01e76668a --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/validate.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { parseScript } from "./utils/ast"; + +export function hasSyntaxError(input) { + try { + parseScript(input); + return false; + } catch (e) { + return `${e.name} : ${e.message}`; + } +} diff --git a/devtools/client/debugger/src/workers/parser/worker.js b/devtools/client/debugger/src/workers/parser/worker.js new file mode 100644 index 0000000000..caa49cd482 --- /dev/null +++ b/devtools/client/debugger/src/workers/parser/worker.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { getSymbols, clearSymbols } from "./getSymbols"; +import { clearASTs } from "./utils/ast"; +import getScopes, { clearScopes } from "./getScopes"; +import { setSource, clearSources } from "./sources"; +import findOutOfScopeLocations from "./findOutOfScopeLocations"; +import { hasSyntaxError } from "./validate"; +import mapExpression from "./mapExpression"; + +import { workerHandler } from "../../../../shared/worker-utils"; + +function clearState() { + clearASTs(); + clearScopes(); + clearSources(); + clearSymbols(); +} + +self.onmessage = workerHandler({ + findOutOfScopeLocations, + getSymbols, + getScopes, + clearState, + hasSyntaxError, + mapExpression, + setSource, +}); diff --git a/devtools/client/debugger/src/workers/pretty-print/LICENSE.md b/devtools/client/debugger/src/workers/pretty-print/LICENSE.md new file mode 100644 index 0000000000..cc8e9a752c --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/LICENSE.md @@ -0,0 +1,23 @@ +Copyright (c) 2013, Nick Fitzgerald +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/devtools/client/debugger/src/workers/pretty-print/index.js b/devtools/client/debugger/src/workers/pretty-print/index.js new file mode 100644 index 0000000000..4b151b39e4 --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/index.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { WorkerDispatcher } from "devtools/client/shared/worker-utils"; + +const WORKER_URL = + "resource://devtools/client/debugger/dist/pretty-print-worker.js"; + +export class PrettyPrintDispatcher extends WorkerDispatcher { + constructor(jestUrl) { + super(jestUrl || WORKER_URL); + } + + #prettyPrintTask = this.task("prettyPrint"); + #prettyPrintInlineScriptTask = this.task("prettyPrintInlineScript"); + #getSourceMapForTask = this.task("getSourceMapForTask"); + + prettyPrint(options) { + return this.#prettyPrintTask(options); + } + + prettyPrintInlineScript(options) { + return this.#prettyPrintInlineScriptTask(options); + } + + getSourceMap(taskId) { + return this.#getSourceMapForTask(taskId); + } +} diff --git a/devtools/client/debugger/src/workers/pretty-print/moz.build b/devtools/client/debugger/src/workers/pretty-print/moz.build new file mode 100644 index 0000000000..b7223ac81a --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/moz.build @@ -0,0 +1,10 @@ +# 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( + "index.js", +) diff --git a/devtools/client/debugger/src/workers/pretty-print/pretty-fast.js b/devtools/client/debugger/src/workers/pretty-print/pretty-fast.js new file mode 100644 index 0000000000..44b07f4eda --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/pretty-fast.js @@ -0,0 +1,1178 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 complexity */ + +var acorn = require("acorn"); +var sourceMap = require("source-map"); +const NEWLINE_CODE = 10; + +export function prettyFast(input, options) { + return new PrettyFast(options).getPrettifiedCodeAndSourceMap(input); +} + +// If any of these tokens are seen before a "[" token, we know that "[" token +// is the start of an array literal, rather than a property access. +// +// The only exception is "}", which would need to be disambiguated by +// parsing. The majority of the time, an open bracket following a closing +// curly is going to be an array literal, so we brush the complication under +// the rug, and handle the ambiguity by always assuming that it will be an +// array literal. +const PRE_ARRAY_LITERAL_TOKENS = new Set([ + "typeof", + "void", + "delete", + "case", + "do", + "=", + "in", + "of", + "...", + "{", + "*", + "/", + "%", + "else", + ";", + "++", + "--", + "+", + "-", + "~", + "!", + ":", + "?", + ">>", + ">>>", + "<<", + "||", + "&&", + "<", + ">", + "<=", + ">=", + "instanceof", + "&", + "^", + "|", + "==", + "!=", + "===", + "!==", + ",", + "}", +]); + +// If any of these tokens are seen before a "{" token, we know that "{" token +// is the start of an object literal, rather than the start of a block. +const PRE_OBJECT_LITERAL_TOKENS = new Set([ + "typeof", + "void", + "delete", + "=", + "in", + "of", + "...", + "*", + "/", + "%", + "++", + "--", + "+", + "-", + "~", + "!", + ">>", + ">>>", + "<<", + "<", + ">", + "<=", + ">=", + "instanceof", + "&", + "^", + "|", + "==", + "!=", + "===", + "!==", +]); + +class PrettyFast { + /** + * @param {Object} options: Provides configurability of the pretty printing. + * @param {String} options.url: The URL string of the ugly JS code. + * @param {String} options.indent: The string to indent code by. + * @param {SourceMapGenerator} options.sourceMapGenerator: An optional sourceMapGenerator + * the mappings will be added to. + * @param {Boolean} options.prefixWithNewLine: When true, the pretty printed code will start + * with a line break + * @param {Integer} options.originalStartLine: The line the passed script starts at (1-based). + * This is used for inline scripts where we need to account for the lines + * before the script tag + * @param {Integer} options.originalStartColumn: The column the passed script starts at (1-based). + * This is used for inline scripts where we need to account for the position + * of the script tag within the line. + * @param {Integer} options.generatedStartLine: The line where the pretty printed script + * will start at (1-based). This is used for pretty printing HTML file, + * where we might have handle previous inline scripts that impact the + * position of this script. + */ + constructor(options = {}) { + // The level of indents deep we are. + this.#indentLevel = 0; + this.#indentChar = options.indent; + + // We will handle mappings between ugly and pretty printed code in this SourceMapGenerator. + this.#sourceMapGenerator = + options.sourceMapGenerator || + new sourceMap.SourceMapGenerator({ + file: options.url, + }); + + this.#file = options.url; + this.#hasOriginalStartLine = "originalStartLine" in options; + this.#hasOriginalStartColumn = "originalStartColumn" in options; + this.#hasGeneratedStartLine = "generatedStartLine" in options; + this.#originalStartLine = options.originalStartLine; + this.#originalStartColumn = options.originalStartColumn; + this.#generatedStartLine = options.generatedStartLine; + this.#prefixWithNewLine = options.prefixWithNewLine; + } + + /* options */ + #indentChar; + #indentLevel; + #file; + #hasOriginalStartLine; + #hasOriginalStartColumn; + #hasGeneratedStartLine; + #originalStartLine; + #originalStartColumn; + #prefixWithNewLine; + #generatedStartLine; + #sourceMapGenerator; + + /* internals */ + + // Whether or not we added a newline on after we added the previous token. + #addedNewline = false; + // Whether or not we added a space after we added the previous token. + #addedSpace = false; + #currentCode = ""; + #currentLine = 1; + #currentColumn = 0; + // The tokens parsed by acorn. + #tokenQueue; + // The index of the current token in this.#tokenQueue. + #currentTokenIndex; + // The previous token we added to the pretty printed code. + #previousToken; + // Stack of token types/keywords that can affect whether we want to add a + // newline or a space. We can make that decision based on what token type is + // on the top of the stack. For example, a comma in a parameter list should + // be followed by a space, while a comma in an object literal should be + // followed by a newline. + // + // Strings that go on the stack: + // + // - "{" + // - "{\n" + // - "(" + // - "(\n" + // - "[" + // - "[\n" + // - "do" + // - "?" + // - "switch" + // - "case" + // - "default" + // + // The difference between "[" and "[\n" (as well as "{" and "{\n", and "(" and "(\n") + // is that "\n" is used when we are treating (curly) brackets/parens as line delimiters + // and should increment and decrement the indent level when we find them. + // "[" can represent either a property access (e.g. `x["hi"]`), or an empty array literal + // "{" only represents an empty object literals + // "(" can represent lots of different things (wrapping expression, if/loop condition, function call, …) + #stack = []; + + /** + * @param {String} input: The ugly JS code we want to pretty print. + * @returns {Object} + * An object with the following properties: + * - code: The pretty printed code string. + * - map: A SourceMapGenerator instance. + */ + getPrettifiedCodeAndSourceMap(input) { + // Add the initial new line if needed + if (this.#prefixWithNewLine) { + this.#write("\n"); + } + + // Pass through acorn's tokenizer and append tokens and comments into a + // single queue to process. For example, the source file: + // + // foo + // // a + // // b + // bar + // + // After this process, tokenQueue has the following token stream: + // + // [ foo, '// a', '// b', bar] + this.#tokenQueue = this.#getTokens(input); + + for (let i = 0, len = this.#tokenQueue.length; i < len; i++) { + this.#currentTokenIndex = i; + const token = this.#tokenQueue[i]; + const nextToken = this.#tokenQueue[i + 1]; + this.#handleToken(token, nextToken); + + // Acorn's tokenizer re-uses tokens, so we have to copy the previous token on + // every iteration. We follow acorn's lead here, and reuse the previousToken + // object the same way that acorn reuses the token object. This allows us + // to avoid allocations and minimize GC pauses. + if (!this.#previousToken) { + this.#previousToken = { loc: { start: {}, end: {} } }; + } + this.#previousToken.start = token.start; + this.#previousToken.end = token.end; + this.#previousToken.loc.start.line = token.loc.start.line; + this.#previousToken.loc.start.column = token.loc.start.column; + this.#previousToken.loc.end.line = token.loc.end.line; + this.#previousToken.loc.end.column = token.loc.end.column; + this.#previousToken.type = token.type; + this.#previousToken.value = token.value; + } + + return { code: this.#currentCode, map: this.#sourceMapGenerator }; + } + + /** + * Write a pretty printed string to the prettified string and for tokens, add their + * mapping to the SourceMapGenerator. + * + * @param String str + * The string to be added to the result. + * @param Number line + * The line number the string came from in the ugly source. + * @param Number column + * The column number the string came from in the ugly source. + * @param Boolean isToken + * Set to true when writing tokens, so we can differentiate them from the + * whitespace we add. + */ + #write(str, line, column, isToken) { + this.#currentCode += str; + if (isToken) { + this.#sourceMapGenerator.addMapping({ + source: this.#file, + // We need to swap original and generated locations, as the prettified text should + // be seen by the sourcemap service as the "original" one. + generated: { + // originalStartLine is 1-based, and here we just want to offset by a number of + // lines, so we need to decrement it + line: this.#hasOriginalStartLine + ? line + (this.#originalStartLine - 1) + : line, + // We only need to adjust the column number if we're looking at the first line, to + // account for the html text before the opening <script> tag. + column: + line == 1 && this.#hasOriginalStartColumn + ? column + this.#originalStartColumn + : column, + }, + original: { + // generatedStartLine is 1-based, and here we just want to offset by a number of + // lines, so we need to decrement it. + line: this.#hasGeneratedStartLine + ? this.#currentLine + (this.#generatedStartLine - 1) + : this.#currentLine, + column: this.#currentColumn, + }, + name: null, + }); + } + + for (let idx = 0, length = str.length; idx < length; idx++) { + if (str.charCodeAt(idx) === NEWLINE_CODE) { + this.#currentLine++; + this.#currentColumn = 0; + } else { + this.#currentColumn++; + } + } + } + + /** + * Add the given token to the pretty printed results. + * + * @param Object token + * The token to add. + */ + #writeToken(token) { + if (token.type.label == "string") { + this.#write( + `'${sanitize(token.value)}'`, + token.loc.start.line, + token.loc.start.column, + true + ); + } else if (token.type.label == "regexp") { + this.#write( + String(token.value.value), + token.loc.start.line, + token.loc.start.column, + true + ); + } else { + let value; + if (token.value != null) { + value = token.value; + if (token.type.label === "privateId") { + value = `#${value}`; + } + } else { + value = token.type.label; + } + this.#write( + String(value), + token.loc.start.line, + token.loc.start.column, + true + ); + } + } + + /** + * Returns the tokens computed with acorn. + * + * @param String input + * The JS code we want the tokens of. + * @returns Array<Object> + */ + #getTokens(input) { + const tokens = []; + + const res = acorn.tokenizer(input, { + locations: true, + ecmaVersion: "latest", + onComment(block, text, start, end, startLoc, endLoc) { + tokens.push({ + type: {}, + comment: true, + block, + text, + loc: { start: startLoc, end: endLoc }, + }); + }, + }); + + for (;;) { + const token = res.getToken(); + tokens.push(token); + if (token.type.label == "eof") { + break; + } + } + + return tokens; + } + + /** + * Add the required whitespace before this token, whether that is a single + * space, newline, and/or the indent on fresh lines. + * + * @param Object token + * The token we are currently handling. + * @param {Object|undefined} nextToken + * The next token, might not exist if we're on the last token + */ + #handleToken(token, nextToken) { + if (token.comment) { + let commentIndentLevel = this.#indentLevel; + if (this.#previousToken?.loc?.end?.line == token.loc.start.line) { + commentIndentLevel = 0; + this.#write(" "); + } + this.#addComment( + commentIndentLevel, + token.block, + token.text, + token.loc.start.line, + nextToken + ); + return; + } + + // Shorthand for token.type.keyword, so we don't have to repeatedly access + // properties. + const ttk = token.type.keyword; + + if (ttk && this.#previousToken?.type?.label == ".") { + token.type = acorn.tokTypes.name; + } + + // Shorthand for token.type.label, so we don't have to repeatedly access + // properties. + const ttl = token.type.label; + + if (ttl == "eof") { + if (!this.#addedNewline) { + this.#write("\n"); + } + return; + } + + if (belongsOnStack(token)) { + let stackEntry; + + if (isArrayLiteral(token, this.#previousToken)) { + // Don't add new lines for empty array literals + stackEntry = nextToken?.type?.label === "]" ? "[" : "[\n"; + } else if (isObjectLiteral(token, this.#previousToken)) { + // Don't add new lines for empty object literals + stackEntry = nextToken?.type?.label === "}" ? "{" : "{\n"; + } else if ( + isRoundBracketStartingLongParenthesis( + token, + this.#tokenQueue, + this.#currentTokenIndex + ) + ) { + stackEntry = "(\n"; + } else if (ttl == "{") { + // We need to add a line break for "{" which are not empty object literals + stackEntry = "{\n"; + } else { + stackEntry = ttl || ttk; + } + + this.#stack.push(stackEntry); + } + + this.#maybeDecrementIndent(token); + this.#prependWhiteSpace(token); + this.#writeToken(token); + this.#addedSpace = false; + + // If the next token is going to be a comment starting on the same line, + // then no need to add a new line here + if ( + !nextToken || + !nextToken.comment || + token.loc.end.line != nextToken.loc.start.line + ) { + this.#maybeAppendNewline(token); + } + + this.#maybePopStack(token); + this.#maybeIncrementIndent(token); + } + + /** + * Returns true if the given token should cause us to pop the stack. + */ + #maybePopStack(token) { + const ttl = token.type.label; + const ttk = token.type.keyword; + const top = this.#stack.at(-1); + + if ( + ttl == "]" || + ttl == ")" || + ttl == "}" || + (ttl == ":" && (top == "case" || top == "default" || top == "?")) || + (ttk == "while" && top == "do") + ) { + this.#stack.pop(); + if (ttl == "}" && this.#stack.at(-1) == "switch") { + this.#stack.pop(); + } + } + } + + #maybeIncrementIndent(token) { + if ( + // Don't increment indent for empty object literals + (token.type.label == "{" && this.#stack.at(-1) === "{\n") || + // Don't increment indent for empty array literals + (token.type.label == "[" && this.#stack.at(-1) === "[\n") || + token.type.keyword == "switch" || + (token.type.label == "(" && this.#stack.at(-1) === "(\n") + ) { + this.#indentLevel++; + } + } + + #shouldDecrementIndent(token) { + const top = this.#stack.at(-1); + const ttl = token.type.label; + return ( + (ttl == "}" && top == "{\n") || + (ttl == "]" && top == "[\n") || + (ttl == ")" && top == "(\n") + ); + } + + #maybeDecrementIndent(token) { + if (!this.#shouldDecrementIndent(token)) { + return; + } + + const ttl = token.type.label; + this.#indentLevel--; + if (ttl == "}" && this.#stack.at(-2) == "switch") { + this.#indentLevel--; + } + } + + /** + * Add a comment to the pretty printed code. + * + * @param Number indentLevel + * The number of indents deep we are (might be different from this.#indentLevel). + * @param Boolean block + * True if the comment is a multiline block style comment. + * @param String text + * The text of the comment. + * @param Number line + * The line number to comment appeared on. + * @param Object nextToken + * The next token if any. + */ + #addComment(indentLevel, block, text, line, nextToken) { + const indentString = this.#indentChar.repeat(indentLevel); + const needNewLineAfter = + !block || !(nextToken && nextToken.loc.start.line == line); + + if (block) { + const commentLinesText = text + .split(new RegExp(`/\n${indentString}/`, "g")) + .join(`\n${indentString}`); + + this.#write( + `${indentString}/*${commentLinesText}*/${needNewLineAfter ? "\n" : " "}` + ); + } else { + this.#write(`${indentString}//${text}\n`); + } + + this.#addedNewline = needNewLineAfter; + this.#addedSpace = !needNewLineAfter; + } + + /** + * Add the required whitespace before this token, whether that is a single + * space, newline, and/or the indent on fresh lines. + * + * @param Object token + * The token we are about to add to the pretty printed code. + */ + #prependWhiteSpace(token) { + const ttk = token.type.keyword; + const ttl = token.type.label; + let newlineAdded = this.#addedNewline; + let spaceAdded = this.#addedSpace; + const ltt = this.#previousToken?.type?.label; + + // Handle whitespace and newlines after "}" here instead of in + // `isLineDelimiter` because it is only a line delimiter some of the + // time. For example, we don't want to put "else if" on a new line after + // the first if's block. + if (this.#previousToken && ltt == "}") { + if ( + (ttk == "while" && this.#stack.at(-1) == "do") || + needsSpaceBeforeClosingCurlyBracket(ttk) + ) { + this.#write(" "); + spaceAdded = true; + } else if (needsLineBreakBeforeClosingCurlyBracket(ttl)) { + this.#write("\n"); + newlineAdded = true; + } + } + + if ( + (ttl == ":" && this.#stack.at(-1) == "?") || + (ttl == "}" && this.#stack.at(-1) == "${") + ) { + this.#write(" "); + spaceAdded = true; + } + + if (this.#previousToken && ltt != "}" && ltt != "." && ttk == "else") { + this.#write(" "); + spaceAdded = true; + } + + const ensureNewline = () => { + if (!newlineAdded) { + this.#write("\n"); + newlineAdded = true; + } + }; + + if (isASI(token, this.#previousToken)) { + ensureNewline(); + } + + if (this.#shouldDecrementIndent(token)) { + ensureNewline(); + } + + if (newlineAdded) { + let indentLevel = this.#indentLevel; + if (ttk == "case" || ttk == "default") { + indentLevel--; + } + this.#write(this.#indentChar.repeat(indentLevel)); + } else if (!spaceAdded && needsSpaceAfter(token, this.#previousToken)) { + this.#write(" "); + spaceAdded = true; + } + } + + /** + * Append the necessary whitespace to the result after we have added the given + * token. + * + * @param Object token + * The token that was just added to the result. + */ + #maybeAppendNewline(token) { + if (!isLineDelimiter(token, this.#stack)) { + this.#addedNewline = false; + return; + } + + this.#write("\n"); + this.#addedNewline = true; + } +} + +/** + * Determines if we think that the given token starts an array literal. + * + * @param Object token + * The token we want to determine if it is an array literal. + * @param Object previousToken + * The previous token we added to the pretty printed results. + * + * @returns Boolean + * True if we believe it is an array literal, false otherwise. + */ +function isArrayLiteral(token, previousToken) { + if (token.type.label != "[") { + return false; + } + if (!previousToken) { + return true; + } + if (previousToken.type.isAssign) { + return true; + } + + return PRE_ARRAY_LITERAL_TOKENS.has( + previousToken.type.keyword || + // Some tokens ('of', 'yield', …) have a `token.type.keyword` of 'name' and their + // actual value in `token.value` + (previousToken.type.label == "name" + ? previousToken.value + : previousToken.type.label) + ); +} + +/** + * Determines if we think that the given token starts an object literal. + * + * @param Object token + * The token we want to determine if it is an object literal. + * @param Object previousToken + * The previous token we added to the pretty printed results. + * + * @returns Boolean + * True if we believe it is an object literal, false otherwise. + */ +function isObjectLiteral(token, previousToken) { + if (token.type.label != "{") { + return false; + } + if (!previousToken) { + return false; + } + if (previousToken.type.isAssign) { + return true; + } + return PRE_OBJECT_LITERAL_TOKENS.has( + previousToken.type.keyword || previousToken.type.label + ); +} + +/** + * Determines if we think that the given token starts a long parenthesis + * + * @param {Object} token + * The token we want to determine if it is the beginning of a long paren. + * @param {Array<Object>} tokenQueue + * The whole list of tokens parsed by acorn + * @param {Integer} currentTokenIndex + * The index of `token` in `tokenQueue` + * @returns + */ +function isRoundBracketStartingLongParenthesis( + token, + tokenQueue, + currentTokenIndex +) { + if (token.type.label !== "(") { + return false; + } + + // If we're just wrapping an object, we'll have a new line right after + if (tokenQueue[currentTokenIndex + 1].type.label == "{") { + return false; + } + + // We're going to iterate through the following tokens until : + // - we find the closing parent + // - or we reached the maximum character we think should be in parenthesis + const longParentContentLength = 60; + + // Keep track of other parens so we know when we get the closing one for `token` + let parenCount = 0; + let parenContentLength = 0; + for (let i = currentTokenIndex + 1, len = tokenQueue.length; i < len; i++) { + const currToken = tokenQueue[i]; + const ttl = currToken.type.label; + + if (ttl == "(") { + parenCount++; + } else if (ttl == ")") { + if (parenCount == 0) { + // Matching closing paren, if we got here, we didn't reach the length limit, + // as we return when parenContentLength is greater than the limit. + return false; + } + parenCount--; + } + + // Aside block comments, all tokens start and end location are on the same line, so + // we can use `start` and `end` to deduce the token length. + const tokenLength = currToken.comment + ? currToken.text.length + : currToken.end - currToken.start; + parenContentLength += tokenLength; + + // If we didn't find the matching closing paren yet and the characters from the + // tokens we evaluated so far are longer than the limit, so consider the token + // a long paren. + if (parenContentLength > longParentContentLength) { + return true; + } + } + + // if we get to here, we didn't found a closing paren, which shouldn't happen + // (scripts with syntax error are not displayed in the debugger), but just to + // be safe, return false. + return false; +} + +// If any of these tokens are followed by a token on a new line, we know that +// ASI cannot happen. +const PREVENT_ASI_AFTER_TOKENS = new Set([ + // Binary operators + "*", + "/", + "%", + "+", + "-", + "<<", + ">>", + ">>>", + "<", + ">", + "<=", + ">=", + "instanceof", + "in", + "==", + "!=", + "===", + "!==", + "&", + "^", + "|", + "&&", + "||", + ",", + ".", + "=", + "*=", + "/=", + "%=", + "+=", + "-=", + "<<=", + ">>=", + ">>>=", + "&=", + "^=", + "|=", + // Unary operators + "delete", + "void", + "typeof", + "~", + "!", + "new", + // Function calls and grouped expressions + "(", +]); + +// If any of these tokens are on a line after the token before it, we know +// that ASI cannot happen. +const PREVENT_ASI_BEFORE_TOKENS = new Set([ + // Binary operators + "*", + "/", + "%", + "<<", + ">>", + ">>>", + "<", + ">", + "<=", + ">=", + "instanceof", + "in", + "==", + "!=", + "===", + "!==", + "&", + "^", + "|", + "&&", + "||", + ",", + ".", + "=", + "*=", + "/=", + "%=", + "+=", + "-=", + "<<=", + ">>=", + ">>>=", + "&=", + "^=", + "|=", + // Function calls + "(", +]); + +/** + * Determine if a token can look like an identifier. More precisely, + * this determines if the token may end or start with a character from + * [A-Za-z0-9_]. + * + * @param Object token + * The token we are looking at. + * + * @returns Boolean + * True if identifier-like. + */ +function isIdentifierLike(token) { + const ttl = token.type.label; + return ( + ttl == "name" || ttl == "num" || ttl == "privateId" || !!token.type.keyword + ); +} + +/** + * Determines if Automatic Semicolon Insertion (ASI) occurs between these + * tokens. + * + * @param Object token + * The current token. + * @param Object previousToken + * The previous token we added to the pretty printed results. + * + * @returns Boolean + * True if we believe ASI occurs. + */ +function isASI(token, previousToken) { + if (!previousToken) { + return false; + } + if (token.loc.start.line === previousToken.loc.start.line) { + return false; + } + if ( + previousToken.type.keyword == "return" || + previousToken.type.keyword == "yield" || + (previousToken.type.label == "name" && previousToken.value == "yield") + ) { + return true; + } + if ( + PREVENT_ASI_AFTER_TOKENS.has( + previousToken.type.label || previousToken.type.keyword + ) + ) { + return false; + } + if (PREVENT_ASI_BEFORE_TOKENS.has(token.type.label || token.type.keyword)) { + return false; + } + return true; +} + +/** + * Determine if we should add a newline after the given token. + * + * @param Object token + * The token we are looking at. + * @param Array stack + * The stack of open parens/curlies/brackets/etc. + * + * @returns Boolean + * True if we should add a newline. + */ +function isLineDelimiter(token, stack) { + const ttl = token.type.label; + const top = stack.at(-1); + return ( + (ttl == ";" && top != "(") || + // Don't add a new line for empty object literals + (ttl == "{" && top == "{\n") || + // Don't add a new line for empty array literals + (ttl == "[" && top == "[\n") || + ((ttl == "," || ttl == "||" || ttl == "&&") && top != "(") || + (ttl == ":" && (top == "case" || top == "default")) || + (ttl == "(" && top == "(\n") + ); +} + +/** + * Determines if we need to add a space after the token we are about to add. + * + * @param Object token + * The token we are about to add to the pretty printed code. + * @param Object [previousToken] + * Optional previous token added to the pretty printed code. + */ +function needsSpaceAfter(token, previousToken) { + if (previousToken && needsSpaceBetweenTokens(token, previousToken)) { + return true; + } + + if (token.type.isAssign) { + return true; + } + if (token.type.binop != null && previousToken) { + return true; + } + if (token.type.label == "?") { + return true; + } + if (token.type.label == "=>") { + return true; + } + + return false; +} + +function needsSpaceBeforePreviousToken(previousToken) { + if (previousToken.type.isLoop) { + return true; + } + if (previousToken.type.isAssign) { + return true; + } + if (previousToken.type.binop != null) { + return true; + } + if (previousToken.value == "of") { + return true; + } + + const previousTokenTypeLabel = previousToken.type.label; + if (previousTokenTypeLabel == "?") { + return true; + } + if (previousTokenTypeLabel == ":") { + return true; + } + if (previousTokenTypeLabel == ",") { + return true; + } + if (previousTokenTypeLabel == ";") { + return true; + } + if (previousTokenTypeLabel == "${") { + return true; + } + if (previousTokenTypeLabel == "=>") { + return true; + } + return false; +} + +function isBreakContinueOrReturnStatement(previousTokenKeyword) { + return ( + previousTokenKeyword == "break" || + previousTokenKeyword == "continue" || + previousTokenKeyword == "return" + ); +} + +function needsSpaceBeforePreviousTokenKeywordAfterNotDot(previousTokenKeyword) { + return ( + previousTokenKeyword != "debugger" && + previousTokenKeyword != "null" && + previousTokenKeyword != "true" && + previousTokenKeyword != "false" && + previousTokenKeyword != "this" && + previousTokenKeyword != "default" + ); +} + +function needsSpaceBeforeClosingParen(tokenTypeLabel) { + return ( + tokenTypeLabel != ")" && + tokenTypeLabel != "]" && + tokenTypeLabel != ";" && + tokenTypeLabel != "," && + tokenTypeLabel != "." + ); +} + +/** + * Determines if we need to add a space between the previous token we added and + * the token we are about to add. + * + * @param Object token + * The token we are about to add to the pretty printed code. + * @param Object previousToken + * The previous token added to the pretty printed code. + */ +function needsSpaceBetweenTokens(token, previousToken) { + if (needsSpaceBeforePreviousToken(previousToken)) { + return true; + } + + const ltt = previousToken.type.label; + if (ltt == "num" && token.type.label == ".") { + return true; + } + + const ltk = previousToken.type.keyword; + const ttl = token.type.label; + if (ltk != null && ttl != ".") { + if (isBreakContinueOrReturnStatement(ltk)) { + return ttl != ";"; + } + if (needsSpaceBeforePreviousTokenKeywordAfterNotDot(ltk)) { + return true; + } + } + + if (ltt == ")" && needsSpaceBeforeClosingParen(ttl)) { + return true; + } + + if (isIdentifierLike(token) && isIdentifierLike(previousToken)) { + // We must emit a space to avoid merging the tokens. + return true; + } + + if (token.type.label == "{" && previousToken.type.label == "name") { + return true; + } + + return false; +} + +function needsSpaceBeforeClosingCurlyBracket(tokenTypeKeyword) { + return ( + tokenTypeKeyword == "else" || + tokenTypeKeyword == "catch" || + tokenTypeKeyword == "finally" + ); +} + +function needsLineBreakBeforeClosingCurlyBracket(tokenTypeLabel) { + return ( + tokenTypeLabel != "(" && + tokenTypeLabel != ";" && + tokenTypeLabel != "," && + tokenTypeLabel != ")" && + tokenTypeLabel != "." && + tokenTypeLabel != "template" && + tokenTypeLabel != "`" + ); +} + +const escapeCharacters = { + // Backslash + "\\": "\\\\", + // Newlines + "\n": "\\n", + // Carriage return + "\r": "\\r", + // Tab + "\t": "\\t", + // Vertical tab + "\v": "\\v", + // Form feed + "\f": "\\f", + // Null character + "\0": "\\x00", + // Line separator + "\u2028": "\\u2028", + // Paragraph separator + "\u2029": "\\u2029", + // Single quotes + "'": "\\'", +}; + +// eslint-disable-next-line prefer-template +const regExpString = "(" + Object.values(escapeCharacters).join("|") + ")"; +const escapeCharactersRegExp = new RegExp(regExpString, "g"); + +function sanitizerReplaceFunc(_, c) { + return escapeCharacters[c]; +} + +/** + * Make sure that we output the escaped character combination inside string + * literals instead of various problematic characters. + */ +function sanitize(str) { + return str.replace(escapeCharactersRegExp, sanitizerReplaceFunc); +} + +/** + * Returns true if the given token type belongs on the stack. + */ +function belongsOnStack(token) { + const ttl = token.type.label; + const ttk = token.type.keyword; + return ( + ttl == "{" || + ttl == "(" || + ttl == "[" || + ttl == "?" || + ttl == "${" || + ttk == "do" || + ttk == "switch" || + ttk == "case" || + ttk == "default" + ); +} diff --git a/devtools/client/debugger/src/workers/pretty-print/tests/__snapshots__/prettyFast.spec.js.snap b/devtools/client/debugger/src/workers/pretty-print/tests/__snapshots__/prettyFast.spec.js.snap new file mode 100644 index 0000000000..498bee267e --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/tests/__snapshots__/prettyFast.spec.js.snap @@ -0,0 +1,1974 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ASI return 1`] = ` +"function f() { + return + { + } +} +" +`; + +exports[`ASI return 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 9) -> (1, 9)", + "(1, 10) -> (1, 10)", + "(1, 11) -> (1, 11)", + "(1, 13) -> (1, 13)", + "(2, 2) -> (2, 13)", + "(3, 2) -> (3, 13)", + "(4, 2) -> (3, 14)", + "(5, 0) -> (4, 11)", +] +`; + +exports[`Arrays 1`] = ` +"var a = [ + 1, + 2, + 3 +]; +" +`; + +exports[`Arrays 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(2, 2) -> (1, 7)", + "(2, 3) -> (1, 8)", + "(3, 2) -> (1, 9)", + "(3, 3) -> (1, 10)", + "(4, 2) -> (1, 11)", + "(5, 0) -> (1, 12)", + "(5, 1) -> (1, 13)", +] +`; + +exports[`Arrays and spread operator 1`] = ` +"var a = [ + 1, + ...[ + 2, + 3 + ], + ...[], + 4 +]; +" +`; + +exports[`Arrays and spread operator 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(2, 2) -> (1, 7)", + "(2, 3) -> (1, 8)", + "(3, 2) -> (1, 9)", + "(3, 5) -> (1, 12)", + "(4, 4) -> (1, 13)", + "(4, 5) -> (1, 14)", + "(5, 4) -> (1, 15)", + "(6, 2) -> (1, 16)", + "(6, 3) -> (1, 17)", + "(7, 2) -> (1, 18)", + "(7, 5) -> (1, 21)", + "(7, 6) -> (1, 22)", + "(7, 7) -> (1, 23)", + "(8, 2) -> (1, 25)", + "(9, 0) -> (1, 26)", + "(9, 1) -> (1, 27)", +] +`; + +exports[`Binary operators 1`] = ` +"var a = 5 * 30; +var b = 5 >> 3; +" +`; + +exports[`Binary operators 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 10) -> (1, 7)", + "(1, 12) -> (1, 8)", + "(1, 14) -> (1, 10)", + "(2, 0) -> (1, 11)", + "(2, 4) -> (1, 15)", + "(2, 6) -> (1, 16)", + "(2, 8) -> (1, 17)", + "(2, 10) -> (1, 18)", + "(2, 13) -> (1, 20)", + "(2, 14) -> (1, 21)", +] +`; + +exports[`Bug 975477 don't move end of line comments to next line 1`] = ` +"switch (request.action) { + case 'show': //$NON-NLS-0$ + if (localStorage.hideicon !== 'true') { //$NON-NLS-0$ + chrome.pageAction.show(sender.tab.id); + } + break; + case 'hide': /*Multiline + Comment */ + break; + default: + console.warn('unknown request'); //$NON-NLS-0$ + // don't respond if you don't understand the message. + return; +} +" +`; + +exports[`Bug 975477 don't move end of line comments to next line 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 7) -> (1, 7)", + "(1, 8) -> (1, 8)", + "(1, 15) -> (1, 15)", + "(1, 16) -> (1, 16)", + "(1, 22) -> (1, 22)", + "(1, 24) -> (1, 24)", + "(2, 2) -> (2, 13)", + "(2, 7) -> (2, 18)", + "(2, 13) -> (2, 24)", + "(3, 4) -> (3, 15)", + "(3, 7) -> (3, 18)", + "(3, 8) -> (3, 19)", + "(3, 20) -> (3, 31)", + "(3, 21) -> (3, 32)", + "(3, 30) -> (3, 41)", + "(3, 34) -> (3, 45)", + "(3, 40) -> (3, 51)", + "(3, 42) -> (3, 53)", + "(4, 6) -> (4, 17)", + "(4, 12) -> (4, 23)", + "(4, 13) -> (4, 24)", + "(4, 23) -> (4, 34)", + "(4, 24) -> (4, 35)", + "(4, 28) -> (4, 39)", + "(4, 29) -> (4, 40)", + "(4, 35) -> (4, 46)", + "(4, 36) -> (4, 47)", + "(4, 39) -> (4, 50)", + "(4, 40) -> (4, 51)", + "(4, 42) -> (4, 53)", + "(4, 43) -> (4, 54)", + "(5, 4) -> (5, 15)", + "(6, 4) -> (6, 15)", + "(6, 9) -> (6, 20)", + "(7, 2) -> (7, 13)", + "(7, 7) -> (7, 18)", + "(7, 13) -> (7, 24)", + "(9, 4) -> (9, 15)", + "(9, 9) -> (9, 20)", + "(10, 2) -> (10, 13)", + "(10, 9) -> (10, 20)", + "(11, 4) -> (11, 15)", + "(11, 11) -> (11, 22)", + "(11, 12) -> (11, 23)", + "(11, 16) -> (11, 27)", + "(11, 17) -> (11, 28)", + "(11, 34) -> (11, 45)", + "(11, 35) -> (11, 46)", + "(13, 4) -> (13, 15)", + "(13, 10) -> (13, 21)", + "(14, 0) -> (14, 11)", +] +`; + +exports[`Bug 977082 - space between grouping operator and dot notation 1`] = ` +"JSON.stringify(3).length; +([1, +2, +3]).length; +(new Date()).toLocaleString(); +" +`; + +exports[`Bug 977082 - space between grouping operator and dot notation 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 5) -> (1, 5)", + "(1, 14) -> (1, 14)", + "(1, 15) -> (1, 15)", + "(1, 16) -> (1, 16)", + "(1, 17) -> (1, 17)", + "(1, 18) -> (1, 18)", + "(1, 24) -> (1, 24)", + "(2, 0) -> (2, 11)", + "(2, 1) -> (2, 12)", + "(2, 2) -> (2, 13)", + "(2, 3) -> (2, 14)", + "(3, 0) -> (2, 15)", + "(3, 1) -> (2, 16)", + "(4, 0) -> (2, 17)", + "(4, 1) -> (2, 18)", + "(4, 2) -> (2, 19)", + "(4, 3) -> (2, 20)", + "(4, 4) -> (2, 21)", + "(4, 10) -> (2, 27)", + "(5, 0) -> (3, 11)", + "(5, 1) -> (3, 12)", + "(5, 5) -> (3, 16)", + "(5, 9) -> (3, 20)", + "(5, 10) -> (3, 21)", + "(5, 11) -> (3, 22)", + "(5, 12) -> (3, 23)", + "(5, 13) -> (3, 24)", + "(5, 27) -> (3, 38)", + "(5, 28) -> (3, 39)", + "(5, 29) -> (3, 40)", +] +`; + +exports[`Bug 1206633 - spaces in for of 1`] = ` +"for (let tab of tabs) { +} +" +`; + +exports[`Bug 1206633 - spaces in for of 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 5) -> (1, 5)", + "(1, 9) -> (1, 9)", + "(1, 13) -> (1, 13)", + "(1, 16) -> (1, 16)", + "(1, 20) -> (1, 20)", + "(1, 22) -> (1, 22)", + "(2, 0) -> (1, 23)", +] +`; + +exports[`Bug 1261971 - indentation after switch statement 1`] = ` +"{ + switch (x) { + } + if (y) { + } + done(); +} +" +`; + +exports[`Bug 1261971 - indentation after switch statement 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(2, 2) -> (1, 1)", + "(2, 9) -> (1, 7)", + "(2, 10) -> (1, 8)", + "(2, 11) -> (1, 9)", + "(2, 13) -> (1, 10)", + "(3, 2) -> (1, 11)", + "(4, 2) -> (1, 12)", + "(4, 5) -> (1, 14)", + "(4, 6) -> (1, 15)", + "(4, 7) -> (1, 16)", + "(4, 9) -> (1, 17)", + "(5, 2) -> (1, 18)", + "(6, 2) -> (1, 19)", + "(6, 6) -> (1, 23)", + "(6, 7) -> (1, 24)", + "(6, 8) -> (1, 25)", + "(7, 0) -> (1, 26)", +] +`; + +exports[`Bug pretty-sure-3 - escaping line and paragraph separators 1`] = ` +"x = '\\\\u2029\\\\u2028'; +" +`; + +exports[`Bug pretty-sure-3 - escaping line and paragraph separators 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 2) -> (1, 2)", + "(1, 4) -> (1, 4)", + "(1, 18) -> (1, 18)", +] +`; + +exports[`Bug pretty-sure-4 - escaping null character before digit 1`] = ` +"x = '\\\\x001'; +" +`; + +exports[`Bug pretty-sure-4 - escaping null character before digit 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 2) -> (1, 2)", + "(1, 4) -> (1, 4)", + "(1, 11) -> (1, 13)", +] +`; + +exports[`Bug pretty-sure-5 - empty multiline comment shouldn't throw exception 1`] = ` +"{ + /* + */ + return; +} +" +`; + +exports[`Bug pretty-sure-5 - empty multiline comment shouldn't throw exception 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(4, 2) -> (4, 13)", + "(4, 8) -> (4, 19)", + "(5, 0) -> (5, 11)", +] +`; + +exports[`Bug pretty-sure-6 - inline comment shouldn't move parenthesis to next line 1`] = ` +"return /* inline comment */ (1 + 1); +" +`; + +exports[`Bug pretty-sure-6 - inline comment shouldn't move parenthesis to next line 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 28) -> (1, 28)", + "(1, 29) -> (2, 13)", + "(1, 31) -> (2, 14)", + "(1, 33) -> (2, 15)", + "(1, 34) -> (2, 16)", + "(1, 35) -> (2, 17)", +] +`; + +exports[`Bug pretty-sure-7 - accessing a literal number property requires a space 1`] = ` +"0 .toString() + x.toString(); +" +`; + +exports[`Bug pretty-sure-7 - accessing a literal number property requires a space 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 2) -> (1, 2)", + "(1, 3) -> (1, 3)", + "(1, 11) -> (1, 11)", + "(1, 12) -> (1, 12)", + "(1, 14) -> (1, 13)", + "(1, 16) -> (1, 14)", + "(1, 17) -> (1, 15)", + "(1, 18) -> (1, 16)", + "(1, 26) -> (1, 24)", + "(1, 27) -> (1, 25)", + "(1, 28) -> (1, 26)", +] +`; + +exports[`Bug pretty-sure-8 - return and yield only accept arguments when on the same line 1`] = ` +"{ + return + (x) + yield + (x) + yield + * x +} +" +`; + +exports[`Bug pretty-sure-8 - return and yield only accept arguments when on the same line 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(2, 2) -> (2, 13)", + "(3, 2) -> (3, 13)", + "(3, 3) -> (3, 14)", + "(3, 4) -> (3, 15)", + "(4, 2) -> (4, 13)", + "(5, 2) -> (5, 13)", + "(5, 3) -> (5, 14)", + "(5, 4) -> (5, 15)", + "(6, 2) -> (6, 13)", + "(7, 2) -> (7, 13)", + "(7, 4) -> (7, 14)", + "(8, 0) -> (8, 11)", +] +`; + +exports[`Bug pretty-sure-9 - accept unary operator at start of file 1`] = ` +"+ 0 +" +`; + +exports[`Bug pretty-sure-9 - accept unary operator at start of file 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 2) -> (1, 2)", +] +`; + +exports[`Class extension within a function 1`] = ` +"(function () { + class X extends Y { + constructor() { + } + } +}) () +" +`; + +exports[`Class extension within a function 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 10) -> (1, 9)", + "(1, 11) -> (1, 10)", + "(1, 13) -> (1, 12)", + "(2, 2) -> (1, 15)", + "(2, 8) -> (1, 21)", + "(2, 10) -> (1, 23)", + "(2, 18) -> (1, 31)", + "(2, 20) -> (1, 33)", + "(3, 4) -> (1, 35)", + "(3, 15) -> (1, 46)", + "(3, 16) -> (1, 47)", + "(3, 18) -> (1, 49)", + "(4, 4) -> (1, 50)", + "(5, 2) -> (1, 52)", + "(6, 0) -> (1, 55)", + "(6, 1) -> (1, 56)", + "(6, 3) -> (1, 57)", + "(6, 4) -> (1, 58)", +] +`; + +exports[`Class handling 1`] = ` +"class Class { + constructor() { + } +} +" +`; + +exports[`Class handling 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 6) -> (1, 7)", + "(1, 12) -> (1, 12)", + "(2, 2) -> (1, 13)", + "(2, 13) -> (1, 24)", + "(2, 14) -> (1, 25)", + "(2, 16) -> (1, 26)", + "(3, 2) -> (1, 27)", + "(4, 0) -> (1, 28)", +] +`; + +exports[`Code that relies on ASI 1`] = ` +"var foo = 10 +var bar = 20 +function g() { + a() + b() +} +" +`; + +exports[`Code that relies on ASI 2`] = ` +Array [ + "(1, 0) -> (2, 11)", + "(1, 4) -> (2, 15)", + "(1, 8) -> (2, 19)", + "(1, 10) -> (2, 21)", + "(2, 0) -> (3, 11)", + "(2, 4) -> (3, 15)", + "(2, 8) -> (3, 19)", + "(2, 10) -> (3, 21)", + "(3, 0) -> (4, 11)", + "(3, 9) -> (4, 20)", + "(3, 10) -> (4, 21)", + "(3, 11) -> (4, 22)", + "(3, 13) -> (4, 24)", + "(4, 2) -> (5, 13)", + "(4, 3) -> (5, 14)", + "(4, 4) -> (5, 15)", + "(5, 2) -> (6, 13)", + "(5, 3) -> (6, 14)", + "(5, 4) -> (6, 15)", + "(6, 0) -> (7, 11)", +] +`; + +exports[`Const handling 1`] = ` +"const d = 'yes'; +" +`; + +exports[`Const handling 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 6) -> (1, 6)", + "(1, 8) -> (1, 8)", + "(1, 10) -> (1, 10)", + "(1, 15) -> (1, 15)", +] +`; + +exports[`Continue/break statements 1`] = ` +"while (1) { + if (x) { + continue + } + if (y) { + break + } + if (z) { + break foo + } +} +" +`; + +exports[`Continue/break statements 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 6) -> (1, 5)", + "(1, 7) -> (1, 6)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(2, 2) -> (1, 9)", + "(2, 5) -> (1, 11)", + "(2, 6) -> (1, 12)", + "(2, 7) -> (1, 13)", + "(2, 9) -> (1, 14)", + "(3, 4) -> (1, 15)", + "(4, 2) -> (1, 23)", + "(5, 2) -> (1, 24)", + "(5, 5) -> (1, 26)", + "(5, 6) -> (1, 27)", + "(5, 7) -> (1, 28)", + "(5, 9) -> (1, 29)", + "(6, 4) -> (1, 30)", + "(7, 2) -> (1, 35)", + "(8, 2) -> (1, 36)", + "(8, 5) -> (1, 38)", + "(8, 6) -> (1, 39)", + "(8, 7) -> (1, 40)", + "(8, 9) -> (1, 41)", + "(9, 4) -> (1, 42)", + "(9, 10) -> (1, 48)", + "(10, 2) -> (1, 51)", + "(11, 0) -> (1, 52)", +] +`; + +exports[`Delete 1`] = ` +"delete obj.prop; +" +`; + +exports[`Delete 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 7) -> (1, 7)", + "(1, 10) -> (1, 10)", + "(1, 11) -> (1, 11)", + "(1, 15) -> (1, 15)", +] +`; + +exports[`Do/while loop 1`] = ` +"do { + x +} while (y) +" +`; + +exports[`Do/while loop 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 3) -> (1, 2)", + "(2, 2) -> (1, 3)", + "(3, 0) -> (1, 4)", + "(3, 2) -> (1, 5)", + "(3, 8) -> (1, 10)", + "(3, 9) -> (1, 11)", + "(3, 10) -> (1, 12)", +] +`; + +exports[`Dot handling with keywords which are identifier name 1`] = ` +"y.await.break.const.delete.else.return.new.yield = 1.23; +" +`; + +exports[`Dot handling with keywords which are identifier name 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 2) -> (1, 2)", + "(1, 7) -> (1, 7)", + "(1, 8) -> (1, 8)", + "(1, 13) -> (1, 13)", + "(1, 14) -> (1, 14)", + "(1, 19) -> (1, 19)", + "(1, 20) -> (1, 20)", + "(1, 26) -> (1, 26)", + "(1, 27) -> (1, 27)", + "(1, 31) -> (1, 31)", + "(1, 32) -> (1, 32)", + "(1, 38) -> (1, 38)", + "(1, 39) -> (1, 39)", + "(1, 42) -> (1, 42)", + "(1, 43) -> (1, 43)", + "(1, 49) -> (1, 49)", + "(1, 51) -> (1, 51)", + "(1, 55) -> (1, 55)", +] +`; + +exports[`Dot handling with let which is identifier name 1`] = ` +"y.let.let = 1.23; +" +`; + +exports[`Dot handling with let which is identifier name 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 2) -> (1, 2)", + "(1, 5) -> (1, 5)", + "(1, 6) -> (1, 6)", + "(1, 10) -> (1, 10)", + "(1, 12) -> (1, 12)", + "(1, 16) -> (1, 16)", +] +`; + +exports[`Empty object/array literals 1`] = ` +"let a = []; +const b = {}; +c = { + ...{}, + d: 42 +}; +for (let x of []) { + for (let y in {}) { + } +} +" +`; + +exports[`Empty object/array literals 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 9) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(2, 0) -> (1, 9)", + "(2, 6) -> (1, 15)", + "(2, 8) -> (1, 16)", + "(2, 10) -> (1, 17)", + "(2, 11) -> (1, 18)", + "(2, 12) -> (1, 19)", + "(3, 0) -> (1, 20)", + "(3, 2) -> (1, 21)", + "(3, 4) -> (1, 22)", + "(4, 2) -> (1, 23)", + "(4, 5) -> (1, 26)", + "(4, 6) -> (1, 27)", + "(4, 7) -> (1, 28)", + "(5, 2) -> (1, 29)", + "(5, 3) -> (1, 30)", + "(5, 5) -> (1, 32)", + "(6, 0) -> (1, 34)", + "(6, 1) -> (1, 35)", + "(7, 0) -> (1, 36)", + "(7, 4) -> (1, 39)", + "(7, 5) -> (1, 40)", + "(7, 9) -> (1, 44)", + "(7, 11) -> (1, 46)", + "(7, 14) -> (1, 49)", + "(7, 15) -> (1, 50)", + "(7, 16) -> (1, 51)", + "(7, 18) -> (1, 52)", + "(8, 2) -> (1, 53)", + "(8, 6) -> (1, 56)", + "(8, 7) -> (1, 57)", + "(8, 11) -> (1, 61)", + "(8, 13) -> (1, 63)", + "(8, 16) -> (1, 66)", + "(8, 17) -> (1, 67)", + "(8, 18) -> (1, 68)", + "(8, 20) -> (1, 69)", + "(9, 2) -> (1, 70)", + "(10, 0) -> (1, 71)", +] +`; + +exports[`Escaping backslashes in strings 1`] = ` +"'\\\\\\\\' +" +`; + +exports[`Escaping backslashes in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`Escaping carriage return in strings 1`] = ` +"'\\\\r' +" +`; + +exports[`Escaping carriage return in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`Escaping form feed in strings 1`] = ` +"'\\\\f' +" +`; + +exports[`Escaping form feed in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`Escaping null character in strings 1`] = ` +"'\\\\x00' +" +`; + +exports[`Escaping null character in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`Escaping tab in strings 1`] = ` +"'\\\\t' +" +`; + +exports[`Escaping tab in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`Escaping vertical tab in strings 1`] = ` +"'\\\\v' +" +`; + +exports[`Escaping vertical tab in strings 2`] = ` +Array [ + "(1, 0) -> (1, 0)", +] +`; + +exports[`False assignment 1`] = ` +"var foo = false; +" +`; + +exports[`False assignment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(1, 15) -> (1, 13)", +] +`; + +exports[`Fat arrow function 1`] = ` +"const a = () => 42 +addEventListener('click', e => { + return false +}); +const sum = (c, d) => c + d +" +`; + +exports[`Fat arrow function 2`] = ` +Array [ + "(1, 0) -> (2, 6)", + "(1, 6) -> (2, 12)", + "(1, 8) -> (2, 14)", + "(1, 10) -> (2, 16)", + "(1, 11) -> (2, 17)", + "(1, 13) -> (2, 19)", + "(1, 16) -> (2, 22)", + "(2, 0) -> (3, 6)", + "(2, 16) -> (3, 22)", + "(2, 17) -> (3, 23)", + "(2, 24) -> (3, 30)", + "(2, 26) -> (3, 32)", + "(2, 28) -> (3, 34)", + "(2, 31) -> (3, 37)", + "(3, 2) -> (3, 39)", + "(3, 9) -> (3, 46)", + "(4, 0) -> (3, 52)", + "(4, 1) -> (3, 53)", + "(4, 2) -> (3, 54)", + "(5, 0) -> (4, 6)", + "(5, 6) -> (4, 12)", + "(5, 10) -> (4, 16)", + "(5, 12) -> (4, 18)", + "(5, 13) -> (4, 19)", + "(5, 14) -> (4, 20)", + "(5, 16) -> (4, 21)", + "(5, 17) -> (4, 22)", + "(5, 19) -> (4, 24)", + "(5, 22) -> (4, 27)", + "(5, 24) -> (4, 28)", + "(5, 26) -> (4, 29)", +] +`; + +exports[`For loop 1`] = ` +"for (var i = 0; i < n; i++) { + console.log(i); +} +" +`; + +exports[`For loop 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 5) -> (1, 5)", + "(1, 9) -> (1, 9)", + "(1, 11) -> (1, 11)", + "(1, 13) -> (1, 13)", + "(1, 14) -> (1, 14)", + "(1, 16) -> (1, 16)", + "(1, 18) -> (1, 18)", + "(1, 20) -> (1, 20)", + "(1, 21) -> (1, 21)", + "(1, 23) -> (1, 23)", + "(1, 24) -> (1, 24)", + "(1, 26) -> (1, 26)", + "(1, 28) -> (1, 28)", + "(2, 2) -> (1, 30)", + "(2, 9) -> (1, 37)", + "(2, 10) -> (1, 38)", + "(2, 13) -> (1, 41)", + "(2, 14) -> (1, 42)", + "(2, 15) -> (1, 43)", + "(2, 16) -> (1, 44)", + "(3, 0) -> (1, 46)", +] +`; + +exports[`Function calls 1`] = ` +"var result = func(a, b, c, d); +" +`; + +exports[`Function calls 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 11) -> (1, 10)", + "(1, 13) -> (1, 11)", + "(1, 17) -> (1, 15)", + "(1, 18) -> (1, 16)", + "(1, 19) -> (1, 17)", + "(1, 21) -> (1, 18)", + "(1, 22) -> (1, 19)", + "(1, 24) -> (1, 20)", + "(1, 25) -> (1, 21)", + "(1, 27) -> (1, 22)", + "(1, 28) -> (1, 23)", + "(1, 29) -> (1, 24)", +] +`; + +exports[`Getter and setter literals 1`] = ` +"var obj = { + get foo() { + return this._foo + }, + set foo(v) { + this._foo = v + } +} +" +`; + +exports[`Getter and setter literals 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(2, 2) -> (1, 9)", + "(2, 6) -> (1, 13)", + "(2, 9) -> (1, 16)", + "(2, 10) -> (1, 17)", + "(2, 12) -> (1, 18)", + "(3, 4) -> (1, 19)", + "(3, 11) -> (1, 26)", + "(3, 15) -> (1, 30)", + "(3, 16) -> (1, 31)", + "(4, 2) -> (1, 35)", + "(4, 3) -> (1, 36)", + "(5, 2) -> (1, 37)", + "(5, 6) -> (1, 41)", + "(5, 9) -> (1, 44)", + "(5, 10) -> (1, 45)", + "(5, 11) -> (1, 46)", + "(5, 13) -> (1, 47)", + "(6, 4) -> (1, 48)", + "(6, 8) -> (1, 52)", + "(6, 9) -> (1, 53)", + "(6, 14) -> (1, 57)", + "(6, 16) -> (1, 58)", + "(7, 2) -> (1, 59)", + "(8, 0) -> (1, 60)", +] +`; + +exports[`If/else statement 1`] = ` +"if (c) { + then() +} else { + other() +} +" +`; + +exports[`If/else statement 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 3) -> (1, 2)", + "(1, 4) -> (1, 3)", + "(1, 5) -> (1, 4)", + "(1, 7) -> (1, 5)", + "(2, 2) -> (1, 6)", + "(2, 6) -> (1, 10)", + "(2, 7) -> (1, 11)", + "(3, 0) -> (1, 12)", + "(3, 2) -> (1, 13)", + "(3, 7) -> (1, 17)", + "(4, 2) -> (1, 18)", + "(4, 7) -> (1, 23)", + "(4, 8) -> (1, 24)", + "(5, 0) -> (1, 25)", +] +`; + +exports[`If/else without curlies 1`] = ` +"if (c) a else b +" +`; + +exports[`If/else without curlies 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 3) -> (1, 2)", + "(1, 4) -> (1, 3)", + "(1, 5) -> (1, 4)", + "(1, 7) -> (1, 6)", + "(1, 9) -> (1, 8)", + "(1, 14) -> (1, 13)", +] +`; + +exports[`Immediately invoked function expression 1`] = ` +"(function () { + thingy() +}()) +" +`; + +exports[`Immediately invoked function expression 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 10) -> (1, 9)", + "(1, 11) -> (1, 10)", + "(1, 13) -> (1, 11)", + "(2, 2) -> (1, 12)", + "(2, 8) -> (1, 18)", + "(2, 9) -> (1, 19)", + "(3, 0) -> (1, 20)", + "(3, 1) -> (1, 21)", + "(3, 2) -> (1, 22)", + "(3, 3) -> (1, 23)", +] +`; + +exports[`In operator 1`] = ` +"if (foo in bar) { + doThing() +} +" +`; + +exports[`In operator 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 3) -> (1, 2)", + "(1, 4) -> (1, 3)", + "(1, 8) -> (1, 7)", + "(1, 11) -> (1, 10)", + "(1, 14) -> (1, 13)", + "(1, 16) -> (1, 14)", + "(2, 2) -> (1, 15)", + "(2, 9) -> (1, 22)", + "(2, 10) -> (1, 23)", + "(3, 0) -> (1, 24)", +] +`; + +exports[`Indented multiline comment 1`] = ` +"function foo() { + /** + * java doc style comment + * more comment + */ + bar(); +} +" +`; + +exports[`Indented multiline comment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 9) -> (1, 9)", + "(1, 12) -> (1, 12)", + "(1, 13) -> (1, 13)", + "(1, 15) -> (1, 15)", + "(6, 2) -> (6, 13)", + "(6, 5) -> (6, 16)", + "(6, 6) -> (6, 17)", + "(6, 7) -> (6, 18)", + "(7, 0) -> (7, 11)", +] +`; + +exports[`Instanceof 1`] = ` +"var a = x instanceof y; +" +`; + +exports[`Instanceof 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 10) -> (1, 8)", + "(1, 21) -> (1, 19)", + "(1, 22) -> (1, 20)", +] +`; + +exports[`Let handling with value 1`] = ` +"let d = 'yes'; +" +`; + +exports[`Let handling with value 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 6)", + "(1, 8) -> (1, 8)", + "(1, 13) -> (1, 13)", +] +`; + +exports[`Let handling without value 1`] = ` +"let d; +" +`; + +exports[`Let handling without value 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 5) -> (1, 5)", +] +`; + +exports[`Long parenthesis 1`] = ` +"if ( + thisIsAVeryLongVariable && + thisIsAnotherOne || + yetAnotherVeryLongVariable +) { + ( + thisIsAnotherOne = thisMayReturnNull() || + 'hi', + thisIsAVeryLongVariable = 42, + yetAnotherVeryLongVariable && + doSomething( + true /* do it well */ , + thisIsAVeryLongVariable, + thisIsAnotherOne, + yetAnotherVeryLongVariable + ) + ) +} +for ( + let thisIsAnotherVeryLongVariable = 0; + i < thisIsAnotherVeryLongVariable.length; + thisIsAnotherVeryLongVariable++ +) { +} +const x = ({ + thisIsAnotherVeryLongPropertyName: 'but should not cause the paren to be a line delimiter' +}) +" +`; + +exports[`Long parenthesis 2`] = ` +Array [ + "(1, 0) -> (2, 6)", + "(1, 3) -> (2, 9)", + "(2, 2) -> (2, 10)", + "(2, 26) -> (2, 34)", + "(3, 2) -> (2, 37)", + "(3, 19) -> (2, 54)", + "(4, 2) -> (2, 57)", + "(5, 0) -> (2, 83)", + "(5, 2) -> (2, 85)", + "(6, 2) -> (3, 8)", + "(7, 4) -> (3, 9)", + "(7, 21) -> (3, 26)", + "(7, 23) -> (3, 28)", + "(7, 40) -> (3, 45)", + "(7, 41) -> (3, 46)", + "(7, 43) -> (3, 48)", + "(8, 4) -> (3, 51)", + "(8, 8) -> (3, 55)", + "(9, 4) -> (3, 57)", + "(9, 28) -> (3, 81)", + "(9, 30) -> (3, 83)", + "(9, 32) -> (3, 85)", + "(10, 4) -> (3, 87)", + "(10, 31) -> (3, 114)", + "(11, 4) -> (3, 117)", + "(11, 15) -> (3, 128)", + "(12, 6) -> (3, 129)", + "(12, 28) -> (3, 150)", + "(13, 6) -> (3, 151)", + "(13, 29) -> (3, 174)", + "(14, 6) -> (3, 176)", + "(14, 22) -> (3, 192)", + "(15, 6) -> (3, 194)", + "(16, 4) -> (3, 220)", + "(17, 2) -> (3, 221)", + "(18, 0) -> (4, 6)", + "(19, 0) -> (5, 6)", + "(19, 4) -> (5, 10)", + "(20, 2) -> (5, 11)", + "(20, 6) -> (5, 15)", + "(20, 36) -> (5, 45)", + "(20, 38) -> (5, 47)", + "(20, 39) -> (5, 48)", + "(21, 2) -> (5, 50)", + "(21, 4) -> (5, 52)", + "(21, 6) -> (5, 54)", + "(21, 35) -> (5, 83)", + "(21, 36) -> (5, 84)", + "(21, 42) -> (5, 90)", + "(22, 2) -> (5, 92)", + "(22, 31) -> (5, 121)", + "(23, 0) -> (5, 123)", + "(23, 2) -> (5, 125)", + "(24, 0) -> (5, 126)", + "(25, 0) -> (6, 6)", + "(25, 6) -> (6, 12)", + "(25, 8) -> (6, 14)", + "(25, 10) -> (6, 16)", + "(25, 11) -> (6, 17)", + "(26, 2) -> (6, 18)", + "(26, 35) -> (6, 51)", + "(26, 37) -> (6, 53)", + "(27, 0) -> (6, 108)", + "(27, 1) -> (6, 109)", +] +`; + +exports[`Multi line comment 1`] = ` +"/* Comment + more comment */ +function foo() { + bar(); +} +" +`; + +exports[`Multi line comment 2`] = ` +Array [ + "(3, 0) -> (4, 4)", + "(3, 9) -> (4, 13)", + "(3, 12) -> (4, 16)", + "(3, 13) -> (4, 17)", + "(3, 15) -> (4, 19)", + "(4, 2) -> (4, 21)", + "(4, 5) -> (4, 24)", + "(4, 6) -> (4, 25)", + "(4, 7) -> (4, 26)", + "(5, 0) -> (4, 28)", +] +`; + +exports[`Multiple single line comments 1`] = ` +"function f() { + // a + // b + // c +} +" +`; + +exports[`Multiple single line comments 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 9) -> (1, 9)", + "(1, 10) -> (1, 10)", + "(1, 11) -> (1, 11)", + "(1, 13) -> (1, 13)", + "(5, 0) -> (5, 11)", +] +`; + +exports[`Named class handling 1`] = ` +"let unnamed = class Class { + constructor() { + } +} +" +`; + +exports[`Named class handling 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 12) -> (1, 11)", + "(1, 14) -> (1, 12)", + "(1, 20) -> (1, 18)", + "(1, 26) -> (1, 23)", + "(2, 2) -> (1, 24)", + "(2, 13) -> (1, 35)", + "(2, 14) -> (1, 36)", + "(2, 16) -> (1, 37)", + "(3, 2) -> (1, 38)", + "(4, 0) -> (1, 39)", +] +`; + +exports[`Nested function 1`] = ` +"function foo() { + function bar() { + debugger; + } + bar(); +} +" +`; + +exports[`Nested function 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 9) -> (1, 9)", + "(1, 12) -> (1, 12)", + "(1, 13) -> (1, 13)", + "(1, 15) -> (1, 15)", + "(2, 2) -> (1, 17)", + "(2, 11) -> (1, 26)", + "(2, 14) -> (1, 29)", + "(2, 15) -> (1, 30)", + "(2, 17) -> (1, 32)", + "(3, 4) -> (1, 34)", + "(3, 12) -> (1, 42)", + "(4, 2) -> (1, 44)", + "(5, 2) -> (1, 46)", + "(5, 5) -> (1, 49)", + "(5, 6) -> (1, 50)", + "(5, 7) -> (1, 51)", + "(6, 0) -> (1, 53)", +] +`; + +exports[`New expression 1`] = ` +"var foo = new Foo(); +" +`; + +exports[`New expression 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(1, 14) -> (1, 12)", + "(1, 17) -> (1, 15)", + "(1, 18) -> (1, 16)", + "(1, 19) -> (1, 17)", +] +`; + +exports[`Non-ASI function call 1`] = ` +"f() +" +`; + +exports[`Non-ASI function call 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (2, 11)", + "(1, 2) -> (2, 12)", +] +`; + +exports[`Non-ASI in 1`] = ` +"'x' in foo +" +`; + +exports[`Non-ASI in 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (2, 11)", + "(1, 7) -> (2, 14)", +] +`; + +exports[`Non-ASI new 1`] = ` +"new F() +" +`; + +exports[`Non-ASI new 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (2, 11)", + "(1, 5) -> (2, 12)", + "(1, 6) -> (2, 13)", +] +`; + +exports[`Non-ASI property access 1`] = ` +"[ + 1, + 2, + 3 +] +[0] +" +`; + +exports[`Non-ASI property access 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(2, 2) -> (1, 1)", + "(2, 3) -> (1, 2)", + "(3, 2) -> (1, 3)", + "(3, 3) -> (1, 4)", + "(4, 2) -> (1, 5)", + "(5, 0) -> (1, 6)", + "(6, 0) -> (2, 11)", + "(6, 1) -> (2, 12)", + "(6, 2) -> (2, 13)", +] +`; + +exports[`Null assignment 1`] = ` +"var i = null; +" +`; + +exports[`Null assignment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 12) -> (1, 10)", +] +`; + +exports[`Objects 1`] = ` +"var o = { + a: 1, + b: 2 +}; +" +`; + +exports[`Objects 2`] = ` +Array [ + "(1, 0) -> (2, 6)", + "(1, 4) -> (2, 10)", + "(1, 6) -> (2, 11)", + "(1, 8) -> (2, 12)", + "(2, 2) -> (2, 13)", + "(2, 3) -> (2, 14)", + "(2, 5) -> (2, 15)", + "(2, 6) -> (2, 16)", + "(3, 2) -> (3, 6)", + "(3, 3) -> (3, 7)", + "(3, 5) -> (3, 8)", + "(4, 0) -> (3, 9)", + "(4, 1) -> (3, 10)", +] +`; + +exports[`Optional chaining parsing support 1`] = ` +"x?.y?.z?.['a']?.check(); +" +`; + +exports[`Optional chaining parsing support 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 3) -> (1, 3)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 6)", + "(1, 7) -> (1, 7)", + "(1, 9) -> (1, 9)", + "(1, 10) -> (1, 10)", + "(1, 13) -> (1, 13)", + "(1, 14) -> (1, 14)", + "(1, 16) -> (1, 16)", + "(1, 21) -> (1, 21)", + "(1, 22) -> (1, 22)", + "(1, 23) -> (1, 23)", +] +`; + +exports[`Private fields parsing support 1`] = ` +"class MyClass { + constructor(a) { + this.#a = a; + this.#b = Math.random(); + this.ab = this.#getAB(); + } + #a + #b = 'default value' + static #someStaticPrivate + #getA() { + return this.#a; + } + #getAB() { + return this.#getA() + this.#b + } +} +" +`; + +exports[`Private fields parsing support 2`] = ` +Array [ + "(1, 0) -> (2, 6)", + "(1, 6) -> (2, 12)", + "(1, 14) -> (2, 20)", + "(2, 2) -> (3, 8)", + "(2, 13) -> (3, 19)", + "(2, 14) -> (3, 20)", + "(2, 15) -> (3, 21)", + "(2, 17) -> (3, 23)", + "(3, 4) -> (4, 10)", + "(3, 8) -> (4, 14)", + "(3, 9) -> (4, 15)", + "(3, 12) -> (4, 18)", + "(3, 14) -> (4, 20)", + "(3, 15) -> (4, 21)", + "(4, 4) -> (4, 22)", + "(4, 8) -> (4, 26)", + "(4, 9) -> (4, 27)", + "(4, 12) -> (4, 30)", + "(4, 14) -> (4, 32)", + "(4, 18) -> (4, 36)", + "(4, 19) -> (4, 37)", + "(4, 25) -> (4, 43)", + "(4, 26) -> (4, 44)", + "(4, 27) -> (4, 45)", + "(5, 4) -> (4, 46)", + "(5, 8) -> (4, 50)", + "(5, 9) -> (4, 51)", + "(5, 12) -> (4, 54)", + "(5, 14) -> (4, 56)", + "(5, 18) -> (4, 60)", + "(5, 19) -> (4, 61)", + "(5, 25) -> (4, 67)", + "(5, 26) -> (4, 68)", + "(5, 27) -> (4, 69)", + "(6, 2) -> (5, 8)", + "(7, 2) -> (6, 8)", + "(8, 2) -> (7, 8)", + "(8, 5) -> (7, 11)", + "(8, 7) -> (7, 13)", + "(9, 2) -> (8, 8)", + "(9, 9) -> (8, 15)", + "(10, 2) -> (9, 8)", + "(10, 7) -> (9, 13)", + "(10, 8) -> (9, 14)", + "(10, 10) -> (9, 16)", + "(11, 4) -> (10, 10)", + "(11, 11) -> (10, 17)", + "(11, 15) -> (10, 21)", + "(11, 16) -> (10, 22)", + "(11, 18) -> (10, 24)", + "(12, 2) -> (11, 8)", + "(13, 2) -> (12, 8)", + "(13, 8) -> (12, 14)", + "(13, 9) -> (12, 15)", + "(13, 11) -> (12, 17)", + "(14, 4) -> (13, 10)", + "(14, 11) -> (13, 17)", + "(14, 15) -> (13, 21)", + "(14, 16) -> (13, 22)", + "(14, 21) -> (13, 27)", + "(14, 22) -> (13, 28)", + "(14, 24) -> (13, 29)", + "(14, 26) -> (13, 30)", + "(14, 30) -> (13, 34)", + "(14, 31) -> (14, 12)", + "(15, 2) -> (15, 8)", + "(16, 0) -> (16, 6)", +] +`; + +exports[`Regexp 1`] = ` +"var r = /foobar/g; +" +`; + +exports[`Regexp 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 17) -> (1, 15)", +] +`; + +exports[`Simple function 1`] = ` +"function foo() { + bar(); +} +" +`; + +exports[`Simple function 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 9) -> (1, 9)", + "(1, 12) -> (1, 12)", + "(1, 13) -> (1, 13)", + "(1, 15) -> (1, 15)", + "(2, 2) -> (1, 17)", + "(2, 5) -> (1, 20)", + "(2, 6) -> (1, 21)", + "(2, 7) -> (1, 22)", + "(3, 0) -> (1, 24)", +] +`; + +exports[`Single line comment 1`] = ` +"// Comment +function foo() { + bar(); +} +" +`; + +exports[`Single line comment 2`] = ` +Array [ + "(2, 0) -> (3, 4)", + "(2, 9) -> (3, 13)", + "(2, 12) -> (3, 16)", + "(2, 13) -> (3, 17)", + "(2, 15) -> (3, 19)", + "(3, 2) -> (3, 21)", + "(3, 5) -> (3, 24)", + "(3, 6) -> (3, 25)", + "(3, 7) -> (3, 26)", + "(4, 0) -> (3, 28)", +] +`; + +exports[`Stack-keyword property access 1`] = ` +"foo.a = 1.1; +foo.do.switch.case.default = 2.2; +foo.b = 3.3; +" +`; + +exports[`Stack-keyword property access 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 3) -> (1, 3)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 11) -> (1, 9)", + "(2, 0) -> (1, 10)", + "(2, 3) -> (1, 13)", + "(2, 4) -> (1, 14)", + "(2, 6) -> (1, 16)", + "(2, 7) -> (1, 17)", + "(2, 13) -> (1, 23)", + "(2, 14) -> (1, 24)", + "(2, 18) -> (1, 28)", + "(2, 19) -> (1, 29)", + "(2, 27) -> (1, 36)", + "(2, 29) -> (1, 37)", + "(2, 32) -> (1, 40)", + "(3, 0) -> (1, 41)", + "(3, 3) -> (1, 44)", + "(3, 4) -> (1, 45)", + "(3, 6) -> (1, 46)", + "(3, 8) -> (1, 47)", + "(3, 11) -> (1, 50)", +] +`; + +exports[`String with quote 1`] = ` +"var foo = '\\\\''; +" +`; + +exports[`String with quote 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 8)", + "(1, 10) -> (1, 10)", + "(1, 14) -> (1, 13)", +] +`; + +exports[`String with semicolon 1`] = ` +"var foo = ';'; +" +`; + +exports[`String with semicolon 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 8)", + "(1, 10) -> (1, 10)", + "(1, 13) -> (1, 13)", +] +`; + +exports[`Subclass handling 1`] = ` +"class Class extends Base { + constructor() { + } +} +" +`; + +exports[`Subclass handling 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 6) -> (1, 7)", + "(1, 12) -> (1, 14)", + "(1, 20) -> (1, 23)", + "(1, 25) -> (1, 27)", + "(2, 2) -> (1, 28)", + "(2, 13) -> (1, 39)", + "(2, 14) -> (1, 40)", + "(2, 16) -> (1, 41)", + "(3, 2) -> (1, 42)", + "(4, 0) -> (1, 43)", +] +`; + +exports[`Switch statements 1`] = ` +"switch (x) { + case a: + foo(); + break; + default: + bar() +} +" +`; + +exports[`Switch statements 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 7) -> (1, 6)", + "(1, 8) -> (1, 7)", + "(1, 9) -> (1, 8)", + "(1, 11) -> (1, 9)", + "(2, 2) -> (1, 10)", + "(2, 7) -> (1, 15)", + "(2, 8) -> (1, 16)", + "(3, 4) -> (1, 17)", + "(3, 7) -> (1, 20)", + "(3, 8) -> (1, 21)", + "(3, 9) -> (1, 22)", + "(4, 4) -> (1, 23)", + "(4, 9) -> (1, 28)", + "(5, 2) -> (1, 29)", + "(5, 9) -> (1, 36)", + "(6, 4) -> (1, 37)", + "(6, 7) -> (1, 40)", + "(6, 8) -> (1, 41)", + "(7, 0) -> (1, 42)", +] +`; + +exports[`Template literals 1`] = ` +"\`abc\${ JSON.stringify({ + clas: 'testing' +}) }def\`; +{ + a(); +} +" +`; + +exports[`Template literals 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 1) -> (1, 1)", + "(1, 4) -> (1, 4)", + "(1, 7) -> (1, 6)", + "(1, 11) -> (1, 10)", + "(1, 12) -> (1, 11)", + "(1, 21) -> (1, 20)", + "(1, 22) -> (1, 21)", + "(2, 2) -> (1, 22)", + "(2, 6) -> (1, 26)", + "(2, 8) -> (1, 28)", + "(3, 0) -> (1, 37)", + "(3, 1) -> (1, 38)", + "(3, 3) -> (1, 39)", + "(3, 4) -> (1, 40)", + "(3, 7) -> (1, 43)", + "(3, 8) -> (1, 44)", + "(4, 0) -> (1, 45)", + "(5, 2) -> (1, 46)", + "(5, 3) -> (1, 47)", + "(5, 4) -> (1, 48)", + "(5, 5) -> (1, 49)", + "(6, 0) -> (1, 50)", +] +`; + +exports[`Ternary operator 1`] = ` +"bar ? baz : bang; +" +`; + +exports[`Ternary operator 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 3)", + "(1, 6) -> (1, 4)", + "(1, 10) -> (1, 7)", + "(1, 12) -> (1, 8)", + "(1, 16) -> (1, 12)", +] +`; + +exports[`This property access 1`] = ` +"var foo = this.foo; +" +`; + +exports[`This property access 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(1, 14) -> (1, 12)", + "(1, 15) -> (1, 13)", + "(1, 18) -> (1, 16)", +] +`; + +exports[`True assignment 1`] = ` +"var foo = true; +" +`; + +exports[`True assignment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 8) -> (1, 7)", + "(1, 10) -> (1, 8)", + "(1, 14) -> (1, 12)", +] +`; + +exports[`Try/catch/finally statement 1`] = ` +"try { + dangerous() +} catch (e) { + handle(e) +} finally { + cleanup() +} +" +`; + +exports[`Try/catch/finally statement 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 3)", + "(2, 2) -> (1, 4)", + "(2, 11) -> (1, 13)", + "(2, 12) -> (1, 14)", + "(3, 0) -> (1, 15)", + "(3, 2) -> (1, 16)", + "(3, 8) -> (1, 21)", + "(3, 9) -> (1, 22)", + "(3, 10) -> (1, 23)", + "(3, 12) -> (1, 24)", + "(4, 2) -> (1, 25)", + "(4, 8) -> (1, 31)", + "(4, 9) -> (1, 32)", + "(4, 10) -> (1, 33)", + "(5, 0) -> (1, 34)", + "(5, 2) -> (1, 35)", + "(5, 10) -> (1, 42)", + "(6, 2) -> (1, 43)", + "(6, 9) -> (1, 50)", + "(6, 10) -> (1, 51)", + "(7, 0) -> (1, 52)", +] +`; + +exports[`Undefined assignment 1`] = ` +"var i = undefined; +" +`; + +exports[`Undefined assignment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 17) -> (1, 15)", +] +`; + +exports[`Unnamed class handling 1`] = ` +"let unnamed = class { + constructor() { + } +} +" +`; + +exports[`Unnamed class handling 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 12) -> (1, 11)", + "(1, 14) -> (1, 12)", + "(1, 20) -> (1, 17)", + "(2, 2) -> (1, 18)", + "(2, 13) -> (1, 29)", + "(2, 14) -> (1, 30)", + "(2, 16) -> (1, 31)", + "(3, 2) -> (1, 32)", + "(4, 0) -> (1, 33)", +] +`; + +exports[`Void 0 assignment 1`] = ` +"var i = void 0; +" +`; + +exports[`Void 0 assignment 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 8) -> (1, 6)", + "(1, 13) -> (1, 11)", + "(1, 14) -> (1, 12)", +] +`; + +exports[`With statement 1`] = ` +"with (obj) { + crock() +} +" +`; + +exports[`With statement 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 5) -> (1, 4)", + "(1, 6) -> (1, 5)", + "(1, 9) -> (1, 8)", + "(1, 11) -> (1, 9)", + "(2, 2) -> (1, 10)", + "(2, 7) -> (1, 15)", + "(2, 8) -> (1, 16)", + "(3, 0) -> (1, 17)", +] +`; + +exports[`for..of loop 1`] = ` +"for (const x of [ + 1, + 2, + 3 +]) { + console.log(x) +} +" +`; + +exports[`for..of loop 2`] = ` +Array [ + "(1, 0) -> (1, 0)", + "(1, 4) -> (1, 4)", + "(1, 5) -> (1, 5)", + "(1, 11) -> (1, 11)", + "(1, 13) -> (1, 13)", + "(1, 16) -> (1, 16)", + "(2, 2) -> (1, 17)", + "(2, 3) -> (1, 18)", + "(3, 2) -> (1, 19)", + "(3, 3) -> (1, 20)", + "(4, 2) -> (1, 21)", + "(5, 0) -> (1, 22)", + "(5, 1) -> (1, 23)", + "(5, 3) -> (1, 25)", + "(6, 2) -> (1, 27)", + "(6, 9) -> (1, 34)", + "(6, 10) -> (1, 35)", + "(6, 13) -> (1, 38)", + "(6, 14) -> (1, 39)", + "(6, 15) -> (1, 40)", + "(7, 0) -> (1, 42)", +] +`; diff --git a/devtools/client/debugger/src/workers/pretty-print/tests/prettyFast.spec.js b/devtools/client/debugger/src/workers/pretty-print/tests/prettyFast.spec.js new file mode 100644 index 0000000000..f1f7a1c635 --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/tests/prettyFast.spec.js @@ -0,0 +1,434 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* + * Copyright 2013 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE.md or: + * http://opensource.org/licenses/BSD-2-Clause + */ +import { prettyFast } from "../pretty-fast"; +import { SourceMapConsumer } from "devtools/client/shared/vendor/source-map/source-map"; + +const cases = [ + { + name: "Simple function", + input: "function foo() { bar(); }", + }, + { + name: "Nested function", + input: "function foo() { function bar() { debugger; } bar(); }", + }, + { + name: "Immediately invoked function expression", + input: "(function(){thingy()}())", + }, + { + name: "Single line comment", + input: ` + // Comment + function foo() { bar(); }`, + }, + { + name: "Multi line comment", + input: ` + /* Comment + more comment */ + function foo() { bar(); } + `, + }, + { name: "Null assignment", input: "var i=null;" }, + { name: "Undefined assignment", input: "var i=undefined;" }, + { name: "Void 0 assignment", input: "var i=void 0;" }, + { + name: "This property access", + input: "var foo=this.foo;\n", + }, + + { + name: "True assignment", + input: "var foo=true;\n", + }, + + { + name: "False assignment", + input: "var foo=false;\n", + }, + + { + name: "For loop", + input: "for (var i = 0; i < n; i++) { console.log(i); }", + }, + + { + name: "for..of loop", + input: "for (const x of [1,2,3]) { console.log(x) }", + }, + + { + name: "String with semicolon", + input: "var foo = ';';\n", + }, + + { + name: "String with quote", + input: 'var foo = "\'";\n', + }, + + { + name: "Function calls", + input: "var result=func(a,b,c,d);", + }, + + { + name: "Regexp", + input: "var r=/foobar/g;", + }, + + { + name: "In operator", + input: "if(foo in bar){doThing()}", + output: "if (foo in bar) {\n" + " doThing()\n" + "}\n", + }, + { + name: "With statement", + input: "with(obj){crock()}", + }, + { + name: "New expression", + input: "var foo=new Foo();", + }, + { + name: "Continue/break statements", + input: "while(1){if(x){continue}if(y){break}if(z){break foo}}", + }, + { + name: "Instanceof", + input: "var a=x instanceof y;", + }, + { + name: "Binary operators", + input: "var a=5*30;var b=5>>3;", + }, + { + name: "Delete", + input: "delete obj.prop;", + }, + + { + name: "Try/catch/finally statement", + input: "try{dangerous()}catch(e){handle(e)}finally{cleanup()}", + }, + { + name: "If/else statement", + input: "if(c){then()}else{other()}", + }, + { + name: "If/else without curlies", + input: "if(c) a else b", + }, + { + name: "Objects", + input: ` + var o={a:1, + b:2};`, + }, + { + name: "Do/while loop", + input: "do{x}while(y)", + }, + { + name: "Arrays", + input: "var a=[1,2,3];", + }, + { + name: "Arrays and spread operator", + input: "var a=[1,...[2,3],...[], 4];", + }, + { + name: "Empty object/array literals", + input: `let a=[];const b={};c={...{},d: 42};for(let x of []){for(let y in {}){}}`, + }, + { + name: "Code that relies on ASI", + input: ` + var foo = 10 + var bar = 20 + function g() { + a() + b() + }`, + }, + { + name: "Ternary operator", + input: "bar?baz:bang;", + }, + { + name: "Switch statements", + input: "switch(x){case a:foo();break;default:bar()}", + }, + + { + name: "Multiple single line comments", + input: `function f() { + // a + // b + // c + }`, + }, + { + name: "Indented multiline comment", + input: `function foo() { + /** + * java doc style comment + * more comment + */ + bar(); + }`, + }, + { + name: "ASI return", + input: `function f() { + return + {} + }`, + }, + { + name: "Non-ASI property access", + input: `[1,2,3] + [0]`, + }, + { + name: "Non-ASI in", + input: `'x' + in foo`, + }, + + { + name: "Non-ASI function call", + input: `f + ()`, + }, + { + name: "Non-ASI new", + input: `new + F()`, + }, + { + name: "Getter and setter literals", + input: "var obj={get foo(){return this._foo},set foo(v){this._foo=v}}", + }, + { + name: "Escaping backslashes in strings", + input: "'\\\\'\n", + }, + { + name: "Escaping carriage return in strings", + input: "'\\r'\n", + }, + { + name: "Escaping tab in strings", + input: "'\\t'\n", + }, + { + name: "Escaping vertical tab in strings", + input: "'\\v'\n", + }, + { + name: "Escaping form feed in strings", + input: "'\\f'\n", + }, + { + name: "Escaping null character in strings", + input: "'\\0'\n", + }, + { + name: "Bug 977082 - space between grouping operator and dot notation", + input: `JSON.stringify(3).length; + ([1,2,3]).length; + (new Date()).toLocaleString();`, + }, + { + name: "Bug 975477 don't move end of line comments to next line", + input: `switch (request.action) { + case 'show': //$NON-NLS-0$ + if (localStorage.hideicon !== 'true') { //$NON-NLS-0$ + chrome.pageAction.show(sender.tab.id); + } + break; + case 'hide': /*Multiline + Comment */ + break; + default: + console.warn('unknown request'); //$NON-NLS-0$ + // don't respond if you don't understand the message. + return; + }`, + }, + { + name: "Const handling", + input: "const d = 'yes';\n", + }, + { + name: "Let handling without value", + input: "let d;\n", + }, + { + name: "Let handling with value", + input: "let d = 'yes';\n", + }, + { + name: "Template literals", + // issue in acorn + input: "`abc${JSON.stringify({clas: 'testing'})}def`;{a();}", + }, + { + name: "Class handling", + input: "class Class{constructor(){}}", + }, + { + name: "Subclass handling", + input: "class Class extends Base{constructor(){}}", + }, + { + name: "Unnamed class handling", + input: "let unnamed=class{constructor(){}}", + }, + { + name: "Named class handling", + input: "let unnamed=class Class{constructor(){}}", + }, + { + name: "Class extension within a function", + input: "(function() { class X extends Y { constructor() {} } })()", + }, + { + name: "Bug 1261971 - indentation after switch statement", + input: "{switch(x){}if(y){}done();}", + }, + { + name: "Bug 1206633 - spaces in for of", + input: "for (let tab of tabs) {}", + }, + { + name: "Bug pretty-sure-3 - escaping line and paragraph separators", + input: "x = '\\u2029\\u2028';", + }, + { + name: "Bug pretty-sure-4 - escaping null character before digit", + input: "x = '\\u00001';", + }, + { + name: "Bug pretty-sure-5 - empty multiline comment shouldn't throw exception", + input: `{ + /* + */ + return; + }`, + }, + { + name: "Bug pretty-sure-6 - inline comment shouldn't move parenthesis to next line", + input: `return /* inline comment */ ( + 1+1);`, + }, + { + name: "Bug pretty-sure-7 - accessing a literal number property requires a space", + input: "0..toString()+x.toString();", + }, + { + name: "Bug pretty-sure-8 - return and yield only accept arguments when on the same line", + input: `{ + return + (x) + yield + (x) + yield + *x + }`, + }, + { + name: "Bug pretty-sure-9 - accept unary operator at start of file", + input: "+ 0", + }, + { + name: "Stack-keyword property access", + input: "foo.a=1.1;foo.do.switch.case.default=2.2;foo.b=3.3;\n", + }, + { + name: "Dot handling with let which is identifier name", + input: "y.let.let = 1.23;\n", + }, + { + name: "Dot handling with keywords which are identifier name", + input: "y.await.break.const.delete.else.return.new.yield = 1.23;\n", + }, + { + name: "Optional chaining parsing support", + input: "x?.y?.z?.['a']?.check();\n", + }, + { + name: "Private fields parsing support", + input: ` + class MyClass { + constructor(a) { + this.#a = a;this.#b = Math.random();this.ab = this.#getAB(); + } + #a + #b = "default value" + static #someStaticPrivate + #getA() { + return this.#a; + } + #getAB() { + return this.#getA()+this. + #b + } + } + `, + }, + { + name: "Long parenthesis", + input: ` + if (thisIsAVeryLongVariable && thisIsAnotherOne || yetAnotherVeryLongVariable) { + (thisIsAnotherOne = thisMayReturnNull() || "hi", thisIsAVeryLongVariable = 42, yetAnotherVeryLongVariable && doSomething(true /* do it well */,thisIsAVeryLongVariable, thisIsAnotherOne, yetAnotherVeryLongVariable)) + } + for (let thisIsAnotherVeryLongVariable = 0; i < thisIsAnotherVeryLongVariable.length; thisIsAnotherVeryLongVariable++) {} + const x = ({thisIsAnotherVeryLongPropertyName: "but should not cause the paren to be a line delimiter"}) + `, + }, + { + name: "Fat arrow function", + input: ` + const a = () => 42 + addEventListener("click", e => { return false }); + const sum = (c,d) => c+d + `, + }, +]; + +const includesOnly = cases.find(({ only }) => only); + +for (const { name, input, only, skip } of cases) { + if ((includesOnly && !only) || skip) { + continue; + } + test(name, async () => { + const actual = prettyFast(input, { + indent: " ", + url: "test.js", + }); + + expect(actual.code).toMatchSnapshot(); + + const smc = await new SourceMapConsumer(actual.map.toJSON()); + const mappings = []; + smc.eachMapping( + ({ generatedColumn, generatedLine, originalColumn, originalLine }) => { + mappings.push( + `(${originalLine}, ${originalColumn}) -> (${generatedLine}, ${generatedColumn})` + ); + } + ); + expect(mappings).toMatchSnapshot(); + }); +} diff --git a/devtools/client/debugger/src/workers/pretty-print/worker.js b/devtools/client/debugger/src/workers/pretty-print/worker.js new file mode 100644 index 0000000000..1c5587db9f --- /dev/null +++ b/devtools/client/debugger/src/workers/pretty-print/worker.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { workerHandler } from "../../../../shared/worker-utils"; +import { prettyFast } from "./pretty-fast"; + +var { SourceMapGenerator } = require("source-map"); + +const sourceMapGeneratorByTaskId = new Map(); + +function prettyPrint({ url, indent, sourceText }) { + const { code, map: sourceMapGenerator } = prettyFast(sourceText, { + url, + indent, + }); + + return { + code, + sourceMap: sourceMapGenerator.toJSON(), + }; +} + +function prettyPrintInlineScript({ + taskId, + url, + indent, + sourceText, + originalStartLine, + originalStartColumn, + generatedStartLine, +}) { + let taskSourceMapGenerator; + if (!sourceMapGeneratorByTaskId.has(taskId)) { + taskSourceMapGenerator = new SourceMapGenerator({ file: url }); + sourceMapGeneratorByTaskId.set(taskId, taskSourceMapGenerator); + } else { + taskSourceMapGenerator = sourceMapGeneratorByTaskId.get(taskId); + } + + const { code } = prettyFast(sourceText, { + url, + indent, + sourceMapGenerator: taskSourceMapGenerator, + /* + * By default prettyPrint will trim the text, and we'd have the pretty text displayed + * just after the script tag, e.g.: + * + * ``` + * <script>if (true) { + * something() + * } + * </script> + * ``` + * + * We want the text to start on a new line, so prepend a line break, so we get + * something like: + * + * ``` + * <script> + * if (true) { + * something() + * } + * </script> + * ``` + */ + prefixWithNewLine: true, + originalStartLine, + originalStartColumn, + generatedStartLine, + }); + + // When a taskId was passed, we only return the pretty printed text. + // The source map should be retrieved with getSourceMapForTask. + return code; +} + +/** + * Get the source map for a pretty-print task + * + * @param {Integer} taskId: The taskId that was used to call prettyPrint + * @returns {Object} A source map object + */ +function getSourceMapForTask(taskId) { + if (!sourceMapGeneratorByTaskId.has(taskId)) { + return null; + } + + const taskSourceMapGenerator = sourceMapGeneratorByTaskId.get(taskId); + sourceMapGeneratorByTaskId.delete(taskId); + return taskSourceMapGenerator.toJSON(); +} + +self.onmessage = workerHandler({ + prettyPrint, + prettyPrintInlineScript, + getSourceMapForTask, +}); diff --git a/devtools/client/debugger/src/workers/search/get-matches.js b/devtools/client/debugger/src/workers/search/get-matches.js new file mode 100644 index 0000000000..972dea6818 --- /dev/null +++ b/devtools/client/debugger/src/workers/search/get-matches.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import assert from "../../utils/assert"; +import buildQuery from "../../utils/build-query"; + +export default function getMatches(query, text, options) { + if (!query || !text || !options) { + return []; + } + const regexQuery = buildQuery(query, options, { + isGlobal: true, + }); + const matchedLocations = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + let singleMatch; + const line = lines[i]; + while ((singleMatch = regexQuery.exec(line)) !== null) { + // Flow doesn't understand the test above. + if (!singleMatch) { + throw new Error("no singleMatch"); + } + + matchedLocations.push({ + line: i, + ch: singleMatch.index, + match: singleMatch[0], + }); + + // When the match is an empty string the regexQuery.lastIndex will not + // change resulting in an infinite loop so we need to check for this and + // increment it manually in that case. See issue #7023 + if (singleMatch[0] === "") { + assert( + !regexQuery.unicode, + "lastIndex++ can cause issues in unicode mode" + ); + regexQuery.lastIndex++; + } + } + } + return matchedLocations; +} diff --git a/devtools/client/debugger/src/workers/search/index.js b/devtools/client/debugger/src/workers/search/index.js new file mode 100644 index 0000000000..928bbf50e6 --- /dev/null +++ b/devtools/client/debugger/src/workers/search/index.js @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { WorkerDispatcher } from "devtools/client/shared/worker-utils"; + +const WORKER_URL = "resource://devtools/client/debugger/dist/search-worker.js"; + +export class SearchDispatcher extends WorkerDispatcher { + constructor(jestUrl) { + super(jestUrl || WORKER_URL); + } + + getMatches = this.task("getMatches"); + + findSourceMatches = this.task("findSourceMatches"); +} diff --git a/devtools/client/debugger/src/workers/search/moz.build b/devtools/client/debugger/src/workers/search/moz.build new file mode 100644 index 0000000000..b7223ac81a --- /dev/null +++ b/devtools/client/debugger/src/workers/search/moz.build @@ -0,0 +1,10 @@ +# 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( + "index.js", +) diff --git a/devtools/client/debugger/src/workers/search/project-search.js b/devtools/client/debugger/src/workers/search/project-search.js new file mode 100644 index 0000000000..f3751b57c4 --- /dev/null +++ b/devtools/client/debugger/src/workers/search/project-search.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// Maybe reuse file search's functions? + +import getMatches from "./get-matches"; + +export function findSourceMatches(content, queryText, options) { + if (queryText == "") { + return []; + } + + const text = content.value; + const lines = text.split("\n"); + + return getMatches(queryText, text, options).map(({ line, ch, match }) => { + const { value, matchIndex } = truncateLine(lines[line], ch); + return { + line: line + 1, + column: ch, + + matchIndex, + match, + value, + }; + }); +} + +// This is used to find start of a word, so that cropped string look nice +const startRegex = /([ !@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/g; +// Similarly, find +const endRegex = new RegExp( + [ + "([ !@#$%^&*()_+-=[]{};':\"\\|,.<>/?])", + '[^ !@#$%^&*()_+-=[]{};\':"\\|,.<>/?]*$"/', + ].join("") +); +// For texts over 100 characters this truncates the text (for display) +// around the context of the matched text. +function truncateLine(text, column) { + if (text.length < 100) { + return { + matchIndex: column, + value: text, + }; + } + + // Initially take 40 chars left to the match + const offset = Math.max(column - 40, 0); + // 400 characters should be enough to figure out the context of the match + const truncStr = text.slice(offset, column + 400); + let start = truncStr.search(startRegex); + let end = truncStr.search(endRegex); + + if (start > column) { + // No word separator found before the match, so we take all characters + // before the match + start = -1; + } + if (end < column) { + end = truncStr.length; + } + const value = truncStr.slice(start + 1, end); + + return { + matchIndex: column - start - offset - 1, + value, + }; +} diff --git a/devtools/client/debugger/src/workers/search/tests/get-matches.spec.js b/devtools/client/debugger/src/workers/search/tests/get-matches.spec.js new file mode 100644 index 0000000000..7b2da92d43 --- /dev/null +++ b/devtools/client/debugger/src/workers/search/tests/get-matches.spec.js @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 getMatches from "../get-matches"; + +describe("search", () => { + describe("getMatches", () => { + it("gets basic string match", () => { + const text = "the test string with test in it multiple times test."; + const query = "test"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }); + expect(matchLocations).toHaveLength(3); + }); + + it("gets basic string match case-sensitive", () => { + const text = "the Test string with test in it multiple times test."; + const query = "Test"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }); + expect(matchLocations).toHaveLength(1); + }); + + it("gets whole word string match", () => { + const text = "the test string test in it multiple times whoatestthe."; + const query = "test"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: true, + regexMatch: false, + }); + expect(matchLocations).toHaveLength(2); + }); + + it("gets regex match", () => { + const text = "the test string test in it multiple times whoatestthe."; + const query = "(\\w+)\\s+(\\w+)"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: true, + }); + expect(matchLocations).toHaveLength(4); + }); + + it("it doesnt fail on empty data", () => { + const text = ""; + const query = ""; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: true, + }); + expect(matchLocations).toHaveLength(0); + }); + + it("fails gracefully when the line is too long", () => { + const text = Array(100002).join("x"); + const query = "query"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: true, + }); + expect(matchLocations).toHaveLength(0); + }); + + // regression test for #6896 + it("doesn't crash on the regex 'a*'", () => { + const text = "abc"; + const query = "a*"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: true, + }); + expect(matchLocations).toHaveLength(4); + }); + + // regression test for #6896 + it("doesn't crash on the regex '^'", () => { + const text = "012"; + const query = "^"; + const matchLocations = getMatches(query, text, { + caseSensitive: true, + wholeWord: false, + regexMatch: true, + }); + expect(matchLocations).toHaveLength(1); + }); + }); +}); diff --git a/devtools/client/debugger/src/workers/search/worker.js b/devtools/client/debugger/src/workers/search/worker.js new file mode 100644 index 0000000000..b452697516 --- /dev/null +++ b/devtools/client/debugger/src/workers/search/worker.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/>. */ + +import getMatches from "./get-matches"; +import { findSourceMatches } from "./project-search"; +import { workerHandler } from "../../../../shared/worker-utils"; + +self.onmessage = workerHandler({ getMatches, findSourceMatches }); |