diff options
Diffstat (limited to 'devtools/client/debugger/src/utils/sources-tree')
22 files changed, 2764 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/sources-tree/addToTree.js b/devtools/client/debugger/src/utils/sources-tree/addToTree.js new file mode 100644 index 0000000000..fdc1f97ccd --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/addToTree.js @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { + nodeHasChildren, + isPathDirectory, + isInvalidUrl, + partIsFile, + createSourceNode, + createDirectoryNode, + getPathParts, + type PathPart, +} from "./utils"; +import { createTreeNodeMatcher, findNodeInContents } from "./treeOrder"; +import { getDisplayURL } from "./getURL"; + +import type { ParsedURL } from "./getURL"; +import type { TreeDirectory, TreeNode } from "./types"; +import type { DisplaySource, Source } from "../../types"; + +function createNodeInTree( + part: string, + path: string, + tree: TreeDirectory, + index: number +): TreeDirectory { + const node = createDirectoryNode(part, path, []); + + // we are modifying the tree + const contents = tree.contents.slice(0); + contents.splice(index, 0, node); + tree.contents = contents; + + return node; +} + +/* + * Look for the child node + * 1. if it exists return it + * 2. if it does not exist create it + */ +function findOrCreateNode( + parts: PathPart[], + subTree: TreeDirectory, + path: string, + part: string, + index: number, + url: Object, + debuggeeHost: ?string, + source: Source +): TreeDirectory { + const addedPartIsFile = partIsFile(index, parts, url); + + const { found: childFound, index: childIndex } = findNodeInContents( + subTree, + createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost) + ); + + // we create and enter the new node + if (!childFound) { + return createNodeInTree(part, path, subTree, childIndex); + } + + // we found a path with the same name as the part. We need to determine + // if this is the correct child, or if we have a naming conflict + const child = subTree.contents[childIndex]; + const childIsFile = !nodeHasChildren(child); + + // if we have a naming conflict, we'll create a new node + if (childIsFile != addedPartIsFile) { + // pass true to findNodeInContents to sort node by url + const { index: insertIndex } = findNodeInContents( + subTree, + createTreeNodeMatcher(part, !addedPartIsFile, debuggeeHost, source, true) + ); + return createNodeInTree(part, path, subTree, insertIndex); + } + + // if there is no naming conflict, we can traverse into the child + return (child: any); +} + +/* + * walk the source tree to the final node for a given url, + * adding new nodes along the way + */ +function traverseTree( + url: ParsedURL, + tree: TreeDirectory, + debuggeeHost: ?string, + source: Source, + thread: string +): TreeNode { + const parts = getPathParts(url, thread, debuggeeHost); + return parts.reduce( + (subTree, { part, path, debuggeeHostIfRoot }, index) => + findOrCreateNode( + parts, + subTree, + path, + part, + index, + url, + debuggeeHostIfRoot, + source + ), + tree + ); +} + +/* + * Add a source file to a directory node in the tree + */ +function addSourceToNode( + node: TreeDirectory, + url: ParsedURL, + source: Source +): Source | TreeNode[] { + const isFile = !isPathDirectory(url.path); + + if (node.type == "source" && !isFile) { + throw new Error(`Unexpected type "source" at: ${node.name}`); + } + + // if we have a file, and the subtree has no elements, overwrite the + // subtree contents with the source + if (isFile) { + // $FlowIgnore + node.type = "source"; + return source; + } + + let { filename } = url; + + if (filename === "(index)" && url.search) { + filename = url.search; + } else { + filename += url.search; + } + + const { found: childFound, index: childIndex } = findNodeInContents( + node, + createTreeNodeMatcher(filename, false, null) + ); + + // if we are readding an existing file in the node, overwrite the existing + // file and return the node's contents + if (childFound) { + const existingNode = node.contents[childIndex]; + if (existingNode.type === "source") { + existingNode.contents = source; + } + + return node.contents; + } + + // if this is a new file, add the new file; + const newNode = createSourceNode(filename, source.url, source); + const contents = node.contents.slice(0); + contents.splice(childIndex, 0, newNode); + return contents; +} + +/** + * @memberof utils/sources-tree + * @static + */ +export function addToTree( + tree: TreeDirectory, + source: DisplaySource, + debuggeeHost: ?string, + thread: string +): void { + const url = getDisplayURL(source, debuggeeHost); + + if (isInvalidUrl(url, source)) { + return; + } + + const finalNode = traverseTree(url, tree, debuggeeHost, source, thread); + + // $FlowIgnore + finalNode.contents = addSourceToNode(finalNode, url, source); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/collapseTree.js b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js new file mode 100644 index 0000000000..57d3e89849 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/collapseTree.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createDirectoryNode } from "./utils"; + +import type { TreeDirectory, TreeNode } from "./types"; + +/** + * Take an existing source tree, and return a new one with collapsed nodes. + */ +function _collapseTree(node: TreeNode, depth: number): TreeNode { + // Node is a folder. + if (node.type === "directory") { + if (!Array.isArray(node.contents)) { + console.log(`Expected array at: ${node.path}`); + } + + // Node is not a (1) thread and (2) root/domain node, + // and only contains 1 item. + if (depth > 2 && node.contents.length === 1) { + const next = node.contents[0]; + // Do not collapse if the next node is a leaf node. + if (next.type === "directory") { + if (!Array.isArray(next.contents)) { + console.log( + `Expected array at: ${next.name} -- ${ + node.name + } -- ${JSON.stringify(next.contents)}` + ); + } + const name = `${node.name}/${next.name}`; + const nextNode = createDirectoryNode(name, next.path, next.contents); + return _collapseTree(nextNode, depth + 1); + } + } + + // Map the contents. + return createDirectoryNode( + node.name, + node.path, + node.contents.map(next => _collapseTree(next, depth + 1)) + ); + } + + // Node is a leaf, not a folder, do not modify it. + return node; +} + +export function collapseTree(node: TreeDirectory): TreeDirectory { + const tree = _collapseTree(node, 0); + return ((tree: any): TreeDirectory); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/formatTree.js b/devtools/client/debugger/src/utils/sources-tree/formatTree.js new file mode 100644 index 0000000000..83598a0e44 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/formatTree.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { TreeNode } from "./types"; + +export function formatTree( + tree: TreeNode, + depth: number = 0, + str: string = "" +): string { + const whitespace = new Array(depth * 2).join(" "); + + if (tree.type === "directory") { + str += `${whitespace} - ${tree.name} path=${tree.path} \n`; + tree.contents.forEach(t => { + str = formatTree(t, depth + 1, str); + }); + } else { + str += `${whitespace} - ${tree.name} path=${tree.path} source_id=${tree.contents.id} \n`; + } + + return str; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/getDirectories.js b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js new file mode 100644 index 0000000000..fe5b813d09 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/getDirectories.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { createParentMap } from "./utils"; +import flattenDeep from "lodash/flattenDeep"; +import type { TreeNode, TreeDirectory } from "./types"; +import type { Source } from "../../types"; + +function findSourceItem(sourceTree: TreeDirectory, source: Source): ?TreeNode { + function _traverse(subtree: TreeNode) { + if (subtree.type === "source") { + if (subtree.contents.id === source.id) { + return subtree; + } + + return null; + } + + const matches = subtree.contents.map(child => _traverse(child)); + return matches && matches.filter(Boolean)[0]; + } + + return _traverse(sourceTree); +} + +export function findSourceTreeNodes( + sourceTree: TreeDirectory, + path: string +): TreeNode[] { + function _traverse(subtree: TreeNode) { + if (subtree.path.endsWith(path)) { + return subtree; + } + + if (subtree.type === "directory") { + const matches = subtree.contents.map(child => _traverse(child)); + return matches && matches.filter(Boolean); + } + } + + const result = _traverse(sourceTree); + // $FlowIgnore + return Array.isArray(result) ? flattenDeep(result) : result; +} + +function getAncestors(sourceTree: TreeDirectory, item: ?TreeNode) { + if (!item) { + return null; + } + + const parentMap = createParentMap(sourceTree); + const directories = []; + + directories.push(item); + while (true) { + item = parentMap.get(item); + if (!item) { + return directories; + } + directories.push(item); + } +} + +export function getDirectories(source: Source, sourceTree: TreeDirectory) { + const item = findSourceItem(sourceTree, source); + const ancestors = getAncestors(sourceTree, item); + return ancestors || [sourceTree]; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/getURL.js b/devtools/client/debugger/src/utils/sources-tree/getURL.js new file mode 100644 index 0000000000..78713db17e --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/getURL.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { parse } from "../url"; + +const { + getUnicodeHostname, + getUnicodeUrlPath, + // $FlowIgnore +} = require("devtools/client/shared/unicode-url"); + +import type { DisplaySource, Source } from "../../types"; +export type ParsedURL = { + path: string, + search: string, + group: string, + filename: string, +}; + +export function getFilenameFromPath(pathname?: string): string { + let filename = ""; + if (pathname) { + filename = pathname.substring(pathname.lastIndexOf("/") + 1); + // This file does not have a name. Default should be (index). + if (filename == "") { + filename = "(index)"; + } + } + return filename; +} + +const NoDomain = "(no domain)"; +const def = { path: "", search: "", group: "", filename: "" }; + +export function getURL(source: Source, defaultDomain: ?string = ""): ParsedURL { + const { url } = source; + if (!url) { + return def; + } + return getURLInternal(url, defaultDomain); +} + +export function getDisplayURL( + source: DisplaySource, + defaultDomain: ?string = "" +): ParsedURL { + const { displayURL } = source; + if (!displayURL) { + return def; + } + return getURLInternal(displayURL, defaultDomain); +} + +function getURLInternal(url: string, defaultDomain: ?string): ParsedURL { + const { pathname, search, protocol, host } = parse(url); + const filename = getUnicodeUrlPath(getFilenameFromPath(pathname)); + + switch (protocol) { + case "javascript:": + // Ignore `javascript:` URLs for now + return def; + + case "moz-extension:": + case "resource:": + return { + ...def, + path: pathname, + search, + filename, + group: `${protocol}//${host || ""}`, + }; + + case "webpack:": + case "ng:": + return { + ...def, + path: pathname, + search, + filename, + group: `${protocol}//`, + }; + + case "about:": + // An about page is a special case + return { + ...def, + path: "/", + search, + filename, + group: url, + }; + + case "data:": + return { + ...def, + path: "/", + search, + group: NoDomain, + filename: url, + }; + + case "": + if (pathname && pathname.startsWith("/")) { + // use file protocol for a URL like "/foo/bar.js" + return { + ...def, + path: pathname, + search, + filename, + group: "file://", + }; + } else if (!host) { + return { + ...def, + path: pathname, + search, + group: defaultDomain || "", + filename, + }; + } + break; + + case "http:": + case "https:": + return { + ...def, + path: pathname, + search, + filename, + group: getUnicodeHostname(host), + }; + } + + return { + ...def, + path: pathname, + search, + group: protocol ? `${protocol}//` : "", + filename, + }; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/index.js b/devtools/client/debugger/src/utils/sources-tree/index.js new file mode 100644 index 0000000000..e9cc35e1c4 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/index.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Utils for Sources Tree Component + * @module utils/sources-tree + */ + +export { addToTree } from "./addToTree"; +export { collapseTree } from "./collapseTree"; +export { formatTree } from "./formatTree"; +export { getDirectories, findSourceTreeNodes } from "./getDirectories"; +export { getFilenameFromPath, getURL } from "./getURL"; +export { sortTree } from "./sortTree"; +export { createTree, updateTree } from "./updateTree"; + +export * from "./utils"; diff --git a/devtools/client/debugger/src/utils/sources-tree/moz.build b/devtools/client/debugger/src/utils/sources-tree/moz.build new file mode 100644 index 0000000000..f1439a46e3 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "addToTree.js", + "collapseTree.js", + "formatTree.js", + "getDirectories.js", + "getURL.js", + "index.js", + "sortTree.js", + "treeOrder.js", + "updateTree.js", + "utils.js", +) diff --git a/devtools/client/debugger/src/utils/sources-tree/sortTree.js b/devtools/client/debugger/src/utils/sources-tree/sortTree.js new file mode 100644 index 0000000000..c090917fea --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/sortTree.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { nodeHasChildren, isExactUrlMatch } from "./utils"; + +import type { TreeDirectory } from "./types"; +import type { URL } from "../../types"; + +/** + * Look at the nodes in the source tree, and determine the index of where to + * insert a new node. The ordering is index -> folder -> file. + * @memberof utils/sources-tree + * @static + */ +export function sortTree(tree: TreeDirectory, debuggeeUrl: URL = ""): number { + return (tree.contents: any).sort((previousNode, currentNode) => { + const currentNodeIsDir = nodeHasChildren(currentNode); + const previousNodeIsDir = nodeHasChildren(previousNode); + if (currentNode.name === "(index)") { + return 1; + } else if (previousNode.name === "(index)") { + return -1; + } else if (isExactUrlMatch(currentNode.name, debuggeeUrl)) { + return 1; + } else if (isExactUrlMatch(previousNode.name, debuggeeUrl)) { + return -1; + // If neither is the case, continue to compare alphabetically + } else if (previousNodeIsDir && !currentNodeIsDir) { + return -1; + } else if (!previousNodeIsDir && currentNodeIsDir) { + return 1; + } + return previousNode.name.localeCompare(currentNode.name); + }); +} diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap new file mode 100644 index 0000000000..a76261e0dc --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/addToTree.spec.js.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources-tree addToTree can add a file to an intermediate directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - unpkg.com path=FakeThread/unpkg.com + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 + - mode path=FakeThread/unpkg.com/codemirror@5.1/mode + - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml + - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39 + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree correctly parses file sources 1`] = ` +" - root path= + - FakeThread path=FakeThread + - file:// path=FakeThread/file:// + - a path=FakeThread/file:///a + - b.js path=FakeThread/file:///a/b.js source_id=actor1 +" +`; + +exports[`sources-tree addToTree does not attempt to add two of the same directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - davidwalsh.name path=FakeThread/davidwalsh.name + - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/37 + - wp-content path=FakeThread/davidwalsh.name/wp-content + - prism.js path=FakeThread/davidwalsh.name/wp-content/prism.js source_id=server1.conn13.child1/39 +" +`; + +exports[`sources-tree addToTree does not attempt to add two of the same file 1`] = ` +" - root path= + - FakeThread path=FakeThread + - davidwalsh.name path=FakeThread/davidwalsh.name + - (index) path=https://davidwalsh.name/ source_id=server1.conn13.child1/39 + - util.js path=FakeThread/davidwalsh.name/util.js source_id=server1.conn13.child1/37 + - FakeThread2 path=FakeThread2 + - davidwalsh.name path=FakeThread2/davidwalsh.name + - util.js path=FakeThread2/davidwalsh.name/util.js source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree does not mangle encoded URLs 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - foo path=FakeThread/example.com/foo + - B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 path=FakeThread/example.com/foo/B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1 source_id=actor1 +" +`; + +exports[`sources-tree addToTree excludes javascript: URLs from the tree 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - source1.js path=FakeThread/example.com/source1.js source_id=actor2 +" +`; + +exports[`sources-tree addToTree name does include query params 1`] = ` +" - root path= + - FakeThread path=FakeThread + - example.com path=FakeThread/example.com + - foo path=FakeThread/example.com/foo + - name.js?bar=3 path=FakeThread/example.com/foo/name.js?bar=3 source_id=actor1 +" +`; + +exports[`sources-tree addToTree replaces a file with a directory 1`] = ` +" - root path= + - FakeThread path=FakeThread + - unpkg.com path=FakeThread/unpkg.com + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 + - mode path=FakeThread/unpkg.com/codemirror@5.1/mode + - xml path=FakeThread/unpkg.com/codemirror@5.1/mode/xml + - xml.js path=FakeThread/unpkg.com/codemirror@5.1/mode/xml/xml.js source_id=server1.conn13.child1/39 + - codemirror@5.1 path=FakeThread/unpkg.com/codemirror@5.1 source_id=server1.conn13.child1/37 +" +`; + +exports[`sources-tree addToTree supports data URLs 1`] = ` +" - root path= + - FakeThread path=FakeThread + - (no domain) path=FakeThread/(no domain) + - data:text/html,<script>console.log(123)</script> path=data:text/html,<script>console.log(123)</script> source_id=server1.conn13.child1/39 +" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap new file mode 100644 index 0000000000..529874f8bd --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/collapseTree.spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sources tree collapseTree can collapse a single source 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with a deeper level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c/d path=Main Thread/example.com/a/b/c/d + - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2 + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with a shallower level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 + - x.js path=Main Thread/example.com/a/b/x.js source_id=actor3 +" +`; + +exports[`sources tree collapseTree correctly merges in a collapsed source with the same level 1`] = ` +" - root path= + - Main Thread path=Main Thread + - example.com path=Main Thread/example.com + - a/b path=Main Thread/example.com/a/b + - c/d path=Main Thread/example.com/a/b/c/d + - e.js path=Main Thread/example.com/a/b/c/d/e.js source_id=actor2 + - c.js path=Main Thread/example.com/a/b/c.js source_id=actor1 +" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap new file mode 100644 index 0000000000..d6bc9a653e --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/__snapshots__/updateTree.spec.js.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`calls updateTree.js adds one source 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js adds two sources 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source2.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source2.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/40\\", + \\"url\\": \\"https://davidwalsh.name/source2.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source2.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source2.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js shows all the sources 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"(index)\\", + \\"path\\": \\"https://davidwalsh.name/\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/\\" + } + }, + { + \\"type\\": \\"source\\", + \\"name\\": \\"source1.js\\", + \\"path\\": \\"FakeThread/davidwalsh.name/source1.js\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/37\\", + \\"url\\": \\"https://davidwalsh.name/source1.js\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/source1.js\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/source1.js\\" + } + } + ] + } + ] + } + ] +}" +`; + +exports[`calls updateTree.js update sources that change their display URL 1`] = ` +"{ + \\"type\\": \\"directory\\", + \\"name\\": \\"root\\", + \\"path\\": \\"\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"FakeThread\\", + \\"path\\": \\"FakeThread\\", + \\"contents\\": [ + { + \\"type\\": \\"directory\\", + \\"name\\": \\"davidwalsh.name\\", + \\"path\\": \\"FakeThread/davidwalsh.name\\", + \\"contents\\": [ + { + \\"type\\": \\"source\\", + \\"name\\": \\"?param\\", + \\"path\\": \\"FakeThread/davidwalsh.name/?param\\", + \\"contents\\": { + \\"id\\": \\"server1.conn13.child1/39\\", + \\"url\\": \\"https://davidwalsh.name/?param\\", + \\"isBlackBoxed\\": false, + \\"isPrettyPrinted\\": false, + \\"relativeUrl\\": \\"https://davidwalsh.name/?param\\", + \\"isWasm\\": false, + \\"extensionName\\": null, + \\"isExtension\\": false, + \\"isOriginal\\": false, + \\"displayURL\\": \\"https://davidwalsh.name/?param\\" + } + } + ] + } + ] + } + ] +}" +`; diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js new file mode 100644 index 0000000000..392d854bea --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/addToTree.spec.js @@ -0,0 +1,374 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/* eslint max-nested-callbacks: ["error", 4]*/ + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { + addToTree, + createDirectoryNode, + createSourceNode, + createTree, + formatTree, + nodeHasChildren, +} from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return sourcesMap; +} + +function createSourcesList(sources: { url: string, id?: string }[]) { + return sources.map((s, i) => makeMockDisplaySource(s.url, s.id)); +} + +function getChildNode(tree, ...path) { + return path.reduce((child, index) => child.contents[index], tree); +} + +describe("sources-tree", () => { + describe("addToTree", () => { + it("should provide node API", () => { + const source = makeMockDisplaySource( + "http://example.com/a/b/c.js", + "actor1" + ); + + const root = createDirectoryNode("root", "", [ + createSourceNode("foo", "/foo", source), + ]); + + expect(root.name).toBe("root"); + expect(nodeHasChildren(root)).toBe(true); + expect(root.contents).toHaveLength(1); + + const child = root.contents[0]; + expect(child.name).toBe("foo"); + expect(child.path).toBe("/foo"); + expect(child.contents).toBe(source); + expect(nodeHasChildren(child)).toBe(false); + }); + + it("builds a path-based tree", () => { + const source1 = makeMockDisplaySource( + "http://example.com/foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0].contents[0]; + expect(base.name).toBe("example.com"); + expect(base.contents).toHaveLength(1); + + const fooNode = base.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("builds a path-based tree for webpack URLs", () => { + const source1 = makeMockDisplaySource( + "webpack:///foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("webpack://"); + expect(base.contents).toHaveLength(1); + + const fooNode = base.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("builds a path-based tree for webpack URLs with absolute path", () => { + const source1 = makeMockDisplaySource( + "webpack:////Users/foo/source1.js", + "actor1" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("webpack://"); + expect(base.contents).toHaveLength(1); + + const emptyNode = base.contents[0]; + expect(emptyNode.name).toBe(""); + expect(emptyNode.contents).toHaveLength(1); + + const userNode = emptyNode.contents[0]; + expect(userNode.name).toBe("Users"); + expect(userNode.contents).toHaveLength(1); + + const fooNode = userNode.contents[0]; + expect(fooNode.name).toBe("foo"); + expect(fooNode.contents).toHaveLength(1); + + const source1Node = fooNode.contents[0]; + expect(source1Node.name).toBe("source1.js"); + }); + + it("handles url with no filename", function() { + const source1 = makeMockDisplaySource("http://example.com/", "actor1"); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", ""); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0]; + expect(base.name).toBe("example.com"); + expect(base.contents).toHaveLength(1); + + const indexNode = base.contents[0]; + expect(indexNode.name).toBe("(index)"); + }); + + it("does not mangle encoded URLs", () => { + const sourceName = // eslint-disable-next-line max-len + "B9724220.131821496;dc_ver=42.111;sz=468x60;u_sd=2;dc_adk=2020465299;ord=a53rpc;dc_rfl=1,https%3A%2F%2Fdavidwalsh.name%2F$0;xdt=1"; + + const source1 = makeMockDisplaySource( + `https://example.com/foo/${sourceName}`, + "actor1" + ); + + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + const childNode = getChildNode(tree, 0, 0, 0, 0); + expect(childNode.name).toEqual(sourceName); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("name does include query params", () => { + const sourceName = "name.js?bar=3"; + + const source1 = makeMockDisplaySource( + `https://example.com/foo/${sourceName}`, + "actor1" + ); + + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("does not attempt to add two of the same directory", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/wp-content/prism.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/", + }, + ]; + + const sourceMap = { FakeThread: createSourcesMap(sources) }; + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "", + threads: [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + isTopLevel: false, + }, + ], + }).sourceTree; + + expect(tree.contents[0].contents).toHaveLength(1); + const subtree = tree.contents[0].contents[0]; + expect(subtree.contents).toHaveLength(2); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("supports data URLs", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "data:text/html,<script>console.log(123)</script>", + }, + ]; + + const sourceMap = { FakeThread: createSourcesMap(sources) }; + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "", + threads: [ + { + actor: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ], + }).sourceTree; + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("does not attempt to add two of the same file", () => { + const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/util.js", + }, + ]; + + const sourceMap = { + FakeThread: createSourcesMap(sources), + FakeThread2: createSourcesMap([sources[1]]), + }; + + const tree = createTree({ + sources: sourceMap, + debuggeeUrl: "https://davidwalsh.name", + threads: [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + isTopLevel: false, + }, + { + actor: "FakeThread2", + name: "FakeThread2", + url: "https://davidwalsh.name/WorkerA.js", + targetType: "worker", + isTopLevel: false, + }, + ], + }).sourceTree; + + expect(tree.contents[0].contents).toHaveLength(1); + const subtree = tree.contents[0].contents[0]; + expect(subtree.contents).toHaveLength(2); + const subtree2 = tree.contents[1].contents[0]; + expect(subtree2.contents).toHaveLength(1); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("excludes javascript: URLs from the tree", () => { + const source1 = makeMockDisplaySource( + "javascript:alert('Hello World')", + "actor1" + ); + const source2 = makeMockDisplaySource( + "http://example.com/source1.js", + "actor2" + ); + const source3 = makeMockDisplaySource( + "javascript:let i = 10; while (i > 0) i--; console.log(i);", + "actor3" + ); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source1, "http://example.com/", "FakeThread"); + addToTree(tree, source2, "http://example.com/", "FakeThread"); + addToTree(tree, source3, "http://example.com/", "FakeThread"); + + const base = tree.contents[0].contents[0]; + expect(tree.contents).toHaveLength(1); + + const source1Node = base.contents[0]; + expect(source1Node.name).toBe("source1.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly parses file sources", () => { + const source = makeMockDisplaySource("file:///a/b.js", "actor1"); + const tree = createDirectoryNode("root", "", []); + + addToTree(tree, source, "file:///a/index.html", "FakeThread"); + expect(tree.contents).toHaveLength(1); + + const base = tree.contents[0].contents[0]; + expect(base.name).toBe("file://"); + expect(base.contents).toHaveLength(1); + + const aNode = base.contents[0]; + expect(aNode.name).toBe("a"); + expect(aNode.contents).toHaveLength(1); + + const bNode = aNode.contents[0]; + expect(bNode.name).toBe("b.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("can add a file to an intermediate directory", () => { + const testData = [ + { + id: "server1.conn13.child1/39", + url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://unpkg.com/codemirror@5.1", + }, + ]; + + const sources = createSourcesList(testData); + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "https://unpkg.com/", "FakeThread") + ); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("replaces a file with a directory", () => { + const testData = [ + { + id: "server1.conn13.child1/37", + url: "https://unpkg.com/codemirror@5.1", + }, + + { + id: "server1.conn13.child1/39", + url: "https://unpkg.com/codemirror@5.1/mode/xml/xml.js", + }, + ]; + + const sources = createSourcesList(testData); + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "https://unpkg.com/", "FakeThread") + ); + expect(formatTree(tree)).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js new file mode 100644 index 0000000000..fba925c2ed --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/collapseTree.spec.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { + collapseTree, + formatTree, + addToTree, + createDirectoryNode, +} from "../index"; + +const abcSource = makeMockDisplaySource( + "http://example.com/a/b/c.js", + "actor1" +); +const abcdeSource = makeMockDisplaySource( + "http://example.com/a/b/c/d/e.js", + "actor2" +); +const abxSource = makeMockDisplaySource( + "http://example.com/a/b/x.js", + "actor3" +); + +describe("sources tree", () => { + describe("collapseTree", () => { + it("can collapse a single source", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + expect(fullTree.contents).toHaveLength(1); + const tree = collapseTree(fullTree); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(1); + + const abcNode = abFolder.contents[0]; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with a deeper level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [cdFolder, abcNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(cdFolder.name).toBe("c/d"); + + const [abcdeNode] = cdFolder.contents; + expect(abcdeNode.name).toBe("e.js"); + expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with a shallower level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abxSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + expect(tree.contents).toHaveLength(1); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [abcNode, abxNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(abxNode.name).toBe("x.js"); + expect(abxNode.path).toBe("Main Thread/example.com/a/b/x.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + + it("correctly merges in a collapsed source with the same level", () => { + const fullTree = createDirectoryNode("root", "", []); + addToTree(fullTree, abcdeSource, "http://example.com/", "Main Thread"); + addToTree(fullTree, abcSource, "http://example.com/", "Main Thread"); + const tree = collapseTree(fullTree); + + expect(tree.contents).toHaveLength(1); + + const host = tree.contents[0].contents[0]; + expect(host.name).toBe("example.com"); + expect(host.contents).toHaveLength(1); + + const abFolder = host.contents[0]; + expect(abFolder.name).toBe("a/b"); + expect(abFolder.contents).toHaveLength(2); + + const [cdFolder, abcNode] = abFolder.contents; + expect(abcNode.name).toBe("c.js"); + expect(abcNode.path).toBe("Main Thread/example.com/a/b/c.js"); + expect(cdFolder.name).toBe("c/d"); + + const [abcdeNode] = cdFolder.contents; + expect(abcdeNode.name).toBe("e.js"); + expect(abcdeNode.path).toBe("Main Thread/example.com/a/b/c/d/e.js"); + expect(formatTree(tree)).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js new file mode 100644 index 0000000000..ffcad21905 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/getDirectories.spec.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; + +import { getDirectories, findSourceTreeNodes, createTree } from "../index"; + +function formatDirectories(source, tree) { + const paths: any = getDirectories(source, tree); + return paths.map(node => node.path); +} + +function createSources(urls) { + return { + FakeThread: urls.reduce((sources, url, index) => { + const id = `a${index}`; + sources[id] = makeMockDisplaySource(url, id); + return sources; + }, {}), + }; +} + +describe("getDirectories", () => { + it("gets a source's ancestor directories", function() { + const sources = createSources([ + "http://a/b.js", + "http://a/c.js", + "http://b/c.js", + ]); + + const threads = [ + { + actor: "FakeThread", + url: "http://a", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ]; + + const debuggeeUrl = "http://a/"; + const { sourceTree } = createTree({ + sources, + debuggeeUrl, + threads, + }); + + expect(formatDirectories(sources.FakeThread.a0, sourceTree)).toEqual([ + "FakeThread/a/b.js", + "FakeThread/a", + "FakeThread", + ]); + expect(formatDirectories(sources.FakeThread.a1, sourceTree)).toEqual([ + "FakeThread/a/c.js", + "FakeThread/a", + "FakeThread", + ]); + expect(formatDirectories(sources.FakeThread.a2, sourceTree)).toEqual([ + "FakeThread/b/c.js", + "FakeThread/b", + "FakeThread", + ]); + }); +}); + +describe("findSourceTreeNodes", () => { + it("finds a node", () => { + const sources = createSources([ + "http://src/main.js", + "http://src/utils/help.js", + "http://src/utils/print.js", + "http://workers/worker.js", + ]); + + const threads = [ + { + actor: "FakeThread", + url: "http://a", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, + ]; + + const debuggeeUrl = "http://a/"; + const { sourceTree } = createTree({ + sources, + debuggeeUrl, + threads, + }); + + const nodes = findSourceTreeNodes(sourceTree, "src") || []; + expect(nodes[0].path).toEqual("FakeThread/src"); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js new file mode 100644 index 0000000000..1b176908e9 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/getUrl.spec.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getURL } from "../getURL"; +import { makeMockSource } from "../../../utils/test-mockup"; +import type { Source } from "../../../types"; + +function createMockSource(props): Source { + const rv = { + ...makeMockSource(), + ...Object.assign( + { + id: "server1.conn13.child1/39", + url: "", + sourceMapURL: "", + isBlackBoxed: false, + isPrettyPrinted: false, + isWasm: false, + }, + props + ), + }; + return (rv: any); +} + +describe("getUrl", () => { + it("handles normal url with http and https for filename", function() { + const urlObject = getURL(createMockSource({ url: "https://a/b.js" })); + expect(urlObject.filename).toBe("b.js"); + + const urlObject2 = getURL( + createMockSource({ id: "server1.conn13.child1/40", url: "http://a/b.js" }) + ); + expect(urlObject2.filename).toBe("b.js"); + }); + + it("handles url with querystring for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/b.js?key=randomKey", + }) + ); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with '#' for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/b.js#specialSection", + }) + ); + expect(urlObject.filename).toBe("b.js"); + }); + + it("handles url with no file extension for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/c", + id: "c", + }) + ); + expect(urlObject.filename).toBe("c"); + }); + + it("handles url with no name for filename", function() { + const urlObject = getURL( + createMockSource({ + url: "https://a/", + id: "c", + }) + ); + expect(urlObject.filename).toBe("(index)"); + }); + + it("separates resources by protocol and host", () => { + const urlObject = getURL( + createMockSource({ + url: "moz-extension://xyz/123", + id: "c2", + }) + ); + expect(urlObject.group).toBe("moz-extension://xyz"); + }); + + it("creates a group name for webpack", () => { + const urlObject = getURL( + createMockSource({ + url: "webpack:///src/component.jsx", + id: "c3", + }) + ); + expect(urlObject.group).toBe("webpack://"); + }); + + it("creates a group name for angular source", () => { + const urlObject = getURL( + createMockSource({ + url: "ng://src/component.jsx", + id: "c3", + }) + ); + expect(urlObject.group).toBe("ng://"); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js new file mode 100644 index 0000000000..6835be7bc9 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/treeOrder.spec.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { getDomain } from "../treeOrder"; + +describe("getDomain", () => { + it("parses a url and returns the host name", () => { + expect(getDomain("http://www.mozilla.com")).toBe("mozilla.com"); + }); + + it("returns null for an undefined string", () => { + expect(getDomain(undefined)).toBe(null); + }); + + it("returns null for an empty string", () => { + expect(getDomain("")).toBe(null); + }); + + it("returns null for a poorly formed string", () => { + expect(getDomain("\\/~`?,.{}[]!@$%^&*")).toBe(null); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js new file mode 100644 index 0000000000..f837c7c1be --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/updateTree.spec.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../../utils/test-mockup"; +import { updateTree, createTree } from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return { FakeThread: sourcesMap }; +} + +function formatTree(tree) { + if (!tree) { + throw new Error("Tree must exist"); + } + return JSON.stringify(tree.uncollapsedTree, null, 2); +} + +const sources = [ + { + id: "server1.conn13.child1/39", + url: "https://davidwalsh.name/", + }, + { + id: "server1.conn13.child1/37", + url: "https://davidwalsh.name/source1.js", + }, + { + id: "server1.conn13.child1/40", + url: "https://davidwalsh.name/source2.js", + }, +]; + +const threads = [ + { + actor: "FakeThread", + url: "https://davidwalsh.name", + targetType: "worker", + name: "FakeThread", + isTopLevel: false, + }, +]; + +const debuggeeUrl = "blah"; + +describe("calls updateTree.js", () => { + it("adds one source", () => { + const prevSources = createSourcesMap([sources[0]]); + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1]]), + uncollapsedTree, + sourceTree, + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + it("adds two sources", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1], sources[2]]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + it("update sources that change their display URL", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([ + { + ...sources[0], + url: `${sources[0].url}?param`, + }, + ]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); + + // NOTE: we currently only add sources to the tree and clear the tree + // on navigate. + it("shows all the sources", () => { + const prevSources = createSourcesMap([sources[0]]); + + const { sourceTree, uncollapsedTree } = createTree({ + debuggeeUrl, + sources: prevSources, + threads, + }); + + const newTree = updateTree({ + debuggeeUrl, + prevSources, + newSources: createSourcesMap([sources[0], sources[1]]), + uncollapsedTree, + sourceTree, + projectRoot: "", + threads, + }); + + expect(formatTree(newTree)).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js new file mode 100644 index 0000000000..0740800c4d --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/tests/utils.spec.js @@ -0,0 +1,223 @@ +/* eslint max-nested-callbacks: ["error", 4]*/ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { makeMockDisplaySource } from "../../test-mockup"; + +import { + createDirectoryNode, + getRelativePath, + isExactUrlMatch, + isDirectory, + addToTree, + isNotJavaScript, + getPathWithoutThread, + createTree, + getSourcesInsideGroup, + getAllSources, +} from "../index"; + +type RawSource = {| url: string, id: string, actors?: any |}; + +function createSourcesMap(sources: RawSource[]) { + const sourcesMap = sources.reduce((map, source) => { + map[source.id] = makeMockDisplaySource(source.url, source.id); + return map; + }, {}); + + return sourcesMap; +} + +describe("sources tree", () => { + describe("isExactUrlMatch", () => { + it("recognizes root url match", () => { + const rootA = "http://example.com/path/to/file.html"; + const rootB = "https://www.demo.com/index.html"; + + expect(isExactUrlMatch("example.com", rootA)).toBe(true); + expect(isExactUrlMatch("www.example.com", rootA)).toBe(true); + expect(isExactUrlMatch("api.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("example.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("www.example.example.com", rootA)).toBe(false); + expect(isExactUrlMatch("demo.com", rootA)).toBe(false); + + expect(isExactUrlMatch("demo.com", rootB)).toBe(true); + expect(isExactUrlMatch("www.demo.com", rootB)).toBe(true); + expect(isExactUrlMatch("maps.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("demo.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("www.demo.demo.com", rootB)).toBe(false); + expect(isExactUrlMatch("example.com", rootB)).toBe(false); + }); + }); + + describe("isDirectory", () => { + it("identifies directories correctly", () => { + const sources = [ + makeMockDisplaySource("http://example.com/a.js", "actor1"), + makeMockDisplaySource("http://example.com/b/c/d.js", "actor2"), + ]; + + const tree = createDirectoryNode("root", "", []); + sources.forEach(source => + addToTree(tree, source, "http://example.com/", "Main Thread") + ); + const [bFolderNode, aFileNode] = tree.contents[0].contents[0].contents; + const [cFolderNode] = bFolderNode.contents; + const [dFileNode] = cFolderNode.contents; + + expect(isDirectory(bFolderNode)).toBe(true); + expect(isDirectory(aFileNode)).toBe(false); + expect(isDirectory(cFolderNode)).toBe(true); + expect(isDirectory(dFileNode)).toBe(false); + }); + }); + + describe("getRelativePath", () => { + it("gets the relative path of the file", () => { + const relPath = "path/to/file.html"; + expect(getRelativePath("http://example.com/path/to/file.html")).toBe( + relPath + ); + expect(getRelativePath("http://www.example.com/path/to/file.html")).toBe( + relPath + ); + expect(getRelativePath("https://www.example.com/path/to/file.js")).toBe( + "path/to/file.js" + ); + expect(getRelativePath("webpack:///path/to/file.html")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html?bla")).toBe(relPath); + expect(getRelativePath("file:///path/to/file.html#bla")).toBe(relPath); + expect(getRelativePath("file:///path/to/file")).toBe("path/to/file"); + }); + }); + + describe("isNotJavaScript", () => { + it("js file", () => { + const source = makeMockDisplaySource("http://example.com/foo.js"); + expect(isNotJavaScript(source)).toBe(false); + }); + + it("css file", () => { + const source = makeMockDisplaySource("http://example.com/foo.css"); + expect(isNotJavaScript(source)).toBe(true); + }); + + it("svg file", () => { + const source = makeMockDisplaySource("http://example.com/foo.svg"); + expect(isNotJavaScript(source)).toBe(true); + }); + + it("png file", () => { + const source = makeMockDisplaySource("http://example.com/foo.png"); + expect(isNotJavaScript(source)).toBe(true); + }); + }); + + describe("getPathWithoutThread", () => { + it("main thread pattern", () => { + const path = getPathWithoutThread("server1.conn0.child1/context18"); + expect(path).toBe(""); + }); + + it("main thread host", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/context18/dbg-workers.glitch.me" + ); + expect(path).toBe("dbg-workers.glitch.me"); + }); + + it("main thread children", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/context18/dbg-workers.glitch.me/more" + ); + expect(path).toBe("dbg-workers.glitch.me/more"); + }); + + it("worker thread", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1" + ); + expect(path).toBe(""); + }); + + it("worker thread with children", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils" + ); + expect(path).toBe("dbg-workers.glitch.me/utils"); + }); + + it("worker thread with file named like pattern", () => { + const path = getPathWithoutThread( + "server1.conn0.child1/workerTarget25/context1/dbg-workers.glitch.me/utils/context38/index.js" + ); + expect(path).toBe("dbg-workers.glitch.me/utils/context38/index.js"); + }); + }); + + it("gets all sources in all threads and gets sources inside of the selected directory", () => { + const testData1 = [ + { + id: "server1.conn13.child1/39", + url: "https://example.com/a.js", + }, + { + id: "server1.conn13.child1/37", + url: "https://example.com/b.js", + }, + { + id: "server1.conn13.child1/35", + url: "https://example.com/c.js", + }, + ]; + const testData2 = [ + { + id: "server1.conn13.child1/33", + url: "https://example.com/d.js", + }, + { + id: "server1.conn13.child1/31", + url: "https://example.com/e.js", + }, + ]; + const sources = { + FakeThread: createSourcesMap(testData1), + OtherThread: createSourcesMap(testData2), + }; + const threads = [ + { + actor: "FakeThread", + name: "FakeThread", + url: "https://example.com/", + targetType: "worker", + isTopLevel: false, + }, + { + actor: "OtherThread", + name: "OtherThread", + url: "https://example.com/", + targetType: "worker", + isTopLevel: false, + }, + ]; + + const tree = createTree({ + sources, + debuggeeUrl: "https://example.com/", + threads, + }).sourceTree; + + const dirA = tree.contents[0]; + const dirB = tree.contents[1]; + + expect(getSourcesInsideGroup(dirA, { threads, sources })).toHaveLength(3); + expect(getSourcesInsideGroup(dirB, { threads, sources })).toHaveLength(2); + expect(getSourcesInsideGroup(tree, { threads, sources })).toHaveLength(5); + + expect(getAllSources({ threads, sources })).toHaveLength(5); + }); +}); diff --git a/devtools/client/debugger/src/utils/sources-tree/treeOrder.js b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js new file mode 100644 index 0000000000..1cf2536f32 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/treeOrder.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { parse } from "../url"; + +import { nodeHasChildren } from "./utils"; + +import type { TreeNode } from "./types"; + +import type { Source } from "../../types"; + +/* + * Gets domain from url (without www prefix) + */ +export function getDomain(url?: string): ?string { + if (!url) { + return null; + } + const { host } = parse(url); + if (!host) { + return null; + } + return host.startsWith("www.") ? host.substr("www.".length) : host; +} + +/* + * Checks if node name matches debugger host/domain. + */ +function isExactDomainMatch(part: string, debuggeeHost: string): boolean { + return part.startsWith("www.") + ? part.substr("www.".length) === debuggeeHost + : part === debuggeeHost; +} + +/* + * Checks if node name matches IndexName + */ +function isIndexName(part: string, ...rest): boolean { + return part === IndexName; +} + +/* + * Function to assist with node search for a defined sorted order, see e.g. + * `createTreeNodeMatcher`. Returns negative number if the node + * stands earlier in sorting order, positive number if the node stands later + * in sorting order, or zero if the node is found. + */ +export type FindNodeInContentsMatcher = (node: TreeNode) => number; + +/* + * Performs a binary search to insert a node into contents. Returns positive + * number, index of the found child, or negative number, which can be used + * to calculate a position where a new node can be inserted (`-index - 1`). + * The matcher is a function that returns result of comparision of a node with + * lookup value. + */ +export function findNodeInContents( + tree: TreeNode, + matcher: FindNodeInContentsMatcher +): {| found: boolean, index: number |} { + if (tree.type === "source" || tree.contents.length === 0) { + return { found: false, index: 0 }; + } + + let left = 0; + let right = tree.contents.length - 1; + while (left < right) { + const middle = Math.floor((left + right) / 2); + if (matcher(tree.contents[middle]) < 0) { + left = middle + 1; + } else { + right = middle; + } + } + const result = matcher(tree.contents[left]); + if (result === 0) { + return { found: true, index: left }; + } + return { found: false, index: result > 0 ? left : left + 1 }; +} + +const IndexName = "(index)"; + +/* + * An array of functions to identify exceptions when sorting sourcesTree. + * Each function must return a boolean. Keep functions in array in the + * order exceptions should be sorted in. + */ +const matcherFunctions = [isIndexName, isExactDomainMatch]; + +/* + * Creates a matcher for findNodeInContents. + * The sorting order of nodes during comparison is: + * - "(index)" node + * - root node with the debuggee host/domain + * - hosts/directories (not files) sorted by name + * - files sorted by name + */ +export function createTreeNodeMatcher( + part: string, + isDir: boolean, + debuggeeHost: ?string, + source?: Source, + sortByUrl?: boolean +): FindNodeInContentsMatcher { + return (node: TreeNode) => { + for (let i = 0; i < matcherFunctions.length; i++) { + // Check part against exceptions + if (matcherFunctions[i](part, debuggeeHost)) { + for (let j = 0; j < i; j++) { + // Check node.name against exceptions + if (matcherFunctions[j](node.name, debuggeeHost)) { + return -1; + } + } + // If part and node.name share the same exception, return 0 + if (matcherFunctions[i](node.name, debuggeeHost)) { + return 0; + } + return 1; + } + // Check node.name against exceptions if part is not exception + if (matcherFunctions[i](node.name, debuggeeHost)) { + return -1; + } + } + // Sort directories before files + const nodeIsDir = nodeHasChildren(node); + if (nodeIsDir && !isDir) { + return -1; + } else if (!nodeIsDir && isDir) { + return 1; + } + + if (sortByUrl && node.type === "source" && source) { + return node.contents.url.localeCompare(source.url); + } + + if (isExactDomainMatch(part, node.name)) { + return 0; + } + + return node.name.localeCompare(part); + }; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/types.js b/devtools/client/debugger/src/utils/sources-tree/types.js new file mode 100644 index 0000000000..c8dacd6849 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/types.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import type { Source } from "../../types"; + +/** + * TODO: createNode is exported so this type could be useful to other modules + * @memberof utils/sources-tree + * @static + */ +export type TreeNode = TreeSource | TreeDirectory; + +export type TreeSource = { + type: "source", + name: string, + path: string, + contents: Source, +}; + +export type TreeDirectory = { + type: "directory", + name: string, + path: string, + contents: TreeNode[], +}; + +export type ParentMap = WeakMap<TreeNode, TreeDirectory>; + +export type SourcesGroups = { + sourcesInside: Source[], + sourcesOuside: Source[], +}; diff --git a/devtools/client/debugger/src/utils/sources-tree/updateTree.js b/devtools/client/debugger/src/utils/sources-tree/updateTree.js new file mode 100644 index 0000000000..af67f0f95d --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/updateTree.js @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { addToTree } from "./addToTree"; +import { collapseTree } from "./collapseTree"; +import { + createDirectoryNode, + createParentMap, + getPathParts, + isInvalidUrl, +} from "./utils"; +import { + getDomain, + createTreeNodeMatcher, + findNodeInContents, +} from "./treeOrder"; + +import { getDisplayURL } from "./getURL"; + +import type { SourcesMapByThread } from "../../reducers/types"; +import type { Thread, DisplaySource, URL } from "../../types"; +import type { TreeDirectory, TreeSource, TreeNode } from "./types"; + +function getSourcesDiff( + newSources, + prevSources +): { + toAdd: Array<DisplaySource>, + toUpdate: Array<[DisplaySource, DisplaySource]>, +} { + const toAdd = []; + const toUpdate = []; + + for (const sourceId in newSources) { + const newSource = newSources[sourceId]; + const prevSource = prevSources ? prevSources[sourceId] : null; + if (!prevSource) { + toAdd.push(newSource); + } else if (prevSource.displayURL !== newSource.displayURL) { + toUpdate.push([prevSource, newSource]); + } + } + + return { toAdd, toUpdate }; +} + +type UpdateTreeParams = { + newSources: SourcesMapByThread, + prevSources: SourcesMapByThread, + uncollapsedTree: TreeDirectory, + debuggeeUrl: URL, + threads: Thread[], + sourceTree?: TreeNode, +}; + +type CreateTreeParams = { + sources: SourcesMapByThread, + debuggeeUrl: URL, + threads: Thread[], +}; + +export function createTree({ + debuggeeUrl, + sources, + threads, +}: CreateTreeParams) { + const uncollapsedTree = createDirectoryNode("root", "", []); + const result = updateTree({ + debuggeeUrl, + newSources: sources, + prevSources: {}, + threads, + uncollapsedTree, + }); + + if (!result) { + throw new Error("Tree must exist"); + } + + return result; +} + +export function updateTree({ + newSources, + prevSources, + debuggeeUrl, + uncollapsedTree, + threads, + create, + sourceTree, +}: UpdateTreeParams) { + const debuggeeHost = getDomain(debuggeeUrl); + const contexts = (Object.keys(newSources): any); + + let shouldUpdate = !sourceTree; + for (const context of contexts) { + const thread = threads.find(t => t.actor === context); + if (!thread) { + continue; + } + + const { toAdd, toUpdate } = getSourcesDiff( + (Object.values(newSources[context]): any), + prevSources[context] ? (Object.values(prevSources[context]): any) : null + ); + + for (const source of toAdd) { + shouldUpdate = true; + addToTree(uncollapsedTree, source, debuggeeHost, thread.actor); + } + + for (const [prevSource, newSource] of toUpdate) { + shouldUpdate = true; + updateInTree( + uncollapsedTree, + prevSource, + newSource, + debuggeeHost, + thread.actor + ); + } + } + + if (!shouldUpdate) { + return false; + } + + const newSourceTree = collapseTree(uncollapsedTree); + + return { + uncollapsedTree, + sourceTree: newSourceTree, + parentMap: createParentMap(newSourceTree), + }; +} + +export function updateInTree( + tree: TreeDirectory, + prevSource: DisplaySource, + newSource: DisplaySource, + debuggeeHost: ?string, + thread: string +): void { + const newUrl = getDisplayURL(newSource, debuggeeHost); + const prevUrl = getDisplayURL(prevSource, debuggeeHost); + + const prevEntries = findEntries( + tree, + prevUrl, + prevSource, + thread, + debuggeeHost + ); + if (!prevEntries) { + return; + } + + if (!isInvalidUrl(newUrl, newSource)) { + const parts = getPathParts(newUrl, thread, debuggeeHost); + + if (parts.length === prevEntries.length) { + let match = true; + for (let i = 0; i < parts.length - 2; i++) { + if (parts[i].path !== prevEntries[i + 1].node.path) { + match = false; + break; + } + } + + if (match) { + const { node, index } = prevEntries.pop(); + // This is guaranteed to be a TreeSource or else findEntries would + // not have returned anything. + const fileNode: TreeSource = (node.contents[index]: any); + fileNode.name = parts[parts.length - 1].part; + fileNode.path = parts[parts.length - 1].path; + fileNode.contents = newSource; + return; + } + } + } + + // Fall back to removing the current entry and inserting a new one if we + // are unable do a straight find-replace of the name and contents. + for (let i = prevEntries.length - 1; i >= 0; i--) { + const { node, index } = prevEntries[i]; + + // If the node has only a single child, we want to keep stepping upward + // to find the overall value to remove. + if (node.contents.length > 1 || (i === 0 && thread)) { + node.contents.splice(index, 1); + break; + } + } + addToTree(tree, newSource, debuggeeHost, thread); +} + +type Entry = { + node: TreeDirectory, + index: number, +}; + +function findEntries(tree, url, source, thread, debuggeeHost): ?Array<Entry> { + const parts = getPathParts(url, thread, debuggeeHost); + + // We're searching for the directory containing the file so we pop off the + // potential filename. This is because the tree has some logic to inject + // special entries when filename parts either conflict with directories, and + // so the last bit of removal needs to do a broad search to find the exact + // target location. + parts.pop(); + + const entries = []; + let currentNode = tree; + for (const { part } of parts) { + const { found: childFound, index: childIndex } = findNodeInContents( + currentNode, + createTreeNodeMatcher(part, true, debuggeeHost) + ); + + if (!childFound || currentNode.type !== "directory") { + return null; + } + + entries.push({ + node: currentNode, + index: childIndex, + }); + + currentNode = currentNode.contents[childIndex]; + } + + // From this point, we do a depth-first search for a node containing the + // specified source, as mentioned above. + const found = (function search(node) { + if (node.type !== "directory") { + if (node.contents.id === source.id) { + return []; + } + return null; + } + + for (let i = 0; i < node.contents.length; i++) { + const child = node.contents[i]; + const result = search(child); + if (result) { + result.unshift({ + node, + index: i, + }); + return result; + } + } + + return null; + })(currentNode); + + return found ? [...entries, ...found] : null; +} diff --git a/devtools/client/debugger/src/utils/sources-tree/utils.js b/devtools/client/debugger/src/utils/sources-tree/utils.js new file mode 100644 index 0000000000..f893fa76a5 --- /dev/null +++ b/devtools/client/debugger/src/utils/sources-tree/utils.js @@ -0,0 +1,292 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { parse } from "../../utils/url"; + +import type { TreeNode, TreeSource, TreeDirectory, ParentMap } from "./types"; +import type { Source, Thread, URL } from "../../types"; +import type { SourcesMapByThread } from "../../reducers/types"; +import { isPretty } from "../source"; +import { getURL, type ParsedURL } from "./getURL"; +const IGNORED_URLS = ["debugger eval code", "XStringBundle"]; + +export type PathPart = { + part: string, + path: string, + debuggeeHostIfRoot: ?string, +}; +export function getPathParts( + url: ParsedURL, + thread: string, + debuggeeHost: ?string +): Array<PathPart> { + const parts = url.path.split("/"); + if (parts.length > 1 && parts[parts.length - 1] === "") { + parts.pop(); + if (url.search) { + parts.push(url.search); + } + } else { + parts[parts.length - 1] += url.search; + } + + parts[0] = url.group; + if (thread) { + parts.unshift(thread); + } + + let path = ""; + return parts.map((part, index) => { + if (index == 0 && thread) { + path = thread; + } else { + path = `${path}/${part}`; + } + + const debuggeeHostIfRoot = index === 1 ? debuggeeHost : null; + + return { + part, + path, + debuggeeHostIfRoot, + }; + }); +} + +export function nodeHasChildren(item: TreeNode): boolean { + return item.type == "directory" && Array.isArray(item.contents); +} + +export function isExactUrlMatch(pathPart: string, debuggeeUrl: URL): boolean { + // compare to hostname with an optional 'www.' prefix + const { host } = parse(debuggeeUrl); + if (!host) { + return false; + } + return ( + host === pathPart || + host.replace(/^www\./, "") === pathPart.replace(/^www\./, "") + ); +} + +export function isPathDirectory(path: string): boolean { + // Assume that all urls point to files except when they end with '/' + // Or directory node has children + + if (path.endsWith("/")) { + return true; + } + + let separators = 0; + for (let i = 0; i < path.length - 1; ++i) { + if (path[i] === "/") { + if (path[i + i] !== "/") { + return false; + } + + ++separators; + } + } + + switch (separators) { + case 0: { + return false; + } + case 1: { + return !path.startsWith("/"); + } + default: { + return true; + } + } +} + +export function isDirectory(item: TreeNode): boolean { + return ( + (item.type === "directory" || isPathDirectory(item.path)) && + item.name != "(index)" + ); +} + +export function getSourceFromNode(item: TreeNode): ?Source { + const { contents } = item; + if (!isDirectory(item) && !Array.isArray(contents)) { + return contents; + } +} + +export function isSource(item: TreeNode): boolean { + return item.type === "source"; +} + +export function getFileExtension(source: Source): string { + const { path } = getURL(source); + if (!path) { + return ""; + } + + const lastIndex = path.lastIndexOf("."); + return lastIndex !== -1 ? path.slice(lastIndex + 1) : ""; +} + +export function isNotJavaScript(source: Source): boolean { + return ["css", "svg", "png"].includes(getFileExtension(source)); +} + +export function isInvalidUrl(url: ParsedURL, source: Source): boolean { + return ( + !source.url || + !url.group || + isNotJavaScript(source) || + IGNORED_URLS.includes(url) || + isPretty(source) + ); +} + +export function partIsFile( + index: number, + parts: Array<PathPart>, + url: Object +): boolean { + const isLastPart = index === parts.length - 1; + return isLastPart && !isDirectory(url); +} + +export function createDirectoryNode( + name: string, + path: string, + contents: TreeNode[] +): TreeDirectory { + return { + type: "directory", + name, + path, + contents, + }; +} + +export function createSourceNode( + name: string, + path: string, + contents: Source +): TreeSource { + return { + type: "source", + name, + path, + contents, + }; +} + +export function createParentMap(tree: TreeNode): ParentMap { + const map = new WeakMap(); + + function _traverse(subtree) { + if (subtree.type === "directory") { + for (const child of subtree.contents) { + map.set(child, subtree); + _traverse(child); + } + } + } + + if (tree.type === "directory") { + // Don't link each top-level path to the "root" node because the + // user never sees the root + tree.contents.forEach(_traverse); + } + + return map; +} + +export function getRelativePath(url: URL): string { + const { pathname } = parse(url); + if (!pathname) { + return url; + } + const index = pathname.indexOf("/"); + + return index !== -1 ? pathname.slice(index + 1) : ""; +} + +export function getPathWithoutThread(path: string): string { + const pathParts = path.split(/(context\d+?\/)/).splice(2); + if (pathParts && pathParts.length > 0) { + return pathParts.join(""); + } + return ""; +} + +export function findSource( + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread }, + itemPath: string, + source: ?Source +): ?Source { + const targetThread = threads.find(thread => itemPath.includes(thread.actor)); + if (targetThread && source) { + const { actor } = targetThread; + if (sources[actor]) { + return sources[actor][source.id]; + } + } + return source; +} + +// NOTE: we get the source from sources because item.contents is cached +export function getSource( + item: TreeNode, + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread } +): ?Source { + const source = getSourceFromNode(item); + return findSource({ threads, sources }, item.path, source); +} + +export function getChildren(item: $Shape<TreeDirectory>) { + return nodeHasChildren(item) ? item.contents : []; +} + +export function getAllSources({ + threads, + sources, +}: { + threads: Thread[], + sources: SourcesMapByThread, +}): Source[] { + const sourcesAll = []; + threads.forEach(thread => { + const { actor } = thread; + + for (const source in sources[actor]) { + sourcesAll.push(sources[actor][source]); + } + }); + return sourcesAll; +} + +export function getSourcesInsideGroup( + item: TreeNode, + { threads, sources }: { threads: Thread[], sources: SourcesMapByThread } +): Source[] { + const sourcesInsideDirectory = []; + + const findAllSourcesInsideDirectory = (directoryToSearch: TreeDirectory) => { + const childrenItems = getChildren(directoryToSearch); + + childrenItems.forEach((itemChild: TreeNode) => { + if (itemChild.type === "directory") { + findAllSourcesInsideDirectory(itemChild); + } else { + const source = getSource(itemChild, { threads, sources }); + if (source) { + sourcesInsideDirectory.push(source); + } + } + }); + }; + if (item.type === "directory") { + findAllSourcesInsideDirectory(item); + } + return sourcesInsideDirectory; +} |