Skip to content

Commit 6243889

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Add tests for insight sorting
Also "help" sorting by INP savings in the case where there is no observed INP, by using a default INP score for purposes of normalizing estimated savings. Bug: 368135130 Change-Id: I5d396ecca8758ab39c86916b5e708f8a213143ef Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6094001 Reviewed-by: Paul Irish <[email protected]> Commit-Queue: Connor Clark <[email protected]>
1 parent 59b3916 commit 6243889

21 files changed

+325
-143
lines changed

front_end/models/trace/Processor.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,5 +365,67 @@ describeWithEnvironment('TraceProcessor', function() {
365365
assert.strictEqual(insights[2].model.RenderBlocking.renderBlockingRequests.length, 0);
366366
assert.strictEqual(insights[3].model.RenderBlocking.renderBlockingRequests.length, 1);
367367
});
368+
369+
it('sorts insights by estimated savings and field data', async function() {
370+
const getInsightOrder = async (includeMetadata: boolean) => {
371+
const processor = Trace.Processor.TraceProcessor.createWithAllHandlers();
372+
const file = await TraceLoader.rawEvents(this, 'image-delivery.json.gz');
373+
374+
let metadata;
375+
if (includeMetadata) {
376+
metadata = await TraceLoader.metadata(this, 'image-delivery.json.gz');
377+
}
378+
379+
await processor.parse(file, {isFreshRecording: true, isCPUProfile: false, metadata});
380+
if (!processor.insights) {
381+
throw new Error('No insights');
382+
}
383+
384+
const insightSet = Array.from(processor.insights.values()).at(-1);
385+
if (!insightSet) {
386+
throw new Error('No insight set');
387+
}
388+
389+
// It's been sorted already ... but let's add some fake estimated savings and re-sort to
390+
// better test the sorting.
391+
insightSet.model.CLSCulprits.metricSavings = {CLS: 0.07};
392+
processor.sortInsightSet(processor.insights, insightSet, metadata ?? null);
393+
394+
return Object.keys(insightSet.model);
395+
};
396+
397+
const orderWithoutMetadata = await getInsightOrder(false);
398+
assert.deepStrictEqual(orderWithoutMetadata, [
399+
'CLSCulprits',
400+
'Viewport',
401+
'InteractionToNextPaint',
402+
'LCPPhases',
403+
'LCPDiscovery',
404+
'RenderBlocking',
405+
'ImageDelivery',
406+
'DocumentLatency',
407+
'FontDisplay',
408+
'DOMSize',
409+
'ThirdParties',
410+
'SlowCSSSelector',
411+
]);
412+
413+
const orderWithMetadata = await getInsightOrder(true);
414+
// Viewport is first, before CLSCulprits, since the field data produces a higher weight for INP than for CLS.
415+
assert.deepStrictEqual(orderWithMetadata, [
416+
'Viewport',
417+
'CLSCulprits',
418+
'InteractionToNextPaint',
419+
'LCPPhases',
420+
'LCPDiscovery',
421+
'RenderBlocking',
422+
'ImageDelivery',
423+
'DocumentLatency',
424+
'FontDisplay',
425+
'DOMSize',
426+
'ThirdParties',
427+
'SlowCSSSelector',
428+
]);
429+
});
368430
});
369431
});

front_end/models/trace/Processor.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,9 @@ export class TraceProcessor extends EventTarget {
347347
* Sort the insight models based on the impact of each insight's estimated savings, additionally weighted by the
348348
* worst metrics according to field data (if present).
349349
*/
350-
#sortInsightSet(
351-
insights: Insights.Types.TraceInsightSets, insightSet: Insights.Types.InsightSet, options: ParseOptions): void {
350+
sortInsightSet(
351+
insights: Insights.Types.TraceInsightSets, insightSet: Insights.Types.InsightSet,
352+
metadata: Types.File.MetaData|null): void {
352353
// The initial order of the insights is alphabetical, based on `front_end/models/trace/insights/Models.ts`.
353354
// The order here provides a baseline that groups insights in a more logical way.
354355
const baselineOrder: Record<keyof Insights.Types.InsightModels, null> = {
@@ -367,17 +368,21 @@ export class TraceProcessor extends EventTarget {
367368
};
368369

369370
// Determine the weights for each metric based on field data, utilizing the same scoring curve that Lighthouse uses.
370-
const weights = Insights.Common.calculateMetricWeightsForSorting(insightSet, options.metadata ?? null);
371+
const weights = Insights.Common.calculateMetricWeightsForSorting(insightSet, metadata);
371372

372373
// Normalize the estimated savings to a single number, weighted by its relative impact
373374
// to the page experience based on the same scoring curve that Lighthouse uses.
374375
const observedLcp = Insights.Common.getLCP(insights, insightSet.id)?.value;
375-
const observedInp = Insights.Common.getINP(insights, insightSet.id)?.value;
376376
const observedCls = Insights.Common.getCLS(insights, insightSet.id).value;
377+
378+
// INP is special - if users did not interact with the page, we'll have no INP, but we should still
379+
// be able to prioritize insights based on this metric. When we observe no interaction, instead use
380+
// a default value for the baseline INP.
381+
const observedInp = Insights.Common.getINP(insights, insightSet.id)?.value ?? 200;
382+
377383
const observedLcpScore =
378384
observedLcp !== undefined ? Insights.Common.evaluateLCPMetricScore(observedLcp) : undefined;
379-
const observedInpScore =
380-
observedInp !== undefined ? Insights.Common.evaluateINPMetricScore(observedInp) : undefined;
385+
const observedInpScore = Insights.Common.evaluateINPMetricScore(observedInp);
381386
const observedClsScore = Insights.Common.evaluateCLSMetricScore(observedCls);
382387

383388
const insightToSortingRank = new Map<string, number>();
@@ -387,17 +392,17 @@ export class TraceProcessor extends EventTarget {
387392
const cls = model.metricSavings?.CLS ?? 0;
388393

389394
const lcpPostSavings = observedLcp !== undefined ? Math.max(0, observedLcp - lcp) : undefined;
390-
const inpPostSavings = observedInp !== undefined ? Math.max(0, observedInp - inp) : undefined;
391-
const clsPostSavings = observedCls !== undefined ? Math.max(0, observedCls - cls) : undefined;
395+
const inpPostSavings = Math.max(0, observedInp - inp);
396+
const clsPostSavings = Math.max(0, observedCls - cls);
392397

393398
let score = 0;
394399
if (weights.lcp && lcp && observedLcpScore !== undefined && lcpPostSavings !== undefined) {
395400
score += weights.lcp * (Insights.Common.evaluateLCPMetricScore(lcpPostSavings) - observedLcpScore);
396401
}
397-
if (weights.inp && inp && observedInpScore !== undefined && inpPostSavings !== undefined) {
402+
if (weights.inp && inp && observedInpScore !== undefined) {
398403
score += weights.inp * (Insights.Common.evaluateINPMetricScore(inpPostSavings) - observedInpScore);
399404
}
400-
if (weights.cls && cls && observedClsScore !== undefined && clsPostSavings !== undefined) {
405+
if (weights.cls && cls && observedClsScore !== undefined) {
401406
score += weights.cls * (Insights.Common.evaluateCLSMetricScore(clsPostSavings) - observedClsScore);
402407
}
403408

@@ -476,7 +481,7 @@ export class TraceProcessor extends EventTarget {
476481
model,
477482
};
478483
insights.set(insightSet.id, insightSet);
479-
this.#sortInsightSet(insights, insightSet, options);
484+
this.sortInsightSet(insights, insightSet, options.metadata ?? null);
480485
}
481486

482487
/**

front_end/models/trace/insights/BUILD.gn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ ts_library("unittests") {
5050

5151
sources = [
5252
"CLSCulprits.test.ts",
53+
"Common.test.ts",
5354
"DOMSize.test.ts",
5455
"DocumentLatency.test.ts",
5556
"FontDisplay.test.ts",
@@ -59,6 +60,7 @@ ts_library("unittests") {
5960
"LCPPhases.test.ts",
6061
"RenderBlocking.test.ts",
6162
"SlowCSSSelector.test.ts",
63+
"Statistics.test.ts",
6264
"ThirdParties.test.ts",
6365
"Viewport.test.ts",
6466
]

front_end/models/trace/insights/CLSCulprits.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,12 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {getFirstOrError, getInsightOrError} from '../../../testing/InsightHelpers.js';
7-
import {TraceLoader} from '../../../testing/TraceLoader.js';
6+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
87
import * as Helpers from '../helpers/helpers.js';
98
import * as Types from '../types/types.js';
109

1110
import {Models} from './insights.js';
1211

13-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
14-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
15-
if (!insights) {
16-
throw new Error('No insights');
17-
}
18-
19-
return {data: parsedTrace, insights};
20-
}
21-
2212
// Root cause invalidation window.
2313
const INVALIDATION_WINDOW = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(0.5));
2414

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2024 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6+
import {getFirstOrError, processTrace} from '../../../testing/InsightHelpers.js';
7+
8+
import * as Insights from './insights.js';
9+
10+
const {calculateMetricWeightsForSorting} = Insights.Common;
11+
12+
describeWithEnvironment('Common', function() {
13+
describe('calculateMetricWeightsForSorting', () => {
14+
async function process(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
15+
const {data, insights, metadata} = await processTrace(testContext, traceFile);
16+
if (!metadata) {
17+
throw new Error('missing metadata');
18+
}
19+
20+
const insightSetKey = getFirstOrError(data.Meta.navigationsByNavigationId.values()).args.data.navigationId;
21+
const insightSet = insights.get(insightSetKey);
22+
if (!insightSet) {
23+
throw new Error('missing insight set');
24+
}
25+
26+
// Clone so it may be modified.
27+
const clonedMetadata = structuredClone(metadata);
28+
29+
return {insightSet, metadata: clonedMetadata};
30+
}
31+
32+
it('returns default weights when there is no field data', async () => {
33+
const {insightSet, metadata} = await process(this, 'image-delivery.json.gz');
34+
35+
// No field data defaults to even split of weights.
36+
metadata.cruxFieldData = undefined;
37+
let weights = calculateMetricWeightsForSorting(insightSet, metadata);
38+
assert.deepStrictEqual(weights, {lcp: 1 / 3, inp: 1 / 3, cls: 1 / 3});
39+
40+
metadata.cruxFieldData = [];
41+
weights = calculateMetricWeightsForSorting(insightSet, metadata);
42+
assert.deepStrictEqual(weights, {lcp: 1 / 3, inp: 1 / 3, cls: 1 / 3});
43+
});
44+
45+
it('returns weights based on field data', async () => {
46+
const {insightSet, metadata} = await process(this, 'image-delivery.json.gz');
47+
48+
const weights = calculateMetricWeightsForSorting(insightSet, metadata);
49+
assert.deepStrictEqual(weights, {lcp: 0.07778127820223579, inp: 0.5504200439526509, cls: 0.37179867784511333});
50+
});
51+
});
52+
});

front_end/models/trace/insights/DOMSize.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,7 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {getFirstOrError, getInsightOrError} from '../../../testing/InsightHelpers.js';
7-
import {TraceLoader} from '../../../testing/TraceLoader.js';
8-
9-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
10-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
11-
if (!insights) {
12-
throw new Error('No insights');
13-
}
14-
15-
return {data: parsedTrace, insights};
16-
}
6+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
177

188
describeWithEnvironment('DOMSize', function() {
199
it('finds layout reflows and style recalcs affected by DOM size',

front_end/models/trace/insights/DocumentLatency.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,16 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {createContextForNavigation, getFirstOrError, getInsightOrError} from '../../../testing/InsightHelpers.js';
6+
import {
7+
createContextForNavigation,
8+
getFirstOrError,
9+
getInsightOrError,
10+
processTrace,
11+
} from '../../../testing/InsightHelpers.js';
712
import {TraceLoader} from '../../../testing/TraceLoader.js';
813
import * as Trace from '../trace.js';
914
import * as Types from '../types/types.js';
1015

11-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
12-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
13-
if (!insights) {
14-
throw new Error('No insights');
15-
}
16-
17-
return {data: parsedTrace, insights};
18-
}
19-
2016
describeWithEnvironment('DocumentLatency', function() {
2117
it('reports savings for main document with redirects', async () => {
2218
const {data, insights} = await processTrace(this, 'lantern/redirect/trace.json.gz');

front_end/models/trace/insights/FontDisplay.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,7 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {getFirstOrError, getInsightOrError} from '../../../testing/InsightHelpers.js';
7-
import {TraceLoader} from '../../../testing/TraceLoader.js';
8-
9-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
10-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
11-
if (!insights) {
12-
throw new Error('No insights');
13-
}
14-
15-
return {data: parsedTrace, insights};
16-
}
6+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
177

188
describeWithEnvironment('FontDisplay', function() {
199
it('finds no requests for remote fonts', async () => {

front_end/models/trace/insights/ImageDelivery.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,12 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {getFirstOrError, getInsightOrError} from '../../../testing/InsightHelpers.js';
7-
import {TraceLoader} from '../../../testing/TraceLoader.js';
6+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
87

98
import * as Insights from './insights.js';
109

1110
const {ImageOptimizationType} = Insights.Models.ImageDelivery;
1211

13-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
14-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
15-
if (!insights) {
16-
throw new Error('No insights');
17-
}
18-
19-
return {data: parsedTrace, insights};
20-
}
21-
2212
describeWithEnvironment('ImageDelivery', function() {
2313
it('finds requests for remote fonts', async () => {
2414
// See the following for a description of each test case:

front_end/models/trace/insights/InteractionToNextPaint.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,9 @@
33
// found in the LICENSE file.
44

55
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6-
import {createContextForNavigation, getFirst} from '../../../testing/InsightHelpers.js';
7-
import {TraceLoader} from '../../../testing/TraceLoader.js';
6+
import {createContextForNavigation, getFirst, processTrace} from '../../../testing/InsightHelpers.js';
87
import * as Trace from '../trace.js';
98

10-
export async function processTrace(testContext: Mocha.Suite|Mocha.Context|null, traceFile: string) {
11-
const {parsedTrace, insights} = await TraceLoader.traceEngine(testContext, traceFile);
12-
if (!insights) {
13-
throw new Error('No insights');
14-
}
15-
16-
return {data: parsedTrace, insights};
17-
}
18-
199
describeWithEnvironment('InteractionToNextPaint', function() {
2010
const test = (traceFile: string, longest?: number, highPercentile?: number) => {
2111
if (highPercentile === undefined) {

0 commit comments

Comments
 (0)