diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..433dd95056 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -139,6 +139,14 @@ CallNodeContextMenu--transform-collapse-resource = .title = Collapsing a resource will flatten out all the calls to that resource into a single collapsed call node. +# This is used as the context menu item to apply the "Collapse source" transform. +# Variables: +# $nameForSource (String) - Name of the source file to collapse. +CallNodeContextMenu--transform-collapse-source = + Collapse source { $nameForSource } + .title = + Collapsing a source file will flatten out all the calls from that + source file into a single collapsed call node. CallNodeContextMenu--transform-collapse-recursion = Collapse recursion .title = Collapsing recursion removes calls that repeatedly recurse into @@ -1155,6 +1163,11 @@ TransformNavigator--complete = Complete “{ $item }” # $item (String) - Name of the resource that collapsed. E.g.: libxul.so. TransformNavigator--collapse-resource = Collapse: { $item } +# "Collapse source" transform. +# Variables: +# $item (String) - Name of the source file that was collapsed. E.g.: foo.js. +TransformNavigator--collapse-source = Collapse source: { $item } + # "Focus subtree" transform. # See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus # Variables: diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..8a9c0123f4 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -16,6 +16,7 @@ import { getThreads, getLastNonShiftClick, getReservedFunctionsForResources, + getReservedFunctionsForSources, } from 'firefox-profiler/selectors/profile'; import { getThreadSelectors, @@ -63,6 +64,7 @@ import type { CallNodePath, IndexIntoCallNodeTable, IndexIntoResourceTable, + IndexIntoSourceTable, TrackIndex, MarkerIndex, Transform, @@ -1847,6 +1849,29 @@ export function addCollapseResourceTransformToStack( }; } +export function addCollapseSourceTransformToStack( + threadsKey: ThreadsKey, + sourceIndex: IndexIntoSourceTable, + implementation: ImplementationFilter +): ThunkAction { + return (dispatch, getState) => { + const reservedFunctionsForSources = + getReservedFunctionsForSources(getState()); + const collapsedFuncIndex = ensureExists( + ensureExists(reservedFunctionsForSources).get(sourceIndex) + ); + + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-source', + sourceIndex, + collapsedFuncIndex, + implementation, + }) + ); + }; +} + export function popTransformsFromStack( firstPoppedFilterIndex: number ): ThunkAction { @@ -2103,6 +2128,20 @@ export function handleCallNodeTransformShortcut( }) ); break; + case 'X': { + const { funcTable } = unfilteredThread; + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + dispatch( + addCollapseSourceTransformToStack( + threadsKey, + sourceIndex, + implementation + ) + ); + } + break; + } default: // This did not match a call tree transform. } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index eecd06a2be..b2383c87cf 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -53,7 +53,7 @@ import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; -export const CURRENT_URL_VERSION = 15; +export const CURRENT_URL_VERSION = 16; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -1351,6 +1351,11 @@ const _upgraders: { .map(mapIndexesInTransform) .join('~'); }, + [16]: (_processedLocation: ProcessedLocationBeforeUpgrade) => { + // Version 16 introduced the 'collapse-source' transform ('cs' short key). + // No URL structure changes are needed; this version bump ensures older + // versions of the profiler do not silently ignore the new transform. + }, }; for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) { diff --git a/src/components/shared/CallNodeContextMenu.tsx b/src/components/shared/CallNodeContextMenu.tsx index 1a360f01c6..4e06e17c68 100644 --- a/src/components/shared/CallNodeContextMenu.tsx +++ b/src/components/shared/CallNodeContextMenu.tsx @@ -21,6 +21,7 @@ import copy from 'copy-to-clipboard'; import { addTransformToStack, addCollapseResourceTransformToStack, + addCollapseSourceTransformToStack, expandAllCallNodeDescendants, updateBottomBoxContentsAndMaybeOpen, setContextMenuVisibility, @@ -80,6 +81,7 @@ type StateProps = { type DispatchProps = { readonly addTransformToStack: typeof addTransformToStack; readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly addCollapseSourceTransformToStack: typeof addCollapseSourceTransformToStack; readonly expandAllCallNodeDescendants: typeof expandAllCallNodeDescendants; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; readonly setContextMenuVisibility: typeof setContextMenuVisibility; @@ -326,6 +328,7 @@ class CallNodeContextMenuImpl extends React.PureComponent { const { addTransformToStack, addCollapseResourceTransformToStack, + addCollapseSourceTransformToStack, implementation, inverted, } = this.props; @@ -393,6 +396,21 @@ class CallNodeContextMenuImpl extends React.PureComponent { ); break; } + case 'collapse-source': { + const { funcTable } = thread; + const sourceIndex = funcTable.source[selectedFunc]; + if (sourceIndex === null) { + throw new Error( + 'collapse-source was triggered on a function without a source' + ); + } + addCollapseSourceTransformToStack( + threadsKey, + sourceIndex, + implementation + ); + break; + } case 'collapse-direct-recursion': { addTransformToStack(threadsKey, { type: 'collapse-direct-recursion', @@ -539,6 +557,36 @@ class CallNodeContextMenuImpl extends React.PureComponent { return stringTable.getString(resNameStringIndex); } + getNameForSelectedSource(): string | null { + const rightClickedCallNodeInfo = this.getRightClickedCallNodeInfo(); + + if (rightClickedCallNodeInfo === null) { + throw new Error( + "The context menu assumes there is a selected call node and there wasn't one." + ); + } + + const { + callNodeInfo, + callNodeIndex, + thread: { funcTable, stringTable, sources }, + } = rightClickedCallNodeInfo; + + const funcIndex = callNodeInfo.funcForNode(callNodeIndex); + if (funcIndex === undefined) { + return null; + } + if (!funcTable.isJS[funcIndex]) { + return null; + } + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + const fileNameIndex = sources.filename[sourceIndex]; + return stringTable.getString(fileNameIndex); + } + getRightClickedCallNodeInfo(): null | { readonly thread: Thread; readonly threadsKey: ThreadsKey; @@ -602,6 +650,7 @@ class CallNodeContextMenuImpl extends React.PureComponent { const hasCategory = categoryIndex !== -1; // This could be the C++ library, or the JS filename. const nameForResource = this.getNameForSelectedResource(); + const nameForSource = this.getNameForSelectedSource(); const categoryName: string = hasCategory ? categories[categoryIndex].name : ''; @@ -740,6 +789,22 @@ class CallNodeContextMenuImpl extends React.PureComponent { }) : null} + {nameForSource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-source', + l10nProps: { + vars: { nameForSource: nameForSource }, + elems: { strong: }, + }, + shortcut: 'X', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-source', + title: '', + content: `Collapse source ${nameForSource}`, + }) + : null} + {funcHasRecursiveCall(callNodeTable, funcIndex) ? this.renderTransformMenuItem({ l10nId: 'CallNodeContextMenu--transform-collapse-recursion', @@ -951,6 +1016,7 @@ export const CallNodeContextMenu = explicitConnect< mapDispatchToProps: { addTransformToStack, addCollapseResourceTransformToStack, + addCollapseSourceTransformToStack, expandAllCallNodeDescendants, updateBottomBoxContentsAndMaybeOpen, setContextMenuVisibility, diff --git a/src/profile-logic/index-translation.ts b/src/profile-logic/index-translation.ts index 45b1fff56a..50b1f2a3c9 100644 --- a/src/profile-logic/index-translation.ts +++ b/src/profile-logic/index-translation.ts @@ -6,6 +6,7 @@ import type { CallNodePath, IndexIntoFuncTable, IndexIntoResourceTable, + IndexIntoSourceTable, ProfileIndexTranslationMaps, } from 'firefox-profiler/types'; @@ -21,9 +22,20 @@ export function translateResourceIndex( return newResourceIndexPlusOne !== 0 ? newResourceIndexPlusOne - 1 : null; } +// Returns the new source index for the given old source index. +// Returns null if the index has no new index equivalent. +export function translateSourceIndex( + sourceIndex: IndexIntoSourceTable, + translationMaps: ProfileIndexTranslationMaps +): IndexIntoSourceTable | null { + const newSourceIndexPlusOne = + translationMaps.oldSourceToNewSourcePlusOne[sourceIndex]; + return newSourceIndexPlusOne !== 0 ? newSourceIndexPlusOne - 1 : null; +} + // Returns the new func index for the given old func index. -// This handles indexes for "reserved funcs" for collapsed resources, which -// are located after the regular funcTable. +// This handles indexes for "reserved funcs" for collapsed resources and +// collapsed sources, which are located after the regular funcTable. // Returns null if the index has no new index equivalent. export function translateFuncIndex( funcIndex: IndexIntoFuncTable, @@ -35,14 +47,26 @@ export function translateFuncIndex( translationMaps.oldFuncToNewFuncPlusOne[funcIndex]; return newFuncIndexPlusOne !== 0 ? newFuncIndexPlusOne - 1 : null; } - // This must be a funcIndex from the "func table with reserved functions for collapsed resources". - const resourceIndex = funcIndex - oldFuncCount; - const newResourceIndex = translateResourceIndex( - resourceIndex, - translationMaps - ); - return newResourceIndex !== null - ? translationMaps.newFuncCount + newResourceIndex + const reservedOffset = funcIndex - oldFuncCount; + const oldResourceCount = translationMaps.oldResourceCount; + if (reservedOffset < oldResourceCount) { + // This is a reserved func for a collapsed resource. + const resourceIndex = reservedOffset; + const newResourceIndex = translateResourceIndex( + resourceIndex, + translationMaps + ); + return newResourceIndex !== null + ? translationMaps.newFuncCount + newResourceIndex + : null; + } + // This is a reserved func for a collapsed source. + const sourceIndex = reservedOffset - oldResourceCount; + const newSourceIndex = translateSourceIndex(sourceIndex, translationMaps); + return newSourceIndex !== null + ? translationMaps.newFuncCount + + translationMaps.newResourceCount + + newSourceIndex : null; } diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 269fc4fc46..c123ab5fa9 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -503,6 +503,8 @@ export function mergeSharedData(profiles: Profile[]): { oldThreadIndexToNew: null, oldFuncCount: profile.shared.funcTable.length, newFuncCount: newFuncTable.length, + oldResourceCount: profile.shared.resourceTable.length, + newResourceCount: newResourceTable.length, oldLibToNewLibPlusOne, oldStringToNewStringPlusOne, oldSourceToNewSourcePlusOne, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 645d8a9cc8..d150321b0a 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -3281,18 +3281,27 @@ export function getOriginAnnotationForFunc( * * This returns a new thread with an extended funcTable. * - * At the moment, the only functions we reserve are "collapsed resource" functions. - * These are used by the "collapse resource" transform. + * We reserve two sets of functions: + * - "collapsed resource" functions (used by the "collapse-resource" transform) + * - "collapsed source" functions (used by the "collapse-source" transform) + * + * Resource reserved funcs occupy indices [funcCount, funcCount + resourceCount). + * Source reserved funcs occupy indices [funcCount + resourceCount, funcCount + resourceCount + sourceCount). */ export function reserveFunctionsForCollapsedResources( originalFuncTable: FuncTable, - resourceTable: ResourceTable + resourceTable: ResourceTable, + sourceTable: SourceTable ): FuncTableWithReservedFunctions { const funcTable = shallowCloneFuncTable(originalFuncTable); const reservedFunctionsForResources = new Map< IndexIntoResourceTable, IndexIntoFuncTable >(); + const reservedFunctionsForSources = new Map< + IndexIntoSourceTable, + IndexIntoFuncTable + >(); const jsResourceTypes = [ ResourceType.Addon, ResourceType.Url, @@ -3318,9 +3327,23 @@ export function reserveFunctionsForCollapsedResources( funcTable.length++; reservedFunctionsForResources.set(resourceIndex, funcIndex); } + for (let sourceIndex = 0; sourceIndex < sourceTable.length; sourceIndex++) { + const name = sourceTable.filename[sourceIndex]; + const funcIndex = funcTable.length; + funcTable.isJS.push(true); + funcTable.relevantForJS.push(true); + funcTable.name.push(name); + funcTable.resource.push(-1); + funcTable.source.push(sourceIndex); + funcTable.lineNumber.push(null); + funcTable.columnNumber.push(null); + funcTable.length++; + reservedFunctionsForSources.set(sourceIndex, funcIndex); + } return { funcTable, reservedFunctionsForResources, + reservedFunctionsForSources, }; } diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts index dec2160f55..47f2334162 100644 --- a/src/profile-logic/sanitize.ts +++ b/src/profile-logic/sanitize.ts @@ -390,6 +390,9 @@ export function sanitizePII( oldFuncCount: profile.shared.funcTable.length, newFuncCount: compactedProfileWithTranslationMaps.profile.shared.funcTable.length, + oldResourceCount: profile.shared.resourceTable.length, + newResourceCount: + compactedProfileWithTranslationMaps.profile.shared.resourceTable.length, ...compactedProfileWithTranslationMaps.translationMaps, }, }; diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 05ba86b2bc..65da62cf96 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -31,6 +31,7 @@ import type { IndexIntoFuncTable, IndexIntoStackTable, IndexIntoResourceTable, + IndexIntoSourceTable, CallNodePath, CallNodeTable, StackType, @@ -54,6 +55,7 @@ import { translateCallNodePath, translateFuncIndex, translateResourceIndex, + translateSourceIndex, } from './index-translation'; import { checkBit, makeBitSet, setBit } from 'firefox-profiler/utils/bitset'; @@ -70,6 +72,7 @@ const TRANSFORM_OBJ: { [key in TransformType]: true } = { 'merge-function': true, 'drop-function': true, 'collapse-resource': true, + 'collapse-source': true, 'collapse-direct-recursion': true, 'collapse-recursion': true, 'collapse-function-subtree': true, @@ -112,6 +115,9 @@ ALL_TRANSFORM_TYPES.forEach((transform: TransformType) => { case 'collapse-resource': shortKey = 'cr'; break; + case 'collapse-source': + shortKey = 'cs'; + break; case 'collapse-direct-recursion': shortKey = 'drec'; break; @@ -183,6 +189,25 @@ export function parseTransforms(transformString: string): TransformStack { break; } + case 'collapse-source': { + // e.g. "cs-js-325-8" + const [, implementation, sourceIndexRaw, collapsedFuncIndexRaw] = tuple; + const sourceIndex = parseInt(sourceIndexRaw, 10); + const collapsedFuncIndex = parseInt(collapsedFuncIndexRaw, 10); + if (isNaN(sourceIndex) || isNaN(collapsedFuncIndex)) { + break; + } + if (sourceIndex >= 0) { + transforms.push({ + type, + sourceIndex, + collapsedFuncIndex, + implementation: toValidImplementationFilter(implementation), + }); + } + + break; + } case 'collapse-recursion': { // e.g. "rec-325" const [, funcIndexRaw] = tuple; @@ -381,6 +406,8 @@ export function stringifyTransforms(transformStack: TransformStack): string { return `${shortKey}-${transform.category}`; case 'collapse-resource': return `${shortKey}-${transform.implementation}-${transform.resourceIndex}-${transform.collapsedFuncIndex}`; + case 'collapse-source': + return `${shortKey}-${transform.implementation}-${transform.sourceIndex}-${transform.collapsedFuncIndex}`; case 'collapse-recursion': return `${shortKey}-${transform.funcIndex}`; case 'collapse-direct-recursion': @@ -426,7 +453,7 @@ export function getTransformLabelL10nIds( threadName: string, transforms: Transform[] ): Array { - const { funcTable, stringTable, resourceTable } = thread; + const { funcTable, stringTable, resourceTable, sources } = thread; const { categories } = meta; const labels: TransformLabeL10nIds[] = transforms.map((transform) => { // Lookup library information. @@ -439,6 +466,15 @@ export function getTransformLabelL10nIds( }; } + if (transform.type === 'collapse-source') { + const nameIndex = sources.filename[transform.sourceIndex]; + const sourceName = stringTable.getString(nameIndex); + return { + l10nId: 'TransformNavigator--collapse-source', + item: sourceName, + }; + } + if (transform.type === 'focus-category') { if (categories === undefined) { throw new Error('Expected categories to be defined.'); @@ -564,6 +600,13 @@ export function applyTransformToCallNodePath( transformedThread.funcTable, callNodePath ); + case 'collapse-source': + return _collapseSourceInCallNodePath( + transform.sourceIndex, + transform.collapsedFuncIndex, + transformedThread.funcTable, + callNodePath + ); case 'collapse-direct-recursion': return _collapseDirectRecursionInCallNodePath( transform.funcIndex, @@ -698,6 +741,32 @@ function _collapseResourceInCallNodePath( ); } +function _collapseSourceInCallNodePath( + sourceIndex: IndexIntoSourceTable, + collapsedFuncIndex: IndexIntoFuncTable, + funcTable: FuncTable, + callNodePath: CallNodePath +) { + return ( + callNodePath + // Map any collapsed functions into the collapsedFuncIndex + .map((pathFuncIndex) => { + return funcTable.source[pathFuncIndex] === sourceIndex + ? collapsedFuncIndex + : pathFuncIndex; + }) + // De-duplicate contiguous collapsed funcs + .filter( + (pathFuncIndex, pathIndex, path) => + // This function doesn't match the previous one, so keep it. + pathFuncIndex !== path[pathIndex - 1] || + // This function matched the previous, only keep it if doesn't match the + // collapsed func. + pathFuncIndex !== collapsedFuncIndex + ) + ); +} + function _collapseDirectRecursionInCallNodePath( funcIndex: IndexIntoFuncTable, callNodePath: CallNodePath @@ -973,6 +1042,46 @@ export function collapseResource( return collapseDirectRecursion(newThread, collapsedFuncIndex, implementation); } +/** + * Substitute any functions from a given source file with the source's + * "collapsed source function", and then collapse consecutive frames with that + * function into a single frame. + * + * This is the source-based counterpart of collapseResource, using the source + * table (specific JS file paths) instead of the resource table (script origins). + */ +export function collapseSource( + thread: Thread, + sourceIndexToCollapse: IndexIntoSourceTable, + collapsedFuncIndex: IndexIntoFuncTable, + implementation: ImplementationFilter +): Thread { + // Strategy: remap all frames from the given source to collapsedFuncIndex, + // then delegate to collapseDirectRecursion to merge consecutive frames with + // that func into one. + const { funcTable, frameTable } = thread; + + // Remap every frame whose func belongs to the collapsed source. + const newFrameTableFuncCol = frameTable.func.slice(); + for (let i = 0; i < frameTable.length; i++) { + const funcIndex = frameTable.func[i]; + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === sourceIndexToCollapse) { + newFrameTableFuncCol[i] = collapsedFuncIndex; + } + } + + const newThread = { + ...thread, + frameTable: { + ...frameTable, + func: newFrameTableFuncCol, + }, + }; + + return collapseDirectRecursion(newThread, collapsedFuncIndex, implementation); +} + export function collapseDirectRecursion( thread: Thread, funcToCollapse: IndexIntoFuncTable, @@ -1841,6 +1950,13 @@ export function applyTransform( transform.collapsedFuncIndex, transform.implementation ); + case 'collapse-source': + return collapseSource( + thread, + transform.sourceIndex, + transform.collapsedFuncIndex, + transform.implementation + ); case 'collapse-direct-recursion': return collapseDirectRecursion( thread, @@ -2004,6 +2120,30 @@ export function translateTransform( collapsedFuncIndex: newCollapsedFuncIndex, }; } + case 'collapse-source': { + const newSourceIndex = translateSourceIndex( + transform.sourceIndex, + translationMaps + ); + if (newSourceIndex === null) { + // If the collapsed source is missing, that means we don't have any + // samples in the sanitized thread which contain any function with this + // source in their stack, which means that this transform was a no-op + // in the range filtered thread. + // We can just drop this transform. + return null; + } + const newCollapsedFuncIndex = + translationMaps.newFuncCount + + translationMaps.newResourceCount + + newSourceIndex; + return { + type, + sourceIndex: newSourceIndex, + implementation: transform.implementation, + collapsedFuncIndex: newCollapsedFuncIndex, + }; + } case 'collapse-direct-recursion': { const newFuncIndex = translateFuncIndex( transform.funcIndex, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 7ac0ef4f2c..81c92e6e4e 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -77,6 +77,7 @@ import type { SourceTable, FuncTableWithReservedFunctions, IndexIntoResourceTable, + IndexIntoSourceTable, IndexIntoFuncTable, FuncTable, } from 'firefox-profiler/types'; @@ -267,6 +268,7 @@ export const getFuncTableWithReservedFunctions: Selector getRawProfileSharedData(state).funcTable, (state: State) => getRawProfileSharedData(state).resourceTable, + (state: State) => getRawProfileSharedData(state).sources, reserveFunctionsForCollapsedResources ); @@ -278,6 +280,11 @@ export const getReservedFunctionsForResources: Selector< > = (state) => getFuncTableWithReservedFunctions(state).reservedFunctionsForResources; +export const getReservedFunctionsForSources: Selector< + Map +> = (state) => + getFuncTableWithReservedFunctions(state).reservedFunctionsForSources; + export const getSourceTable: Selector = (state: State) => getRawProfileSharedData(state).sources; diff --git a/src/test/components/CallNodeContextMenu.test.tsx b/src/test/components/CallNodeContextMenu.test.tsx index 0e032e73cf..8b598086bb 100644 --- a/src/test/components/CallNodeContextMenu.test.tsx +++ b/src/test/components/CallNodeContextMenu.test.tsx @@ -148,6 +148,14 @@ describe('calltree/CallNodeContextMenu', function () { ).toBe(type); }); }); + + it('adds a transform for "collapse-source"', function () { + const { getState } = setup(createStoreWithJsCallStack()); + fireFullClick(screen.getByText(/Collapse source/)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe('collapse-source'); + }); }); describe('clicking on the rest of the menu items', function () { diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..8f76ac2af7 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -12,10 +12,10 @@ import { changeSelectedCallNode, changeRightClickedCallNode, } from '../../actions/profile-view'; +import { addSourceToTable, fireFullKeyPress } from '../fixtures/utils'; import { FlameGraph } from '../../components/flame-graph'; import { selectedThreadSelectors } from 'firefox-profiler/selectors'; import { ensureExists, objectEntries } from '../../utils/types'; -import { fireFullKeyPress } from '../fixtures/utils'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { ProfileCallTreeView } from '../../components/calltree/ProfileCallTreeView'; import { StackChart } from 'firefox-profiler/components/stack-chart'; @@ -326,3 +326,73 @@ describe('stack chart transform shortcuts', () => { }); } }); + +describe('collapse-source shortcut (X)', () => { + function setupWithJsSource() { + const { + profile, + stringTable, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A.js + B.js + `); + const fileNameIndex = stringTable.indexForString( + 'https://example.com/script.js' + ); + const sourceIndex = addSourceToTable(profile.shared.sources, fileNameIndex); + + const { funcTable } = profile.shared; + const funcIndexB = funcNames.indexOf('B.js'); + funcTable.source[funcIndexB] = sourceIndex; + + const store = storeWithProfile(profile); + const { getState } = store; + + render( + + + + ); + + act(() => { + store.dispatch( + changeSelectedCallNode(0, [funcNames.indexOf('A.js'), funcIndexB]) + ); + }); + + return { + store, + sourceIndex, + funcIndexB, + getTransform: () => { + const stack = selectedThreadSelectors.getTransformStack(getState()); + return stack.length === 1 ? stack[0] : null; + }, + pressKey: pressKeyBuilder('treeViewBody'), + }; + } + + it('handles collapse source', () => { + const { pressKey, getTransform, sourceIndex } = setupWithJsSource(); + pressKey({ key: 'X' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-source', + sourceIndex, + }); + }); + + it('ignores X when no source is set', () => { + // setupStore creates a profile where functions have no source entries + const { store, funcNames, getTransform } = setupStore( + + ); + const { A, B } = funcNames; + act(() => { + store.dispatch(changeSelectedCallNode(0, [A, B])); + }); + const pressKey = pressKeyBuilder('treeViewBody'); + pressKey({ key: 'X' }); + expect(getTransform()).toBeNull(); + }); +}); diff --git a/src/test/store/transforms.test.ts b/src/test/store/transforms.test.ts index 0773185967..6d79e040fa 100644 --- a/src/test/store/transforms.test.ts +++ b/src/test/store/transforms.test.ts @@ -8,7 +8,7 @@ import { getProfileWithJsAllocations, addMarkersToThreadWithCorrespondingSamples, } from '../fixtures/profiles/processed-profile'; -import { formatTree } from '../fixtures/utils'; +import { formatTree, addSourceToTable } from '../fixtures/utils'; import { storeWithProfile } from '../fixtures/stores'; import { assertSetContainsOnly } from '../fixtures/custom-assertions'; import { @@ -19,6 +19,7 @@ import { import { addTransformToStack, addCollapseResourceTransformToStack, + addCollapseSourceTransformToStack, popTransformsFromStack, changeInvertCallstack, changeImplementationFilter, @@ -1231,6 +1232,85 @@ describe('"collapse-resource" transform', function () { }); }); +describe('"collapse-source" transform', function () { + /** + * A A + * -----´ `----- | + * / \ v + * v v Collapse foo.js foo.js + * B[src:foo.js] E[src:foo.js] -> / \ + * | | D F + * v v + * C[src:foo.js] F + * | + * v + * D + */ + const { + profile, + stringTable, + funcNamesPerThread: [funcNames], + } = getProfileFromTextSamples(` + A A + B E + C F + D + `); + const fooUrlIndex = stringTable.indexForString('foo.js'); + const fooSourceIndex = addSourceToTable(profile.shared.sources, fooUrlIndex); + const { funcTable } = profile.shared; + funcTable.source[funcNames.indexOf('B')] = fooSourceIndex; + funcTable.source[funcNames.indexOf('C')] = fooSourceIndex; + funcTable.source[funcNames.indexOf('E')] = fooSourceIndex; + const collapsedFuncNames = [...funcNames, 'foo.js']; + const threadIndex = 0; + + it('starts as an unfiltered call tree', function () { + const { getState } = storeWithProfile(profile); + expect(formatTree(selectedThreadSelectors.getCallTree(getState()))).toEqual( + [ + '- A (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: —)', + ' - D (total: 1, self: 1)', + ' - E (total: 1, self: —)', + ' - F (total: 1, self: 1)', + ] + ); + }); + + it('can collapse functions from "foo.js"', function () { + const { dispatch, getState } = storeWithProfile(profile); + dispatch( + addCollapseSourceTransformToStack(threadIndex, fooSourceIndex, 'combined') + ); + expect(formatTree(selectedThreadSelectors.getCallTree(getState()))).toEqual( + [ + '- A (total: 2, self: —)', + ' - foo.js (total: 2, self: —)', + ' - D (total: 1, self: 1)', + ' - F (total: 1, self: 1)', + ] + ); + }); + + it('can apply the transform to the selected CallNodePaths', function () { + const { dispatch, getState } = storeWithProfile(profile); + dispatch( + changeSelectedCallNode( + threadIndex, + ['A', 'B', 'C', 'D'].map((name) => collapsedFuncNames.indexOf(name)) + ) + ); + dispatch( + addCollapseSourceTransformToStack(threadIndex, fooSourceIndex, 'combined') + ); + expect(selectedThreadSelectors.getSelectedCallNodePath(getState())).toEqual( + ['A', 'foo.js', 'D'].map((name) => collapsedFuncNames.indexOf(name)) + ); + }); +}); + describe('"collapse-function-subtree" transform', function () { /** * A:4,0 A:4,0 diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index 88ff994ee0..770e1675de 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -1309,7 +1309,7 @@ describe('url upgrading', function () { describe('URL serialization of the transform stack', function () { const transformString = 'f-combined-0w2~mcn-combined-2w4~f-js-3w5-i~mf-6~ff-7~fg-42~cr-combined-8-9~' + - 'drec-combined-10~rec-11~df-12~cfs-13'; + 'drec-combined-10~rec-11~df-12~cfs-13~cs-combined-14-15'; const { getState } = _getStoreWithURL({ search: '?transforms=' + transformString, }); @@ -1372,6 +1372,12 @@ describe('URL serialization of the transform stack', function () { type: 'collapse-function-subtree', funcIndex: 13, }, + { + type: 'collapse-source', + sourceIndex: 14, + collapsedFuncIndex: 15, + implementation: 'combined', + }, ]); }); diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index f52454fd9e..82d0ef577e 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -531,6 +531,7 @@ export type FuncTableWithReservedFunctions = { IndexIntoResourceTable, IndexIntoFuncTable >; + reservedFunctionsForSources: Map; }; /** @@ -814,6 +815,8 @@ export type ProfileIndexTranslationMaps = { oldThreadIndexToNew: Map | null; oldFuncCount: number; newFuncCount: number; + oldResourceCount: number; + newResourceCount: number; oldStackToNewStackPlusOne: Int32Array; oldFrameToNewFramePlusOne: Int32Array; oldFuncToNewFuncPlusOne: Int32Array; diff --git a/src/types/transforms.ts b/src/types/transforms.ts index 0837ced0d7..8c63d56f46 100644 --- a/src/types/transforms.ts +++ b/src/types/transforms.ts @@ -17,6 +17,7 @@ import type { IndexIntoFuncTable, IndexIntoResourceTable, + IndexIntoSourceTable, IndexIntoCategoryList, } from './profile'; import type { CallNodePath, ThreadsKey } from './profile-derived'; @@ -281,6 +282,31 @@ export type TransformDefinitions = { readonly implementation: ImplementationFilter; }; + /** + * Collapse source takes CallNodes that are from a consecutive source file, and + * collapses them into a new collapsed pseudo-stack. This is the same operation + * as collapse-resource, but it uses the source table (specific JS file paths) + * instead of the resource table (script origins/hosts). + * + * A A + * / \ | + * v v Collapse foo.js v + * B:foo.js E:foo.js -> foo.js + * | | / \ + * v v D F + * C:foo.js F + * | + * v + * D + */ + 'collapse-source': { + readonly type: 'collapse-source'; + readonly sourceIndex: IndexIntoSourceTable; + // This is the index of the newly created function that represents the collapsed stack. + readonly collapsedFuncIndex: IndexIntoFuncTable; + readonly implementation: ImplementationFilter; + }; + /** * Collapse direct recursion takes a function that calls itself recursively and collapses * it into a single stack. diff --git a/src/utils/types.ts b/src/utils/types.ts index 1d6df05f5e..5a89891020 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -88,6 +88,7 @@ export function convertToTransformType(type: string): TransformType | null { case 'focus-category': case 'focus-self': case 'collapse-resource': + case 'collapse-source': case 'collapse-direct-recursion': case 'collapse-recursion': case 'collapse-function-subtree':