chrome-devtools-frontend
Version:
Chrome DevTools UI
1,016 lines (898 loc) • 58.8 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Trace from '../../models/trace/trace.js';
import * as TraceBounds from '../../services/trace_bounds/trace_bounds.js';
import {assertScreenshot, dispatchClickEvent, doubleRaf, raf, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {
microsecondsTraceWindow,
renderWidgetInVbox,
setupIgnoreListManagerEnvironment
} from '../../testing/TraceHelpers.js';
import {TraceLoader} from '../../testing/TraceLoader.js';
import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Timeline from './timeline.js';
import * as Utils from './utils/utils.js';
const {urlString} = Platform.DevToolsPath;
class MockViewDelegate implements Timeline.TimelinePanel.TimelineModeViewDelegate {
selection: Timeline.TimelineSelection.TimelineSelection|null = null;
select(selection: Timeline.TimelineSelection.TimelineSelection|null): void {
this.selection = selection;
}
set3PCheckboxDisabled(_disabled: boolean): void {
}
selectEntryAtTime(_events: Trace.Types.Events.Event[]|null, _time: number): void {
}
highlightEvent(_event: Trace.Types.Events.Event|null): void {
}
element = document.createElement('div');
}
function clearPersistTrackConfigSettings() {
const mainGroupSetting = Common.Settings.Settings.instance().createSetting('timeline-main-flame-group-config', {});
const networkGroupSetting =
Common.Settings.Settings.instance().createSetting('timeline-network-flame-group-config', {});
// In case they already existed and need clearing out.
mainGroupSetting.set({});
networkGroupSetting.set({});
}
describeWithEnvironment('TimelineFlameChartView', function() {
before(() => {
// In case any previous test suite set this.
clearPersistTrackConfigSettings();
});
beforeEach(() => {
setupIgnoreListManagerEnvironment();
const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
});
afterEach(() => {
// Avoid any group expansion state leaking across tests.
clearPersistTrackConfigSettings();
});
describe('rendering', () => {
beforeEach(() => {
// We persist collapsed/expanded states across sessions, but we want to
// make sure each test here does not impact others.
Common.Settings.Settings.instance().createSetting('timeline-flamechart-network-view-group-expansion', {}).set({});
});
it('renders the network and other tracks in collapsed and expanded modes', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.updateCountersGraphToggle(false); // don't care about the memory view in this test
renderWidgetInVbox(flameChartView);
// IMPORTANT: order is important; for the flame chart view to render properly
// it must be in the DOM before we set the model, so it can calculate and
// set heights.
flameChartView.setModel(parsedTrace, metadata);
// Most of the network content is in the first ~700ms of this trace
const {min} = parsedTrace.Meta.traceBounds;
const interestingRange = Trace.Helpers.Timing.milliToMicro(Trace.Types.Timing.Milli(700));
const max = Trace.Types.Timing.Micro(min + interestingRange);
const newBounds = microsecondsTraceWindow(min, max);
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(newBounds);
await raf();
await assertScreenshot('timeline/flamechart_view_network_collapsed.png');
flameChartView.getNetworkFlameChart().toggleGroupExpand(0);
await raf();
await assertScreenshot('timeline/flamechart_view_network_expanded.png');
});
it('does not show the network track when there is no network request', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'slow-interaction-keydown.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.updateCountersGraphToggle(false);
renderWidgetInVbox(flameChartView);
flameChartView.setModel(parsedTrace, metadata);
await assertScreenshot('timeline/flamechart_view_no_network_events.png');
});
it('shows the details for a selected network event', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
const searchableView = new UI.SearchableView.SearchableView(flameChartView, null);
searchableView.setMinimumSize(0, 100);
searchableView.hideWidget();
flameChartView.setSearchableView(searchableView);
flameChartView.updateCountersGraphToggle(false); // don't care about the memory view in this test
renderWidgetInVbox(searchableView);
// IMPORTANT: order is important; for the flame chart view to render properly
// it must be in the DOM before we set the model, so it can calculate and
// set heights.
flameChartView.show(searchableView.element);
flameChartView.setModel(parsedTrace, metadata);
flameChartView.getNetworkFlameChart().toggleGroupExpand(0);
// Most of the network content is in the first ~700ms of this trace
const {min} = parsedTrace.Meta.traceBounds;
const interestingRange = Trace.Helpers.Timing.milliToMicro(Trace.Types.Timing.Milli(700));
const max = Trace.Types.Timing.Micro(min + interestingRange);
const newBounds = microsecondsTraceWindow(min, max);
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(newBounds);
await raf();
const networkRequest = parsedTrace.NetworkRequests.byTime.find(req => {
return req.args.data.url === 'https://web.dev/js/app.js?v=fedf5fbe';
});
assert.isOk(networkRequest);
const selection = Timeline.TimelineSelection.selectionFromEvent(networkRequest);
flameChartView.setSelectionAndReveal(selection);
await raf();
await assertScreenshot('timeline/timeline_with_network_selection.png');
});
it('shows the details for a selected main thread event', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
const searchableView = new UI.SearchableView.SearchableView(flameChartView, null);
searchableView.setMinimumSize(0, 100);
searchableView.hideWidget();
flameChartView.setSearchableView(searchableView);
flameChartView.updateCountersGraphToggle(false); // don't care about the memory view in this test
renderWidgetInVbox(searchableView);
// IMPORTANT: order is important; for the flame chart view to render properly
// it must be in the DOM before we set the model, so it can calculate and
// set heights.
flameChartView.show(searchableView.element);
flameChartView.setModel(parsedTrace, metadata);
flameChartView.getNetworkFlameChart().toggleGroupExpand(0);
// Most of the network content is in the first ~700ms of this trace
const {min} = parsedTrace.Meta.traceBounds;
const interestingRange = Trace.Helpers.Timing.milliToMicro(Trace.Types.Timing.Milli(700));
const max = Trace.Types.Timing.Micro(min + interestingRange);
const newBounds = microsecondsTraceWindow(min, max);
TraceBounds.TraceBounds.BoundsManager.instance().setTimelineVisibleWindow(newBounds);
await raf();
// No particular reason to pick this event; it's just an event in the
// main thread within the time bounds.
const event = parsedTrace.Renderer.allTraceEntries.find(event => {
return Trace.Types.Events.isTimerFire(event) && event.ts === 122411157276;
});
assert.isOk(event);
const selection = Timeline.TimelineSelection.selectionFromEvent(event);
flameChartView.setSelectionAndReveal(selection);
await raf();
await assertScreenshot('timeline/timeline_with_main_thread_selection.png');
});
});
it('can gather the visual track config to store as metadata', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
renderElementIntoDOM(flameChartView);
flameChartView.setModel(parsedTrace, metadata);
const mainChart = flameChartView.getMainFlameChart();
mainChart.hideGroup(0);
// Can't do group one as that is the screenshots, which are nested under
// "Frames", so we pick Group 2 (which is animations).
mainChart.moveGroupDown(2);
const networkChart = flameChartView.getNetworkFlameChart();
networkChart.toggleGroupExpand(0);
const visualMetadata = flameChartView.getPersistedConfigMetadata(parsedTrace);
assert.deepEqual(visualMetadata.network, [{expanded: true, hidden: false, originalIndex: 0, visualIndex: 0}]);
assert.deepEqual(visualMetadata.main, [
{expanded: false, hidden: true, originalIndex: 0, visualIndex: 0},
{expanded: false, hidden: false, originalIndex: 1, visualIndex: 1},
{expanded: false, hidden: false, originalIndex: 2, visualIndex: 3},
{expanded: true, hidden: false, originalIndex: 3, visualIndex: 2},
{expanded: false, hidden: false, originalIndex: 4, visualIndex: 4},
{expanded: false, hidden: false, originalIndex: 5, visualIndex: 5},
{expanded: false, hidden: false, originalIndex: 6, visualIndex: 6},
{expanded: false, hidden: false, originalIndex: 7, visualIndex: 7},
{expanded: false, hidden: false, originalIndex: 8, visualIndex: 8},
{expanded: false, hidden: false, originalIndex: 9, visualIndex: 9},
{expanded: false, hidden: false, originalIndex: 10, visualIndex: 10},
{expanded: false, hidden: false, originalIndex: 11, visualIndex: 11},
{expanded: false, hidden: false, originalIndex: 12, visualIndex: 12}
]);
});
it('will apply metadata on disk to the setting when a trace is imported', async function() {
const {parsedTrace, metadata: originalMetdata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const FAKE_VISUAL_CONFIG_MAIN: Trace.Types.File.TrackVisualConfig[] = [
// Move the order of Group 1 and Group 2 around
{expanded: false, hidden: true, originalIndex: 0, visualIndex: 0},
{expanded: false, hidden: false, originalIndex: 1, visualIndex: 1},
{expanded: false, hidden: false, originalIndex: 2, visualIndex: 3},
{expanded: true, hidden: false, originalIndex: 3, visualIndex: 2},
{expanded: false, hidden: false, originalIndex: 4, visualIndex: 4},
{expanded: false, hidden: false, originalIndex: 5, visualIndex: 5},
{expanded: false, hidden: false, originalIndex: 6, visualIndex: 6},
{expanded: false, hidden: false, originalIndex: 7, visualIndex: 7},
{expanded: false, hidden: false, originalIndex: 8, visualIndex: 8},
{expanded: false, hidden: false, originalIndex: 9, visualIndex: 9},
{expanded: false, hidden: false, originalIndex: 10, visualIndex: 10},
{expanded: false, hidden: false, originalIndex: 11, visualIndex: 11},
{expanded: false, hidden: false, originalIndex: 12, visualIndex: 12}
];
const FAKE_VISUAL_CONFIG_NETWORK: Trace.Types.File.TrackVisualConfig[] =
[{expanded: true, hidden: false, originalIndex: 0, visualIndex: 0}];
const metadata: Trace.Types.File.MetaData = {
...originalMetdata,
visualTrackConfig: {
main: FAKE_VISUAL_CONFIG_MAIN,
network: FAKE_VISUAL_CONFIG_NETWORK,
}
};
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setModel(parsedTrace, metadata);
const metadataInSetting = flameChartView.getPersistedConfigMetadata(parsedTrace);
assert.deepEqual(metadataInSetting, {main: FAKE_VISUAL_CONFIG_MAIN, network: FAKE_VISUAL_CONFIG_NETWORK});
});
it('does not use visual config from file if the user has locally made config changes', async function() {
const {parsedTrace, metadata: originalMetadata} =
await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
const FROM_FILE_VISUAL_CONFIG_NETWORK: Trace.Types.File.TrackVisualConfig[] =
[{expanded: true, hidden: false, originalIndex: 0, visualIndex: 0}];
const traceKey = Timeline.TrackConfiguration.keyForTraceConfig(parsedTrace);
// Populate the in-memory setting to pretend the user has already modified
// this trace's visual config.
// Importantly for this test, this is a different setting to FAKE_VISUAL_CONFIG_NETWORK above.
const networkGroupSetting =
Common.Settings.Settings.instance()
.createSetting<PerfUI.FlameChart.PersistedConfigPerTrace>('timeline-network-flame-group-config', {})
.get();
const USER_VISUAL_CONFIG_NETWORK = {hidden: true, expanded: true, originalIndex: 0, visualIndex: 0};
networkGroupSetting[traceKey] = [USER_VISUAL_CONFIG_NETWORK];
// Now add network configuration to the metadata that we get from the trace file itself.
const metadata: Trace.Types.File.MetaData = {
...originalMetadata,
visualTrackConfig: {
main: null,
network: FROM_FILE_VISUAL_CONFIG_NETWORK,
}
};
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setModel(parsedTrace, metadata);
const metadataInSetting = flameChartView.getPersistedConfigMetadata(parsedTrace);
assert.deepEqual(metadataInSetting, {main: null, network: [USER_VISUAL_CONFIG_NETWORK]});
});
it('fires an event when an entry label overlay is clicked', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-modifications.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
const searchableView = new UI.SearchableView.SearchableView(flameChartView, null);
searchableView.setMinimumSize(0, 100);
searchableView.hideWidget();
flameChartView.setSearchableView(searchableView);
flameChartView.updateCountersGraphToggle(false); // don't care about the memory view in this test
renderWidgetInVbox(searchableView);
// IMPORTANT: order is important; for the flame chart view to render properly
// it must be in the DOM before we set the model, so it can calculate and
// set heights.
flameChartView.show(searchableView.element);
flameChartView.setModel(parsedTrace, metadata);
const modifications = Timeline.ModificationsManager.ModificationsManager.activeManager();
assert.isOk(modifications);
const labelAnnotation = modifications.getAnnotations().find(a => a.type === 'ENTRY_LABEL');
assert.isOk(labelAnnotation);
// This creates an active annotation in the UI and creates the overlay.
const overlay = modifications.createAnnotation(labelAnnotation);
flameChartView.addOverlay(overlay);
await raf();
const overlayElement = flameChartView.overlays().elementForOverlay(overlay);
assert.isOk(overlayElement);
const labelAnnotationClickedStub = sinon.stub();
flameChartView.addEventListener(Timeline.TimelineFlameChartView.Events.ENTRY_LABEL_ANNOTATION_CLICKED, event => {
labelAnnotationClickedStub(event.data.entry);
});
dispatchClickEvent(overlayElement);
sinon.assert.calledOnceWithExactly(labelAnnotationClickedStub, labelAnnotation.entry);
});
describe('groupForLevel', () => {
const {groupForLevel} = Timeline.TimelineFlameChartView;
it('finds the right group for the given level', async () => {
const groups: PerfUI.FlameChart.Group[] = [
{
name: 'group-1' as Common.UIString.LocalizedString,
startLevel: 0,
style: {} as PerfUI.FlameChart.GroupStyle,
},
{
name: 'group-2' as Common.UIString.LocalizedString,
startLevel: 10,
style: {} as PerfUI.FlameChart.GroupStyle,
},
{
name: 'group-3' as Common.UIString.LocalizedString,
startLevel: 12,
style: {} as PerfUI.FlameChart.GroupStyle,
},
];
assert.strictEqual(groupForLevel(groups, 1), groups[0]);
assert.strictEqual(groupForLevel(groups, 10), groups[1]);
assert.strictEqual(groupForLevel(groups, 11), groups[1]);
assert.strictEqual(groupForLevel(groups, 12), groups[2]);
assert.strictEqual(groupForLevel(groups, 999), groups[2]);
});
});
it('Can search for events by name in the timeline', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
// The timeline flamechart view will invoke the `select` method
// of this delegate every time an event has matched on a search.
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
const searchableView = new UI.SearchableView.SearchableView(flameChartView, null);
flameChartView.setSearchableView(searchableView);
flameChartView.setModel(parsedTrace, metadata);
const searchQuery = 'Paint';
const searchConfig =
new UI.SearchableView.SearchConfig(/* query */ searchQuery, /* caseSensitive */ false, /* isRegex */ false);
flameChartView.performSearch(searchConfig, true);
assert.strictEqual(flameChartView.getSearchResults()?.length, 14);
assertSelectionName('PrePaint');
flameChartView.jumpToNextSearchResult();
assertSelectionName('Paint');
flameChartView.jumpToNextSearchResult();
assertSelectionName('Paint');
flameChartView.jumpToPreviousSearchResult();
assertSelectionName('Paint');
flameChartView.jumpToPreviousSearchResult();
assertSelectionName('PrePaint');
function assertSelectionName(name: string) {
const selection = mockViewDelegate.selection;
if (!selection || !Timeline.TimelineSelection.selectionIsEvent(selection)) {
throw new Error('Selection is not present or not a Trace Event');
}
assert.strictEqual(selection.event.name, name);
}
flameChartView.detach();
});
it('can search across both flame charts for events', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
// The timeline flamechart view will invoke the `select` method
// of this delegate every time an event has matched on a search.
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
const searchableView = new UI.SearchableView.SearchableView(flameChartView, null);
flameChartView.setSearchableView(searchableView);
flameChartView.setModel(parsedTrace, metadata);
const searchQuery = 'app.js';
const searchConfig =
new UI.SearchableView.SearchConfig(/* query */ searchQuery, /* caseSensitive */ false, /* isRegex */ false);
flameChartView.performSearch(searchConfig, true);
const results = flameChartView.getSearchResults();
assert.isOk(results);
assert.lengthOf(results, 9);
assert.lengthOf(results.filter(r => r.provider === 'main'), 8);
assert.lengthOf(results.filter(r => r.provider === 'network'), 1);
flameChartView.detach();
});
it('Adds Hidden Descendants Arrow as a decoration when a Context Menu action is applied on a node', async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'load-simple.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setModel(parsedTrace, metadata);
// Find the main track to later collapse entries of
const mainTrack = flameChartView.getMainFlameChart().timelineData()?.groups.find(group => {
return group.name === 'Main — http://localhost:8080/';
});
if (!mainTrack) {
throw new Error('Could not find main track');
}
// Find the first node that has children to collapse and is visible in the timeline
const nodeOfGroup = flameChartView.getMainDataProvider().groupTreeEvents(mainTrack);
const firstNodeWithChildren = nodeOfGroup?.find(node => {
const childrenAmount = parsedTrace.Renderer.entryToNode.get(node)?.children.length;
if (!childrenAmount) {
return false;
}
return childrenAmount > 0 && node.cat === 'devtools.timeline';
});
const node = parsedTrace.Renderer.entryToNode.get(firstNodeWithChildren as Trace.Types.Events.Event);
if (!node) {
throw new Error('Could not find a visible node with children');
}
// Apply COLLAPSE_FUNCTION action to the node. This action will hide all the children of the passed node and add HIDDEN_DESCENDANTS_ARROW decoration to it.
flameChartView.getMainFlameChart().modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION, node?.id);
const decorationsForEntry = flameChartView.getMainFlameChart().timelineData()?.entryDecorations[node?.id];
assert.deepEqual(decorationsForEntry, [
{
type: PerfUI.FlameChart.FlameChartDecorationType.HIDDEN_DESCENDANTS_ARROW,
},
]);
flameChartView.detach();
});
it('Adds Hidden Descendants Arrow as a decoration when a Context Menu action is applied on a selected node with a key shortcut event',
async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'load-simple.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setModel(parsedTrace, metadata);
// Find the main track to later collapse entries of
const mainTrack = flameChartView.getMainFlameChart().timelineData()?.groups.find(group => {
return group.name === 'Main — http://localhost:8080/';
});
if (!mainTrack) {
throw new Error('Could not find main track');
}
// Find the first node that has children to collapse and is visible in the timeline
const groupTreeEvents = flameChartView.getMainDataProvider().groupTreeEvents(mainTrack);
const firstEventWithChildren = groupTreeEvents?.find(node => {
const childrenAmount = parsedTrace.Renderer.entryToNode.get(node)?.children.length;
if (!childrenAmount) {
return false;
}
return childrenAmount > 0 && node.cat === 'devtools.timeline';
});
assert.exists(firstEventWithChildren);
const nodeId = flameChartView.getMainDataProvider().indexForEvent(firstEventWithChildren);
assert.exists(nodeId);
flameChartView.getMainFlameChart().setSelectedEntry(nodeId);
// Dispatch a shortcut keydown event that applies 'Hide Children' Context menu action
const event = new KeyboardEvent('keydown', {code: 'KeyC'});
flameChartView.getMainFlameChart().getCanvas().dispatchEvent(event);
const decorationsForEntry = flameChartView.getMainFlameChart().timelineData()?.entryDecorations[nodeId];
assert.deepEqual(decorationsForEntry, [
{
type: PerfUI.FlameChart.FlameChartDecorationType.HIDDEN_DESCENDANTS_ARROW,
},
]);
flameChartView.detach();
});
it('Removes Hidden Descendants Arrow as a decoration when Reset Children action is applied on a node',
async function() {
const {parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'load-simple.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setModel(parsedTrace, metadata);
Timeline.ModificationsManager.ModificationsManager.activeManager();
// Find the main track to later collapse entries of
let mainTrack = flameChartView.getMainFlameChart().timelineData()?.groups.find(group => {
return group.name === 'Main — http://localhost:8080/';
});
if (!mainTrack) {
throw new Error('Could not find main track');
}
// Find the first node that has children to collapse and is visible in the timeline
const nodeOfGroup = flameChartView.getMainDataProvider().groupTreeEvents(mainTrack);
const firstNodeWithChildren = nodeOfGroup?.find(node => {
const childrenAmount = parsedTrace.Renderer.entryToNode.get(node)?.children.length;
if (!childrenAmount) {
return false;
}
return childrenAmount > 0 && node.cat === 'devtools.timeline';
});
const node = parsedTrace.Renderer.entryToNode.get(firstNodeWithChildren as Trace.Types.Events.Event);
if (!node) {
throw new Error('Could not find a visible node with children');
}
// Apply COLLAPSE_FUNCTION Context Menu action to the node.
// This action will hide all the children of the passed node and add HIDDEN_DESCENDANTS_ARROW decoration to it.
flameChartView.getMainFlameChart().modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION, node?.id);
let decorationsForEntry = flameChartView.getMainFlameChart().timelineData()?.entryDecorations[node?.id];
assert.deepEqual(decorationsForEntry, [
{
type: PerfUI.FlameChart.FlameChartDecorationType.HIDDEN_DESCENDANTS_ARROW,
},
]);
mainTrack = flameChartView.getMainFlameChart().timelineData()?.groups.find(group => {
return group.name === 'Main — http://localhost:8080/';
});
if (!mainTrack) {
throw new Error('Could not find main track');
}
// Apply a RESET_CHILDREN action that will reveal all of the hidden children of the passed node and remove HIDDEN_DESCENDANTS_ARROW decoration from it.
flameChartView.getMainFlameChart().modifyTree(PerfUI.FlameChart.FilterAction.RESET_CHILDREN, node?.id);
// No decorations should exist on the node
decorationsForEntry = flameChartView.getMainFlameChart().timelineData()?.entryDecorations[node?.id];
assert.isUndefined(decorationsForEntry);
flameChartView.detach();
});
it('renders metrics as marker overlays w/ tooltips', async function() {
const {parsedTrace, metadata, insights} = await TraceLoader.traceEngine(this, 'crux.json.gz');
const mockViewDelegate = new MockViewDelegate();
const flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
flameChartView.setInsights(insights, new Map());
flameChartView.setModel(parsedTrace, metadata);
const tooltips =
[...flameChartView.element.querySelectorAll('.overlay-type-TIMINGS_MARKER .marker-title')].map(el => {
el?.dispatchEvent(new MouseEvent('mousemove', {
clientX: 0,
clientY: 0,
}));
return flameChartView.element.querySelector('.timeline-entry-tooltip-element')
?.textContent?.replaceAll('\xa0', ' ');
});
assert.deepEqual(tooltips, [
'0 μsNav',
'43.98 msL',
'75.90 msFCP - Local939.00 msFCP - Field (URL)',
'75.90 msLCP - Local936.00 msLCP - Field (URL)',
'43.75 msDCL',
]);
flameChartView.detach();
});
describe('Context Menu', function() {
let flameChartView: Timeline.TimelineFlameChartView.TimelineFlameChartView;
let parsedTrace: Trace.Handlers.Types.ParsedTrace;
let metadata: Trace.Types.File.MetaData|null;
const flameChartContainer = document.createElement('div');
this.beforeEach(async () => {
({parsedTrace, metadata} = await TraceLoader.traceEngine(this, 'recursive-blocking-js.json.gz'));
const mockViewDelegate = new MockViewDelegate();
flameChartView = new Timeline.TimelineFlameChartView.TimelineFlameChartView(mockViewDelegate);
// If we run these tests with animations disabled, they hide some flakes
// where the animations are not correctly cancelled when the component is
// destroyed.
flameChartView.forceAnimationsForTest();
flameChartView.setModel(parsedTrace, metadata);
flameChartView.markAsRoot();
// IMPORTANT: we show the widget within the div, but the div is never
// added to the DOM.
// Adding the div to the DOM means that the tests below become flakey as
// they rely on X/Y coordinates and assume a top left point of (0, 0); if
// we mount the component to the DOM and another test mounts a component
// and doesn't tidy it up, the flamechart can end up not at (0, 0) and
// that breaks the context menu tests.
flameChartView.show(flameChartContainer);
Timeline.ModificationsManager.ModificationsManager.activeManager();
sinon.stub(UI.ContextMenu.ContextMenu.prototype, 'show').resolves();
});
this.afterEach(() => {
flameChartView.detach();
});
it('Does not create customized Context Menu for network track', async function() {
// The mouse event passed to the Context Menu is used to indicate where the menu should appear. Since we don't
// need it to actually appear for this test, pass an event with coordinates that is not in the track header.
flameChartView.getNetworkFlameChart().onContextMenu(new MouseEvent('contextmenu', {clientX: 100, clientY: 100}));
assert.isUndefined(flameChartView.getNetworkFlameChart().getContextMenu());
});
it('Does not create Context Menu for Network track header', async function() {
// So for the first track header, its x will start from beginning.
// And its y will start after the ruler (ruler's height is 17).
flameChartView.getNetworkFlameChart().onContextMenu(new MouseEvent('contextmenu', {clientX: 0, clientY: 17}));
assert.isUndefined(flameChartView.getNetworkFlameChart().getContextMenu());
});
it('Create correct Context Menu for track headers in main flame chart', async function() {
// So for the first track header, its x will start from beginning.
// And its y will start after the ruler (ruler's height is 17).
flameChartView.getMainFlameChart().onContextMenu(new MouseEvent('contextmenu', {clientX: 0, clientY: 17}));
assert.strictEqual(flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.length, 1);
assert.strictEqual(
flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.at(0)?.buildDescriptor().label,
'Configure tracks');
});
describe('Context Menu Actions For Thread tracks', function() {
this.beforeEach(async () => {
// Find the Main track to later collapse entries of
const mainTrack = flameChartView.getMainFlameChart().timelineData()?.groups.find(group => {
return group.name === 'Main — http://127.0.0.1:8080/';
});
if (!mainTrack) {
throw new Error('Could not find main track');
}
});
function getMainThread(data: Trace.Handlers.ModelHandlers.Renderer.RendererHandlerData):
Trace.Handlers.ModelHandlers.Renderer.RendererThread {
let mainThread: Trace.Handlers.ModelHandlers.Renderer.RendererThread|null = null;
for (const [, process] of data.processes) {
for (const [, thread] of process.threads) {
if (thread.name === 'CrRendererMain') {
mainThread = thread;
break;
}
}
}
if (!mainThread) {
throw new Error('Could not find main thread.');
}
return mainThread;
}
function findFirstEntry(
allEntries: readonly Trace.Types.Events.Event[],
predicate: (entry: Trace.Types.Events.Event) => boolean): Trace.Types.Events.Event {
const entry = allEntries.find(entry => predicate(entry));
if (!entry) {
throw new Error('Could not find expected entry.');
}
return entry;
}
function generateContextMenuForNodeId(nodeId: number): void {
// Highlight the node to make the Context Menu dispatch on this node
flameChartView.getMainFlameChart().highlightEntry(nodeId);
const eventCoordinates = flameChartView.getMainFlameChart().entryIndexToCoordinates(nodeId);
if (!eventCoordinates) {
throw new Error('Coordinates were not found');
}
// The mouse event passed to the Context Menu is used to indicate where the menu should appear. So just simply
// use the pixels of top left corner of the event.
flameChartView.getMainFlameChart().onContextMenu(
new MouseEvent('contextmenu', {clientX: eventCoordinates.x, clientY: eventCoordinates.y}));
}
function generateContextMenuForNode(node: Trace.Types.Events.Event): void {
const nodeId = flameChartView.getMainDataProvider().indexForEvent(node);
assert.isNotNull(nodeId);
generateContextMenuForNodeId(nodeId);
}
it('When an entry has no children, correctly make only Hide Entry enabled in the Context Menu action',
async function() {
/** Part of this stack looks roughly like so (with some events omitted):
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* == now == == updateCounters == <-- ID=245
*
* In this test we want to test that the Context Menu option available
* for an entry with no children and a parent is to hide given entry only.
* Since there are no children to hide, we don't want to show 'hide children' option.
*
* To achieve that, we will dispatch the context menu on the 'updateCounters' function that does not have
* children.
* The ID of 'updateCounters' is 245.
**/
const nodeIdWithNoChildren = 245;
generateContextMenuForNodeId(nodeIdWithNoChildren);
assert.strictEqual(flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.length, 5);
// Hide function enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(0)
?.buildDescriptor()
.enabled);
// Rest of the actions disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(1)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(2)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(3)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(4)
?.buildDescriptor()
.enabled);
});
it('When an entry has children, correctly make only Hide Entry and Hide Children enabled in the Context Menu action',
async function() {
/** Part of this stack looks roughly like so (with some events omitted):
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* ===== wait ===== ===== wait =====
* = now = = now = = now = = now =
*
* In this test we want to test that the Context Menu option available
* for an entry with children and a parent is to hide given entry, and hide children only.
* Since there are no repeating children to hide, we don't want to show 'hide repeating children' option.
*
* To achieve that, we will dispatch the context menu on the 'wait' function that has only non-repeating
* children.
**/
const mainThread = getMainThread(parsedTrace.Renderer);
const entry = findFirstEntry(mainThread.entries, entry => {
return Trace.Types.Events.isProfileCall(entry) && entry.callFrame.functionName === 'wait';
});
const nodeIdWithNoChildren = flameChartView.getMainDataProvider().indexForEvent(entry);
assert.exists(nodeIdWithNoChildren);
generateContextMenuForNodeId(nodeIdWithNoChildren);
// This entry has URL, so there are 5 always-shown actions, and one to add script to ignore list.
assert.strictEqual(flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.length, 6);
// Hide function enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(0)
?.buildDescriptor()
.enabled);
// Hide children enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(1)
?.buildDescriptor()
.enabled);
// Rest of the actions disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(2)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(3)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(4)
?.buildDescriptor()
.enabled);
});
it('When an entry has repeating children, correctly make only Hide Entry, Hide Children and Hide repeating children enabled in the Context Menu action',
async function() {
/** Part of this stack looks roughly like so (with some events omitted):
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* ===== wait ===== ===== wait =====
* = now = = now = = now = = now =
*
* In this test we want to test that the Context Menu option available
* for an entry with children repeating children and a parent is to hide given entry, hide children and hide
* repeating children.
*
* To achieve that, we will dispatch the context menu on the 'foo' function that has child 'foo' calls.
**/
const mainThread = getMainThread(parsedTrace.Renderer);
const entry = findFirstEntry(mainThread.entries, entry => {
return Trace.Types.Events.isProfileCall(entry) && entry.callFrame.functionName === 'foo';
});
const nodeId = flameChartView.getMainDataProvider().indexForEvent(entry);
assert.exists(nodeId);
generateContextMenuForNodeId(nodeId);
// This entry has URL, so there are 5 always-shown actions, and one to add script to ignore list.
assert.strictEqual(flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.length, 6);
// Hide function enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(0)
?.buildDescriptor()
.enabled);
// Hide children enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(1)
?.buildDescriptor()
.enabled);
// Hide repeating children enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(2)
?.buildDescriptor()
.enabled);
// Rest of the actions disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(3)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(4)
?.buildDescriptor()
.enabled);
});
it('When an entry has no parent and has children, correctly make only Hide Children enabled in the Context Menu action',
async function() {
/** Part of this stack looks roughly like so (with some events omitted):
* =============== Task ==============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* ===== wait ===== ===== wait =====
* = now = = now = = now = = now =
*
* In this test we want to test that the Context Menu option available for an entry with no parent is only to
* hide children.
* If an entry has no parent, we don't want to show an option to hide the entry since when an entry is hidden,
* it is indicated by adding a decoration to the parent and if there is no parent, there is no way to show it
* is hidden.
*
* To achieve that, we will dispatch the context menu on the 'Task' function that is on the top of the stack
* and has no parent.
**/
const mainThread = getMainThread(parsedTrace.Renderer);
const entry = findFirstEntry(mainThread.entries, entry => {
const childrenAmount = parsedTrace.Renderer.entryToNode.get(entry)?.children.length;
if (!childrenAmount) {
return false;
}
return Trace.Types.Events.isRunTask(entry);
});
const nodeId = flameChartView.getMainDataProvider().indexForEvent(entry);
assert.exists(nodeId);
generateContextMenuForNodeId(nodeId);
// Hide function disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(0)
?.buildDescriptor()
.enabled);
// Hide children enabled
assert.isTrue(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(1)
?.buildDescriptor()
.enabled);
// Rest of the actions disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(2)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(3)
?.buildDescriptor()
.enabled);
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(4)
?.buildDescriptor()
.enabled);
});
it('Reset Trace Context Menu action is disabled before some action has been applied', async function() {
/** Part of this stack looks roughly like so (with some events omitted):
* =============== Task ==============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* =============== foo ===============
* ===== wait ===== ===== wait =====
* = now = = now = = now = = now =
*
* In this test we want to test that the Reset Trace Context Menu option is disabled by default and enabled after
* some action has been applied.
*
* To achieve that, we will first check if Reset Trace is disabled and then dispatch a Context Menu action on
* "Task" entry and then check if Reset Trace is enabled.
**/
const mainThread = getMainThread(parsedTrace.Renderer);
const entry = findFirstEntry(mainThread.entries, entry => Trace.Types.Events.isRunTask(entry));
const nodeId = flameChartView.getMainDataProvider().indexForEvent(entry);
assert.exists(nodeId);
generateContextMenuForNodeId(nodeId);
assert.strictEqual(flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.length, 5);
assert.strictEqual(
flameChartView.getMainFlameChart().getContextMenu()?.defaultSection().items.at(4)?.buildDescriptor().label,
'Reset trace');
// Check that Reset Trace is disabled
assert.isFalse(flameChartView.getMainFlameChart()
.getContextMenu()
?.defaultSection()
.items.at(4)
?.buildDescriptor()
.enabled);
flameChartView.getMainFlameChart().modifyTree(PerfUI.FlameChart.FilterAction.MERGE_FUNCTION, nodeId);
generateContextMenuForNodeId(nodeId);
// Check that Reset Trace is enabled
assert.isTrue(flameChartView.getMainFlameCha