chrome-devtools-frontend
Version:
Chrome DevTools UI
1,015 lines (820 loc) • 36.1 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 SDK from '../../../core/sdk/sdk.js';
import * as CrUXManager from '../../../models/crux-manager/crux-manager.js';
import * as EmulationModel from '../../../models/emulation/emulation.js';
import * as LiveMetrics from '../../../models/live-metrics/live-metrics.js';
import type * as Trace from '../../../models/trace/trace.js';
import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
import {createTarget} from '../../../testing/EnvironmentHelpers.js';
import {describeWithMockConnection} from '../../../testing/MockConnection.js';
import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Components from './components.js';
type Milli = Trace.Types.Timing.Milli;
function renderLiveMetrics(): Components.LiveMetricsView.LiveMetricsView {
const root = document.createElement('div');
renderElementIntoDOM(root);
const widget = new UI.Widget.Widget();
widget.markAsRoot();
widget.show(root);
const view = new Components.LiveMetricsView.LiveMetricsView();
widget.contentElement.append(view);
return view;
}
function getFieldMetricValue(view: Element, metric: string): HTMLElement|null {
const card = view.shadowRoot!.querySelector(`#${metric} devtools-metric-card`);
return card!.shadowRoot!.querySelector('#field-value .metric-value');
}
function getEnvironmentRecs(view: Element): HTMLElement[] {
return Array.from(view.shadowRoot!.querySelectorAll<HTMLElement>('.environment-rec'));
}
function getInteractions(view: Element): HTMLElement[] {
const interactionsListEl = view.shadowRoot!.querySelector('.log[slot="interactions-log-content"]');
return Array.from(interactionsListEl?.querySelectorAll('.interaction') || []);
}
function getLayoutShifts(view: Element): HTMLElement[] {
const interactionsListEl = view.shadowRoot!.querySelector('.log[slot="layout-shifts-log-content"]');
return Array.from(interactionsListEl?.querySelectorAll('.layout-shift') || []);
}
function selectVisibleLog(view: Element, logId: string): void {
view.shadowRoot!.querySelector('devtools-live-metrics-logs')!.shadowRoot!.querySelector('.tabbed-pane')!.shadowRoot!
.getElementById(`tab-${logId}`)
?.dispatchEvent(
new MouseEvent('mousedown', {bubbles: true}),
);
}
function getClearLogButton(view: Element): HTMLElementTagNameMap['devtools-button'] {
return view.shadowRoot!.querySelector('devtools-live-metrics-logs')!.shadowRoot!.querySelector('.tabbed-pane')!
.shadowRoot!.querySelector('devtools-toolbar devtools-button')!;
}
function selectDeviceOption(view: Element, deviceOption: string): void {
const deviceScopeSelector = view.shadowRoot!.querySelector('devtools-select-menu#device-scope-select') as HTMLElement;
const deviceScopeOptions = Array.from(deviceScopeSelector.querySelectorAll('devtools-menu-item'));
deviceScopeSelector.click();
deviceScopeOptions.find(o => o.value === deviceOption)!.click();
}
function selectPageScope(view: Element, pageScope: string): void {
const pageScopeSelector = view.shadowRoot!.querySelector('devtools-select-menu#page-scope-select') as HTMLElement;
pageScopeSelector.click();
const pageScopeOptions = Array.from(pageScopeSelector.querySelectorAll('devtools-menu-item'));
const originOption = pageScopeOptions.find(o => o.value === pageScope);
originOption!.click();
}
function getFieldMessage(view: Element): HTMLElement|null {
return view.shadowRoot!.querySelector('#field-setup .field-data-message');
}
function getLiveMetricsTitle(view: Element): HTMLElement {
// There may be multiple, but this should always be the first one.
return view.shadowRoot!.querySelector('.live-metrics > .section-title') as HTMLElement;
}
function getInpInteractionLink(view: Element): HTMLElement|null {
return view.shadowRoot!.querySelector<HTMLElement>('#inp .related-info button');
}
function getClsClusterLink(view: Element): HTMLElement|null {
return view.shadowRoot!.querySelector<HTMLElement>('#cls .related-info button');
}
function createMockFieldData() {
return {
record: {
key: {
// Only one of these keys will be set for a given result in reality
// Setting both here to make testing easier.
url: 'https://example.com/',
origin: 'https://example.com',
},
metrics: {
largest_contentful_paint: {
histogram: [
{start: 0, end: 2500, density: 0.5},
{start: 2500, end: 4000, density: 0.3},
{start: 4000, density: 0.2},
],
percentiles: {p75: 1000},
},
cumulative_layout_shift: {
histogram: [
{start: 0, end: 0.1},
{start: 0.1, end: 0.25, density: 0.2},
{start: 0.25, density: 0.8},
],
percentiles: {p75: 0.25},
},
round_trip_time: {
percentiles: {p75: 150},
},
form_factors: {
fractions: {
desktop: 0.6,
phone: 0.3,
tablet: 0.1,
},
},
},
collectionPeriod: {
firstDate: {year: 2024, month: 1, day: 1},
lastDate: {year: 2024, month: 1, day: 29},
},
},
};
}
function createInteractionsMap(interactions: LiveMetrics.Interaction[]): LiveMetrics.InteractionMap {
return new Map(interactions.map(interaction => [interaction.interactionId, interaction]));
}
describeWithMockConnection('LiveMetricsView', () => {
const mockHandleAction = sinon.stub();
beforeEach(async () => {
mockHandleAction.reset();
UI.ActionRegistration.registerActionExtension({
actionId: 'timeline.toggle-recording',
category: UI.ActionRegistration.ActionCategory.PERFORMANCE,
loadActionDelegate: async () => ({handleAction: mockHandleAction}),
});
UI.ActionRegistration.registerActionExtension({
actionId: 'timeline.record-reload',
category: UI.ActionRegistration.ActionCategory.PERFORMANCE,
loadActionDelegate: async () => ({handleAction: mockHandleAction}),
});
const dummyStorage = new Common.Settings.SettingsStorage({});
Common.Settings.Settings.instance({
forceNew: true,
syncedStorage: dummyStorage,
globalStorage: dummyStorage,
localStorage: dummyStorage,
});
const actionRegistryInstance = UI.ActionRegistry.ActionRegistry.instance({forceNew: true});
UI.ShortcutRegistry.ShortcutRegistry.instance({forceNew: true, actionRegistry: actionRegistryInstance});
LiveMetrics.LiveMetrics.instance({forceNew: true});
CrUXManager.CrUXManager.instance({forceNew: true});
EmulationModel.DeviceModeModel.DeviceModeModel.instance({forceNew: true});
});
afterEach(async () => {
UI.ActionRegistry.ActionRegistry.reset();
UI.ShortcutRegistry.ShortcutRegistry.removeInstance();
UI.ActionRegistration.maybeRemoveActionExtension('timeline.toggle-recording');
UI.ActionRegistration.maybeRemoveActionExtension('timeline.record-reload');
});
it('should show interactions', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 500,
phases: {
inputDelay: 100 as Milli,
processingDuration: 300 as Milli,
presentationDelay: 100 as Milli,
},
interactionId: 'interaction-1-1',
},
interactions: createInteractionsMap([
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-1',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [],
},
{
duration: 30,
startTime: 0,
nextPaintTime: 30,
interactionType: 'keyboard',
interactionId: 'interaction-1-2',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 10 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [],
});
await RenderCoordinator.done();
const interactionsEls = getInteractions(view);
assert.lengthOf(interactionsEls, 2);
// Click each interaction so we can test the expandable details.
for (const interactionEl of interactionsEls) {
interactionEl.querySelector('summary')!.click();
}
await RenderCoordinator.done();
const typeEl1 = interactionsEls[0].querySelector('.interaction-type') as HTMLDivElement;
assert.match(typeEl1.textContent!, /pointer/);
const inpChip1 = typeEl1.querySelector('.interaction-inp-chip');
assert.isNotNull(inpChip1);
const durationEl1 = interactionsEls[0].querySelector('.interaction-duration .metric-value') as HTMLDivElement;
assert.strictEqual(durationEl1.textContent, '500 ms');
assert.strictEqual(durationEl1.className, 'metric-value needs-improvement dim');
const phases1 =
Array.from(interactionsEls[0].querySelectorAll<HTMLElement>('.phase-table-row:not(.phase-table-header-row)'))
.map(el => el.innerText);
assert.deepEqual(phases1, [
'Input delay\n100',
'Processing duration\n300',
'Presentation delay\n100',
]);
const typeEl2 = interactionsEls[1].querySelector('.interaction-type') as HTMLDivElement;
assert.match(typeEl2.textContent!, /keyboard/);
const inpChip2 = typeEl2.querySelector('.interaction-inp-chip');
assert.isNull(inpChip2);
const durationEl2 = interactionsEls[1].querySelector('.interaction-duration .metric-value') as HTMLDivElement;
assert.strictEqual(durationEl2.textContent, '30 ms');
assert.strictEqual(durationEl2.className, 'metric-value good dim');
const phases2 =
Array.from(interactionsEls[1].querySelectorAll<HTMLElement>('.phase-table-row:not(.phase-table-header-row)'))
.map(el => el.innerText);
assert.deepEqual(phases2, [
'Input delay\n10',
'Processing duration\n10',
'Presentation delay\n10',
]);
});
it('should show button to log script details to console', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 500,
phases: {
inputDelay: 100 as Milli,
processingDuration: 300 as Milli,
presentationDelay: 100 as Milli,
},
interactionId: 'interaction-1-1',
},
interactions: createInteractionsMap([
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-1',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [{
renderStart: 0,
duration: 0,
scripts: [],
}],
},
{
duration: 30,
startTime: 0,
nextPaintTime: 30,
interactionType: 'keyboard',
interactionId: 'interaction-1-2',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 10 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [],
});
await RenderCoordinator.done();
const interactions = getInteractions(view);
assert.lengthOf(interactions, 2);
assert(
interactions[0].querySelector('.log-extra-details-button'), 'First interaction should have log details button');
assert.isNotOk(
interactions[1].querySelector('.log-extra-details-button'),
'Second interaction should not have log details button');
});
it('should show help icon for interaction that is longer than INP', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 50,
phases: {
inputDelay: 10 as Milli,
processingDuration: 30 as Milli,
presentationDelay: 10 as Milli,
},
interactionId: 'interaction-1-2',
},
interactions: createInteractionsMap([
{
duration: 50,
startTime: 0,
nextPaintTime: 50,
interactionType: 'keyboard',
interactionId: 'interaction-1-1',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 30 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-2',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [],
});
await RenderCoordinator.done();
const interactionsEls = getInteractions(view);
assert.lengthOf(interactionsEls, 2);
const typeEl1 = interactionsEls[0].querySelector<HTMLElement>('.interaction-type');
assert.match(typeEl1!.textContent!, /keyboard/);
const durationEl1 = interactionsEls[0].querySelector<HTMLElement>('.interaction-duration .metric-value');
assert.strictEqual(durationEl1!.textContent, '50 ms');
assert.strictEqual(durationEl1!.className, 'metric-value good dim');
const helpEl1 = interactionsEls[0].querySelector('.interaction-info');
assert.isNull(helpEl1);
const typeEl2 = interactionsEls[1].querySelector<HTMLElement>('.interaction-type');
assert.match(typeEl2!.textContent!, /pointer/);
const helpEl2 = interactionsEls[1].querySelector<HTMLElement>('.interaction-info');
assert.match(helpEl2!.title, /98th percentile/);
const durationEl2 = interactionsEls[1].querySelector<HTMLElement>('.interaction-duration .metric-value');
assert.strictEqual(durationEl2!.textContent, '500 ms');
assert.strictEqual(durationEl2!.className, 'metric-value needs-improvement dim');
});
it('should reveal CLS cluster when link clicked', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
cls: {
value: 0.11,
clusterShiftIds: ['layout-shift-1-2', 'layout-shift-1-3'],
},
interactions: new Map(),
layoutShifts: [
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
],
});
await RenderCoordinator.done();
selectVisibleLog(view, 'interactions');
await RenderCoordinator.done();
const firstClusterShift = getLayoutShifts(view).find(el => el.id === 'layout-shift-1-2')!;
assert.isFalse(firstClusterShift.checkVisibility());
assert.isFalse(firstClusterShift.hasFocus());
getClsClusterLink(view)!.click();
await RenderCoordinator.done();
assert.isTrue(firstClusterShift.checkVisibility());
assert.isTrue(firstClusterShift.hasFocus());
});
it('should hide CLS cluster link if there is no defined cluster', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
cls: {
value: 0.11,
clusterShiftIds: [],
},
interactions: new Map(),
layoutShifts: [
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
],
});
await RenderCoordinator.done();
assert.isNull(getClsClusterLink(view));
});
it('should hide CLS cluster link if there are no matching shifts', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
cls: {
value: 0.11,
clusterShiftIds: ['layout-shift-2-0'],
},
interactions: new Map(),
layoutShifts: [
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
],
});
await RenderCoordinator.done();
assert.isNull(getClsClusterLink(view));
});
it('should reveal INP interaction when link clicked', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 500,
phases: {
inputDelay: 100 as Milli,
processingDuration: 300 as Milli,
presentationDelay: 100 as Milli,
},
interactionId: 'interaction-1-1',
},
interactions: createInteractionsMap([
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-1',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [],
},
{
duration: 30,
startTime: 0,
nextPaintTime: 30,
interactionType: 'keyboard',
interactionId: 'interaction-1-2',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 10 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [],
});
await RenderCoordinator.done();
selectVisibleLog(view, 'layout-shifts');
await RenderCoordinator.done();
const inpInteractionEl = getInteractions(view).find(el => el.id === 'interaction-1-1')!;
assert.isFalse(inpInteractionEl.checkVisibility());
assert.isFalse(inpInteractionEl.hasFocus());
const inpInteractionLink = getInpInteractionLink(view);
inpInteractionLink!.click();
await RenderCoordinator.done();
assert.isTrue(inpInteractionEl.checkVisibility());
assert.isTrue(inpInteractionEl.hasFocus());
});
it('should hide INP link if no matching interaction', async () => {
const view = renderLiveMetrics();
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 500,
phases: {
inputDelay: 100 as Milli,
processingDuration: 300 as Milli,
presentationDelay: 100 as Milli,
},
interactionId: 'interaction-1-1',
},
interactions: createInteractionsMap([
{
duration: 30,
startTime: 0,
nextPaintTime: 30,
interactionType: 'keyboard',
interactionId: 'interaction-1-2',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 10 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [],
});
await RenderCoordinator.done();
const inpInteractionLink = getInpInteractionLink(view);
assert.isNull(inpInteractionLink);
});
it('clear interactions log button should work', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 0);
assert.lengthOf(getLayoutShifts(view), 0);
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 50,
phases: {
inputDelay: 10 as Milli,
processingDuration: 30 as Milli,
presentationDelay: 10 as Milli,
},
interactionId: 'interaction-1-2',
},
interactions: createInteractionsMap([
{
duration: 50,
startTime: 0,
nextPaintTime: 50,
interactionType: 'keyboard',
interactionId: 'interaction-1-1',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 30 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-2',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
],
});
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 2);
assert.lengthOf(getLayoutShifts(view), 1);
const clearLogButton = getClearLogButton(view);
clearLogButton.click();
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 0);
assert.lengthOf(getLayoutShifts(view), 1);
});
it('clear layout shifts log button should work', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 0);
assert.lengthOf(getLayoutShifts(view), 0);
LiveMetrics.LiveMetrics.instance().setStatusForTesting({
inp: {
value: 50,
phases: {
inputDelay: 10 as Milli,
processingDuration: 30 as Milli,
presentationDelay: 10 as Milli,
},
interactionId: 'interaction-1-2',
},
interactions: createInteractionsMap([
{
duration: 50,
startTime: 0,
nextPaintTime: 50,
interactionType: 'keyboard',
interactionId: 'interaction-1-1',
eventNames: ['keyup'],
phases: {inputDelay: 10 as Milli, processingDuration: 30 as Milli, presentationDelay: 10 as Milli},
longAnimationFrameTimings: [],
},
{
duration: 500,
startTime: 0,
nextPaintTime: 500,
interactionType: 'pointer',
interactionId: 'interaction-1-2',
eventNames: ['pointerup'],
phases: {inputDelay: 100 as Milli, processingDuration: 300 as Milli, presentationDelay: 100 as Milli},
longAnimationFrameTimings: [],
},
]),
layoutShifts: [
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
],
});
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 2);
assert.lengthOf(getLayoutShifts(view), 1);
selectVisibleLog(view, 'layout-shifts');
await RenderCoordinator.done();
const clearLogButton = getClearLogButton(view);
clearLogButton.click();
await RenderCoordinator.done();
assert.lengthOf(getInteractions(view), 2);
assert.lengthOf(getLayoutShifts(view), 0);
});
it('record action button should work', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
const recordButton =
view.shadowRoot?.querySelector('#record devtools-button') as HTMLElementTagNameMap['devtools-button'];
recordButton.click();
await RenderCoordinator.done();
assert.strictEqual(mockHandleAction.firstCall.args[1], 'timeline.toggle-recording');
});
it('record page load button should work', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
const recordButton =
view.shadowRoot?.querySelector('#record-page-load devtools-button') as HTMLElementTagNameMap['devtools-button'];
recordButton.click();
await RenderCoordinator.done();
assert.strictEqual(mockHandleAction.firstCall.args[1], 'timeline.record-reload');
});
it('should show minimal view for Node connections', async () => {
const view = renderLiveMetrics();
view.isNode = true;
await RenderCoordinator.done();
const title = view.shadowRoot?.querySelector('.section-title');
assert.strictEqual(title!.textContent!, 'Node performance');
});
describe('field data', () => {
let target: SDK.Target.Target;
let mockFieldData: CrUXManager.PageResult;
beforeEach(async () => {
const tabTarget = createTarget({type: SDK.Target.Type.TAB});
target = createTarget({parentTarget: tabTarget});
mockFieldData = {
'origin-ALL': null,
'origin-DESKTOP': null,
'origin-PHONE': null,
'origin-TABLET': null,
'url-ALL': null,
'url-DESKTOP': null,
'url-PHONE': null,
'url-TABLET': null,
warnings: [],
};
sinon.stub(CrUXManager.CrUXManager.instance(), 'getFieldDataForPage').callsFake(async () => mockFieldData);
CrUXManager.CrUXManager.instance().getConfigSetting().set({enabled: true, override: ''});
});
it('should not show when crux is disabled', async () => {
CrUXManager.CrUXManager.instance().getConfigSetting().set({enabled: false, override: ''});
mockFieldData['url-ALL'] = createMockFieldData();
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.lengthOf(envRecs, 0);
const fieldMessage = getFieldMessage(view);
assert.match(fieldMessage!.innerText, /See how your local metrics compare/);
const title = getLiveMetricsTitle(view);
assert.strictEqual(title.innerText, 'Local metrics');
});
it('should show when crux is enabled', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
mockFieldData['url-ALL'] = createMockFieldData();
target.model(SDK.ResourceTreeModel.ResourceTreeModel)
?.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, {
url: 'https://example.com',
isPrimaryFrame: () => true,
} as SDK.ResourceTreeModel.ResourceTreeFrame);
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.lengthOf(envRecs, 2);
assert.match(envRecs[0].textContent!, /60%.*desktop/);
assert.match(envRecs[1].textContent!, /Slow 4G/);
const fieldMessage = getFieldMessage(view);
// We can't match the exact string because we format the dates based on
// locale, so the exact format depends based on where the SWE or bots who
// run these tests are!
// We expect it to say something like Jan 1 - Jan 29 2024.
assert.match(fieldMessage!.innerText, /Jan.+2024/);
const title = getLiveMetricsTitle(view);
assert.strictEqual(title.innerText, 'Local and field metrics');
});
it('should show empty values when crux is enabled but there is no field data', async () => {
const view = renderLiveMetrics();
await RenderCoordinator.done();
target.model(SDK.ResourceTreeModel.ResourceTreeModel)
?.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, {
url: 'https://example.com',
isPrimaryFrame: () => true,
} as SDK.ResourceTreeModel.ResourceTreeFrame);
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, 'Not enough data');
assert.strictEqual(envRecs[1].textContent, 'Not enough data');
const fieldMessage = getFieldMessage(view);
assert.match(fieldMessage!.textContent!, /Not enough data/);
const title = getLiveMetricsTitle(view);
assert.strictEqual(title.innerText, 'Local and field metrics');
});
it('should display any warning from crux', async () => {
mockFieldData.warnings.push('Warning from crux');
const view = renderLiveMetrics();
await RenderCoordinator.done();
const fieldMessage = getFieldMessage(view);
assert.match(fieldMessage!.textContent!, /Warning from crux/);
});
it('should make initial request on render when crux is enabled', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
const view = renderLiveMetrics();
await RenderCoordinator.done();
const lcpFieldEl = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl!.textContent, '1.00 s');
});
it('should be removed once crux is disabled', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
const view = renderLiveMetrics();
await RenderCoordinator.done();
const lcpFieldEl1 = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl1!.textContent, '1.00 s');
CrUXManager.CrUXManager.instance().getConfigSetting().set({enabled: false, override: ''});
await RenderCoordinator.done();
const lcpFieldEl2 = getFieldMetricValue(view, 'lcp');
assert.isNull(lcpFieldEl2);
});
it('should take from selected page scope', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
mockFieldData['origin-ALL'] = createMockFieldData();
mockFieldData['origin-ALL'].record.metrics.largest_contentful_paint!.percentiles!.p75 = 2000;
const view = renderLiveMetrics();
await RenderCoordinator.done();
const lcpFieldEl1 = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl1!.textContent, '1.00 s');
selectPageScope(view, 'origin');
await RenderCoordinator.done();
const lcpFieldEl2 = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl2!.textContent, '2.00 s');
});
it('should take from selected device scope', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
mockFieldData['url-PHONE'] = createMockFieldData();
mockFieldData['url-PHONE'].record.metrics.largest_contentful_paint!.percentiles!.p75 = 2000;
const view = renderLiveMetrics();
await RenderCoordinator.done();
selectDeviceOption(view, 'ALL');
const lcpFieldEl1 = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl1!.textContent, '1.00 s');
selectDeviceOption(view, 'PHONE');
await RenderCoordinator.done();
const lcpFieldEl2 = getFieldMetricValue(view, 'lcp');
assert.strictEqual(lcpFieldEl2!.textContent, '2.00 s');
});
describe('network throttling recommendation', () => {
it('should show for closest target RTT', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
// 165ms is the adjusted latency of "Fast 4G" but 165ms is actually closer to the target RTT
// of "Slow 4G" than the target RTT of "Fast 4G".
// So we should expect the recommended preset to be "Slow 4G".
mockFieldData['url-ALL'].record.metrics.round_trip_time!.percentiles!.p75 = 165;
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.lengthOf(envRecs, 2);
assert.strictEqual(envRecs[0].textContent, '30% mobile, 60% desktop');
assert.match(envRecs[1].textContent!, /Slow 4G/);
const recNotice = view.shadowRoot!.querySelector('.environment-option devtools-network-throttling-selector')
?.shadowRoot!.querySelector('devtools-icon[name="info"]');
assert.exists(recNotice);
});
it('should hide if no RTT data', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
mockFieldData['url-ALL'].record.metrics.round_trip_time = undefined;
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '30% mobile, 60% desktop');
assert.strictEqual(envRecs[1].textContent, 'Not enough data');
const recNotice = view.shadowRoot!.querySelector('.environment-option devtools-network-throttling-selector')
?.shadowRoot!.querySelector('devtools-icon[name="info"]');
assert.notExists(recNotice);
});
it('should suggest no throttling for very low latency', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
// In theory this is closest to the "offline" preset latency of 0,
// but that preset should be ignored.
mockFieldData['url-ALL'].record.metrics.round_trip_time!.percentiles!.p75 = 1;
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '30% mobile, 60% desktop');
assert.match(envRecs[1].textContent!, /too fast to simulate with throttling/);
const recNotice = view.shadowRoot!.querySelector('.environment-option devtools-network-throttling-selector')
?.shadowRoot!.querySelector('devtools-button');
assert.notExists(recNotice);
});
it('should ignore presets that are generally too far off', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
// This is closest to the "3G" preset compared to other presets, but it's
// still too far away in general.
mockFieldData['url-ALL'].record.metrics.round_trip_time!.percentiles!.p75 = 10_000;
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '30% mobile, 60% desktop');
assert.strictEqual(envRecs[1].textContent, 'Not enough data');
const recNotice = view.shadowRoot!.querySelector('.environment-option devtools-network-throttling-selector')
?.shadowRoot!.querySelector('devtools-button');
assert.notExists(recNotice);
});
});
describe('form factor recommendation', () => {
it('should recommend desktop if it is the majority', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '30% mobile, 60% desktop');
assert.match(envRecs[1].textContent!, /Slow 4G/);
});
it('should recommend mobile if it is the majority', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
mockFieldData['url-ALL'].record.metrics.form_factors!.fractions = {
desktop: 0.1,
phone: 0.8,
tablet: 0.1,
};
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '80% mobile, 10% desktop');
assert.match(envRecs[1].textContent!, /Slow 4G/);
});
it('should recommend nothing if there is no majority', async () => {
mockFieldData['url-ALL'] = createMockFieldData();
mockFieldData['url-ALL'].record.metrics.form_factors!.fractions = {
desktop: 0.49,
phone: 0.49,
tablet: 0.02,
};
const view = renderLiveMetrics();
await RenderCoordinator.done();
const envRecs = getEnvironmentRecs(view);
assert.strictEqual(envRecs[0].textContent, '49% mobile, 49% desktop');
assert.match(envRecs[1].textContent!, /Slow 4G/);
});
});
});
});