diff --git a/src/actions/receive-profile.js b/src/actions/receive-profile.js index b7d26ce1d5..87d722e7be 100644 --- a/src/actions/receive-profile.js +++ b/src/actions/receive-profile.js @@ -260,11 +260,13 @@ export function finalizeFullProfileView( ); const localTracksByPid = computeLocalTracksByPid(profile, globalTracks); + const threadActivityScores = getThreadActivityScores(getState()); const legacyThreadOrder = getLegacyThreadOrder(getState()); const globalTrackOrder = initializeGlobalTrackOrder( globalTracks, hasUrlInfo ? getGlobalTrackOrder(getState()) : null, - legacyThreadOrder + legacyThreadOrder, + threadActivityScores ); const localTrackOrderByPid = initializeLocalTrackOrderByPid( hasUrlInfo ? getLocalTrackOrderByPid(getState()) : null, @@ -308,7 +310,7 @@ export function finalizeFullProfileView( hiddenTracks = computeDefaultHiddenTracks( tracksWithOrder, profile, - getThreadActivityScores(getState()), + threadActivityScores, // Only include the parent process if there is no tab filter applied. includeParentProcessThreads ); @@ -317,7 +319,8 @@ export function finalizeFullProfileView( const selectedThreadIndexes = initializeSelectedThreadIndex( maybeSelectedThreadIndexes, getVisibleThreads(tracksWithOrder, hiddenTracks), - profile + profile, + threadActivityScores ); let timelineType = null; @@ -1495,11 +1498,13 @@ export function changeTabFilter(tabID: TabID | null): ThunkAction { ); const localTracksByPid = computeLocalTracksByPid(profile, globalTracks); + const threadActivityScores = getThreadActivityScores(getState()); const legacyThreadOrder = getLegacyThreadOrder(getState()); const globalTrackOrder = initializeGlobalTrackOrder( globalTracks, null, // Passing null to urlGlobalTrackOrder to reinitilize it. - legacyThreadOrder + legacyThreadOrder, + threadActivityScores ); const localTrackOrderByPid = initializeLocalTrackOrderByPid( null, // Passing null to urlTrackOrderByPid to reinitilize it. @@ -1536,7 +1541,7 @@ export function changeTabFilter(tabID: TabID | null): ThunkAction { hiddenTracks = computeDefaultHiddenTracks( tracksWithOrder, profile, - getThreadActivityScores(getState()), + threadActivityScores, // Only include the parent process if there is no tab filter applied. includeParentProcessThreads ); @@ -1545,7 +1550,8 @@ export function changeTabFilter(tabID: TabID | null): ThunkAction { const selectedThreadIndexes = initializeSelectedThreadIndex( null, // maybeSelectedThreadIndexes getVisibleThreads(tracksWithOrder, hiddenTracks), - profile + profile, + threadActivityScores ); // If the currently selected tab is only visible when the selected track diff --git a/src/profile-logic/profile-data.js b/src/profile-logic/profile-data.js index 1aa5de9d60..bbba49027a 100644 --- a/src/profile-logic/profile-data.js +++ b/src/profile-logic/profile-data.js @@ -1286,47 +1286,6 @@ export function getTimeRangeIncludingAllThreads( return completeRange; } -export function defaultThreadOrder(threads: RawThread[]): ThreadIndex[] { - const threadOrder = threads.map((thread, i) => i); - - // Note: to have a consistent behavior independant of the sorting algorithm, - // we need to be careful that the comparator function is consistent: - // comparator(a, b) === - comparator(b, a) - // and - // comparator(a, b) === 0 if and only if a === b - threadOrder.sort((a, b) => { - const nameA = threads[a].name; - const nameB = threads[b].name; - - if (nameA === nameB) { - return a - b; - } - - // Put the compositor/renderer thread last. - // Compositor will always be before Renderer, if both are present. - if (nameA === 'Compositor') { - return 1; - } - - if (nameB === 'Compositor') { - return -1; - } - - if (nameA === 'Renderer') { - return 1; - } - - if (nameB === 'Renderer') { - return -1; - } - - // Otherwise keep the existing order. We don't return 0 to guarantee that - // the sort is stable even if the sort algorithm isn't. - return a - b; - }); - return threadOrder; -} - export function toValidImplementationFilter( implementation: string ): ImplementationFilter { diff --git a/src/profile-logic/tracks.js b/src/profile-logic/tracks.js index 597e647fc0..2641fa0849 100644 --- a/src/profile-logic/tracks.js +++ b/src/profile-logic/tracks.js @@ -20,7 +20,6 @@ import type { } from 'firefox-profiler/types'; import { - defaultThreadOrder, getFriendlyThreadName, computeStackTableFromRawStackTable, } from './profile-data'; @@ -167,14 +166,60 @@ function _getDefaultLocalTrackOrder(tracks: LocalTrack[], profile: ?Profile) { return trackOrder; } -function _getDefaultGlobalTrackOrder(tracks: GlobalTrack[]) { +function _getDefaultGlobalTrackOrder( + tracks: GlobalTrack[], + threadActivityScores: Array +) { const trackOrder = tracks.map((_, index) => index); + // In place sort! - trackOrder.sort( - (a, b) => - GLOBAL_TRACK_DISPLAY_ORDER[tracks[a].type] - - GLOBAL_TRACK_DISPLAY_ORDER[tracks[b].type] - ); + trackOrder.sort((a, b) => { + const trackA = tracks[a]; + const trackB = tracks[b]; + + // First, sort by track type priority (visual progress, screenshots, then process). + const typeOrderA = GLOBAL_TRACK_DISPLAY_ORDER[trackA.type]; + const typeOrderB = GLOBAL_TRACK_DISPLAY_ORDER[trackB.type]; + + if (typeOrderA !== typeOrderB) { + return typeOrderA - typeOrderB; + } + + if (trackA.type !== 'process' || trackB.type !== 'process') { + // For all the cases where both of them are not the process type, return zero. + return 0; + } + + // This is the case where both of the tracks are processes. Let's sort them + // by activity while keeping the parent process at the top. + // mainThreadIndex might be null in case the GeckoMain thread is not + // profiled in a profile. + const activityA = + trackA.mainThreadIndex !== null + ? threadActivityScores[trackA.mainThreadIndex] + : null; + const activityB = + trackB.mainThreadIndex !== null + ? threadActivityScores[trackB.mainThreadIndex] + : null; + + // Keep the parent process at the top. + if (activityA?.isInParentProcess && !activityB?.isInParentProcess) { + return -1; + } + if (!activityA?.isInParentProcess && activityB?.isInParentProcess) { + return 1; + } + + // For non-parent processes, sort by activity score. + if (activityA && activityB) { + return activityB.boostedSampleScore - activityA.boostedSampleScore; + } + + // For all other cases, maintain original order. + return 0; + }); + return trackOrder; } @@ -646,7 +691,8 @@ export function initializeGlobalTrackOrder( urlGlobalTrackOrder: TrackIndex[] | null, // If viewing an old profile URL, there were not tracks, only thread indexes. Turn // the legacy ordering into track ordering. - legacyThreadOrder: ThreadIndex[] | null + legacyThreadOrder: ThreadIndex[] | null, + threadActivityScores: Array ): TrackIndex[] { if (legacyThreadOrder !== null) { // Upgrade an older URL value based on the thread index to the track index based @@ -692,7 +738,7 @@ export function initializeGlobalTrackOrder( return urlGlobalTrackOrder !== null && _indexesAreValid(globalTracks.length, urlGlobalTrackOrder) ? urlGlobalTrackOrder - : _getDefaultGlobalTrackOrder(globalTracks); + : _getDefaultGlobalTrackOrder(globalTracks, threadActivityScores); } // Returns the selected thread (set), intersected with the set of visible threads. @@ -700,10 +746,15 @@ export function initializeGlobalTrackOrder( export function initializeSelectedThreadIndex( selectedThreadIndexes: Set | null, visibleThreadIndexes: ThreadIndex[], - profile: Profile + profile: Profile, + threadActivityScores: Array ): Set { if (selectedThreadIndexes === null) { - return getDefaultSelectedThreadIndexes(visibleThreadIndexes, profile); + return getDefaultSelectedThreadIndexes( + visibleThreadIndexes, + profile, + threadActivityScores + ); } // Filter out hidden threads from the set of selected threads. @@ -713,16 +764,24 @@ export function initializeSelectedThreadIndex( ); if (visibleSelectedThreadIndexes.size === 0) { // No selected threads were visible. Fall back to default selection. - return getDefaultSelectedThreadIndexes(visibleThreadIndexes, profile); + return getDefaultSelectedThreadIndexes( + visibleThreadIndexes, + profile, + threadActivityScores + ); } return visibleSelectedThreadIndexes; } -// Select either the GeckoMain [tab] thread, or the first thread in the thread -// order. +// Select either the most active GeckoMain [tab] thread, or the most active +// thread sorted by the thread activity scores. +// It always selects global tracks when there is a GeckoMain [tab], but when +// there is no GeckoMain [tab], it might select local tracks too depending +// on the activity score. function getDefaultSelectedThreadIndexes( visibleThreadIndexes: ThreadIndex[], - profile: Profile + profile: Profile, + threadActivityScores: Array ): Set { if (profile.meta.initialSelectedThreads !== undefined) { return new Set( @@ -740,17 +799,83 @@ function getDefaultSelectedThreadIndexes( }) ); } - const visibleThreads = visibleThreadIndexes.map( - (threadIndex) => profile.threads[threadIndex] - ); - const defaultThread = _findDefaultThread(visibleThreads); - const defaultThreadIndex = profile.threads.indexOf(defaultThread); - if (defaultThreadIndex === -1) { + + const { threads } = profile; + if (threads.length === 0) { throw new Error('Expected to find a thread index to select.'); } + + const threadOrder = _defaultThreadOrder( + visibleThreadIndexes, + threads, + threadActivityScores + ); + + // Try to find a tab process with the highest activity score. If it can't + // find one, select the first thread with the highest one. + const defaultThreadIndex = + threadOrder.find( + (threadIndex) => + threads[threadIndex].name === 'GeckoMain' && + threads[threadIndex].processType === 'tab' + ) ?? threadOrder[0]; + return new Set([defaultThreadIndex]); } +function _defaultThreadOrder( + visibleThreadIndexes: ThreadIndex[], + threads: RawThread[], + threadActivityScores: Array +): ThreadIndex[] { + const threadOrder = [...visibleThreadIndexes]; + + // Note: to have a consistent behavior independant of the sorting algorithm, + // we need to be careful that the comparator function is consistent: + // comparator(a, b) === - comparator(b, a) + // and + // comparator(a, b) === 0 if and only if a === b + threadOrder.sort((a, b) => { + const nameA = threads[a].name; + const nameB = threads[b].name; + + if (nameA === nameB) { + // Sort by the activity, but keep the original order if the activity + // scores are the equal. + return ( + threadActivityScores[b].boostedSampleScore - + threadActivityScores[a].boostedSampleScore || a - b + ); + } + + // Put the compositor/renderer thread last. + // Compositor will always be before Renderer, if both are present. + if (nameA === 'Compositor') { + return 1; + } + + if (nameB === 'Compositor') { + return -1; + } + + if (nameA === 'Renderer') { + return 1; + } + + if (nameB === 'Renderer') { + return -1; + } + + // Otherwise keep the existing order. We don't return 0 to guarantee that + // the sort is stable even if the sort algorithm isn't. + return ( + threadActivityScores[b].boostedSampleScore - + threadActivityScores[a].boostedSampleScore || a - b + ); + }); + return threadOrder; +} + // Returns either a configuration of hidden tracks that has at least one // visible thread, or null. export function tryInitializeHiddenTracksLegacy( @@ -1264,20 +1389,6 @@ function _computeThreadSampleScore( return nonIdleSampleCount * referenceCPUDeltaPerInterval; } -function _findDefaultThread(threads: RawThread[]): RawThread | null { - if (threads.length === 0) { - // Tests may have no threads. - return null; - } - const contentThreadId = threads.findIndex( - (thread) => thread.name === 'GeckoMain' && thread.processType === 'tab' - ); - const defaultThreadIndex = - contentThreadId !== -1 ? contentThreadId : defaultThreadOrder(threads)[0]; - - return threads[defaultThreadIndex]; -} - function _indexesAreValid(listLength: number, indexes: number[]) { return ( // The item length is valid. diff --git a/src/test/store/profile-view.test.js b/src/test/store/profile-view.test.js index 64b014760b..89745d425a 100644 --- a/src/test/store/profile-view.test.js +++ b/src/test/store/profile-view.test.js @@ -534,6 +534,10 @@ describe('actions/ProfileView', function () { describe('with a comparison profile', function () { it('selects the calltree tab when selecting the diffing track', function () { + const firstTrackReference = { + type: 'global', + trackIndex: 0, + }; const diffingTrackReference = { type: 'global', trackIndex: 2, @@ -545,6 +549,7 @@ describe('actions/ProfileView', function () { ]); const { getState, dispatch } = storeWithProfile(profile); + dispatch(ProfileView.selectTrackWithModifiers(firstTrackReference)); dispatch(App.changeSelectedTab('flame-graph')); expect(UrlStateSelectors.getSelectedThreadIndexes(getState())).toEqual( new Set([0]) diff --git a/src/test/store/receive-profile.test.js b/src/test/store/receive-profile.test.js index 36fa4cccf1..8e6ad94616 100644 --- a/src/test/store/receive-profile.test.js +++ b/src/test/store/receive-profile.test.js @@ -265,9 +265,9 @@ describe('actions/receive-profile', function () { store.dispatch(viewProfile(profile)); expect(getHumanReadableTracks(store.getState())).toEqual([ - 'hide [thread GeckoMain tab]', 'show [thread GeckoMain tab] SELECTED', 'hide [thread GeckoMain tab]', + 'hide [thread GeckoMain tab]', ]); }); @@ -396,8 +396,8 @@ describe('actions/receive-profile', function () { store.dispatch(viewProfile(profile)); expect(getHumanReadableTracks(store.getState())).toEqual([ - 'hide [thread GeckoMain tab]', 'show [thread GeckoMain tab] SELECTED', + 'hide [thread GeckoMain tab]', ]); }); @@ -411,9 +411,9 @@ describe('actions/receive-profile', function () { store.dispatch(viewProfile(profile)); expect(getHumanReadableTracks(store.getState())).toEqual([ - 'show [thread Empty default] SELECTED', 'show [thread Empty default]', - 'show [thread Diff between 1 and 2 comparison]', + 'show [thread Empty default]', + 'show [thread Diff between 1 and 2 comparison] SELECTED', ]); }); @@ -514,9 +514,9 @@ describe('actions/receive-profile', function () { store.dispatch(viewProfile(profile)); expect(getHumanReadableTracks(store.getState())).toEqual([ - 'show [thread Empty default] SELECTED', 'show [thread Empty default]', - 'show [thread Diff between 1 and 2 comparison]', + 'show [thread Empty default]', + 'show [thread Diff between 1 and 2 comparison] SELECTED', ]); }); }); @@ -571,7 +571,7 @@ describe('actions/receive-profile', function () { ' - show [thread Thread with 140 CPU]', ' - show [thread Thread with 160 CPU]', ' - show [thread Thread with 180 CPU]', - ' - show [thread Thread with 190 CPU] SELECTED', + ' - show [thread Thread with 190 CPU]', ' - show [thread Thread with 220 CPU]', ' - show [thread Thread with 230 CPU]', ' - show [thread Thread with 270 CPU]', @@ -579,7 +579,7 @@ describe('actions/receive-profile', function () { ' - show [thread Thread with 320 CPU]', ' - show [thread Thread with 330 CPU]', ' - show [thread Thread with 350 CPU]', - ' - show [thread Thread with 380 CPU]', + ' - show [thread Thread with 380 CPU] SELECTED', ]); }); @@ -634,7 +634,7 @@ describe('actions/receive-profile', function () { ' - show [thread Thread with 130 CPU]', ' - show [thread Thread with 140 CPU]', ' - show [thread Thread with 180 CPU]', - ' - show [thread Thread with 190 CPU] SELECTED', + ' - show [thread Thread with 190 CPU]', ' - show [thread Thread with 220 CPU]', ' - show [thread Thread with 230 CPU]', ' - show [thread Thread with 270 CPU]', @@ -642,7 +642,7 @@ describe('actions/receive-profile', function () { ' - show [thread Thread with 320 CPU]', ' - show [thread Thread with 330 CPU]', ' - show [thread Thread with 350 CPU]', - ' - show [thread Thread with 380 CPU]', + ' - show [thread Thread with 380 CPU] SELECTED', ]); }); }); @@ -1899,9 +1899,9 @@ describe('actions/receive-profile', function () { store.dispatch(viewProfile(resultProfile)); expect(getHumanReadableTracks(store.getState())).toEqual([ - 'show [thread Empty default] SELECTED', 'show [thread Empty default]', - 'show [thread Diff between 1 and 2 comparison]', + 'show [thread Empty default]', + 'show [thread Diff between 1 and 2 comparison] SELECTED', ]); }); diff --git a/src/test/store/tracks.test.js b/src/test/store/tracks.test.js index 10e6f499c0..8da6b55bca 100644 --- a/src/test/store/tracks.test.js +++ b/src/test/store/tracks.test.js @@ -7,6 +7,8 @@ import { getNetworkTrackProfile, addIPCMarkerPairToThreads, getProfileWithMarkers, + getProfileFromTextSamples, + getProfileWithThreadCPUDelta, } from '../fixtures/profiles/processed-profile'; import { getEmptyThread } from '../../profile-logic/data-structures'; import { storeWithProfile } from '../fixtures/stores'; @@ -396,6 +398,202 @@ describe('ordering and hiding', function () { ).toEqual(userFacingSortOrder); }); }); + + describe('ordering by activity', function () { + it('orders process tracks by activity score while keeping parent process first', function () { + const { profile } = getProfileFromTextSamples( + 'A B C D E F G H I J', // High activity parent process + 'X Y', // Low activity tab process + 'P Q R S T U V W X Y Z A B C D' // Very high activity tab process + ); + + // Set up threads as different processes + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'default'; // Parent process + profile.threads[0].pid = '1'; + + profile.threads[1].name = 'GeckoMain'; + profile.threads[1].isMainThread = true; + profile.threads[1].processType = 'tab'; + profile.threads[1].pid = '2'; + + profile.threads[2].name = 'GeckoMain'; + profile.threads[2].isMainThread = true; + profile.threads[2].processType = 'tab'; + profile.threads[2].pid = '3'; + + const { getState } = storeWithProfile(profile); + + // Parent process should be first, then tab processes ordered by activity + // The highest activity tab process (index 2) should be selected by default + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain default]', // Parent process first + 'show [thread GeckoMain tab] SELECTED', // Very high activity tab process selected + 'show [thread GeckoMain tab]', // Low activity tab process + ]); + + const globalTrackOrder = + UrlStateSelectors.getGlobalTrackOrder(getState()); + expect(globalTrackOrder).toEqual([0, 2, 1]); + }); + + it('orders multiple tab processes by their activity levels', function () { + const profile = getProfileWithThreadCPUDelta([ + [5, 5, 5], // Low activity tab process + [50, 50, 50], // High activity tab process + [25, 25, 25], // Medium activity tab process + ]); + + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'tab'; + profile.threads[0].pid = '1'; + + profile.threads[1].name = 'GeckoMain'; + profile.threads[1].isMainThread = true; + profile.threads[1].processType = 'tab'; + profile.threads[1].pid = '2'; + + profile.threads[2].name = 'GeckoMain'; + profile.threads[2].isMainThread = true; + profile.threads[2].processType = 'tab'; + profile.threads[2].pid = '3'; + + const { getState } = storeWithProfile(profile); + + // Processes should be ordered by activity: high, medium, low + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain tab] SELECTED', // High activity (index 1) + 'show [thread GeckoMain tab]', // Medium activity (index 2) + 'show [thread GeckoMain tab]', // Low activity (index 0) + ]); + + const globalTrackOrder = + UrlStateSelectors.getGlobalTrackOrder(getState()); + expect(globalTrackOrder).toEqual([1, 2, 0]); + }); + + it('handles processes without main threads gracefully', function () { + const { profile } = getProfileFromTextSamples('A', 'B'); + + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'tab'; + profile.threads[0].pid = '1'; + + profile.threads[1].name = 'DOM Worker'; + profile.threads[1].isMainThread = false; + profile.threads[1].processType = 'tab'; + profile.threads[1].pid = '2'; // Different PID, no main thread + + const { getState } = storeWithProfile(profile); + + // Should not crash and maintain a stable order + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain tab] SELECTED', + 'show [process]', + ' - show [thread DOM Worker]', + ]); + + const globalTrackOrder = + UrlStateSelectors.getGlobalTrackOrder(getState()); + expect(globalTrackOrder).toEqual([0, 1]); + }); + }); + + describe('default selected thread selection by activity', function () { + it('selects the tab process with highest activity as default', function () { + const profile = getProfileWithThreadCPUDelta([ + [10, 10, 10], // Low activity parent process + [5, 5, 5], // Low activity tab process + [50, 50, 50], // High activity tab process + ]); + + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'default'; // Parent process + profile.threads[0].pid = '0'; + + profile.threads[1].name = 'GeckoMain'; + profile.threads[1].isMainThread = true; + profile.threads[1].processType = 'tab'; + profile.threads[1].pid = '1'; + + profile.threads[2].name = 'GeckoMain'; + profile.threads[2].isMainThread = true; + profile.threads[2].processType = 'tab'; + profile.threads[2].pid = '2'; + + const { getState } = storeWithProfile(profile); + + // The high activity tab process (index 2) should be selected + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain default]', // Parent process + 'show [thread GeckoMain tab] SELECTED', // High activity tab selected + 'show [thread GeckoMain tab]', // Low activity tab + ]); + + const globalTrackOrder = + UrlStateSelectors.getGlobalTrackOrder(getState()); + expect(globalTrackOrder).toEqual([0, 2, 1]); + }); + + it('falls back to first thread when no tab processes exist', function () { + const profile = getProfileWithThreadCPUDelta([ + [10, 10, 10], // Parent process only + ]); + + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'default'; + profile.threads[0].pid = '0'; + + const { getState } = storeWithProfile(profile); + + // Parent process should be selected as fallback + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain default] SELECTED', + ]); + }); + + it('selects highest activity tab even when parent has higher activity', function () { + const profile = getProfileWithThreadCPUDelta([ + [100, 100, 100], // Very high activity parent process + [50, 50, 50], // Medium activity tab process + [75, 75, 75], // High activity tab process + ]); + + profile.threads[0].name = 'GeckoMain'; + profile.threads[0].isMainThread = true; + profile.threads[0].processType = 'default'; // Parent process + profile.threads[0].pid = '0'; + + profile.threads[1].name = 'GeckoMain'; + profile.threads[1].isMainThread = true; + profile.threads[1].processType = 'tab'; + profile.threads[1].pid = '1'; + + profile.threads[2].name = 'GeckoMain'; + profile.threads[2].isMainThread = true; + profile.threads[2].processType = 'tab'; + profile.threads[2].pid = '2'; + + const { getState } = storeWithProfile(profile); + + // The highest activity tab process (index 2) should be selected, + // not the parent process despite it having higher activity + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain default]', // Parent process not selected + 'show [thread GeckoMain tab] SELECTED', // High activity tab selected + 'show [thread GeckoMain tab]', // Medium activity tab + ]); + + const globalTrackOrder = + UrlStateSelectors.getGlobalTrackOrder(getState()); + expect(globalTrackOrder).toEqual([0, 2, 1]); + }); + }); }); describe('local tracks', function () { diff --git a/src/test/store/useful-tabs.test.js b/src/test/store/useful-tabs.test.js index f0614bf790..c7512bf53a 100644 --- a/src/test/store/useful-tabs.test.js +++ b/src/test/store/useful-tabs.test.js @@ -53,6 +53,12 @@ describe('getUsefulTabs', function () { it('shows only the call tree when a diffing track is selected', function () { const { profile } = getMergedProfileFromTextSamples(['A B C', 'A B B']); const { getState, dispatch } = storeWithProfile(profile); + dispatch({ + type: 'SELECT_TRACK', + selectedThreadIndexes: new Set([0]), + selectedTab: 'calltree', + lastNonShiftClickInformation: null, + }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', 'flame-graph',