chrome-devtools-frontend
Version:
Chrome DevTools UI
265 lines (222 loc) • 12.4 kB
text/typescript
// Copyright 2022 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 {TraceLoader} from '../../../testing/TraceLoader.js';
import * as Trace from '../trace.js';
async function processTrace(context: Mocha.Suite|Mocha.Context|null, url: string): Promise<void> {
Trace.Handlers.ModelHandlers.Meta.reset();
Trace.Handlers.ModelHandlers.LayoutShifts.reset();
try {
const events = await TraceLoader.rawEvents(context, url);
for (const event of events) {
Trace.Handlers.ModelHandlers.Meta.handleEvent(event);
Trace.Handlers.ModelHandlers.Screenshots.handleEvent(event);
Trace.Handlers.ModelHandlers.LayoutShifts.handleEvent(event);
}
} catch (error) {
assert.fail(error);
}
await Trace.Handlers.ModelHandlers.Meta.finalize();
await Trace.Handlers.ModelHandlers.Screenshots.finalize();
await Trace.Handlers.ModelHandlers.LayoutShifts.finalize();
}
describe('LayoutShiftsHandler', function() {
beforeEach(async () => {
// The layout shifts handler stores by process, so to make life easier we
// run the meta handler here, too, so that later on we can get the IDs of
// the main renderer process and thread.
Trace.Handlers.ModelHandlers.Meta.reset();
Trace.Handlers.ModelHandlers.LayoutShifts.reset();
});
it('clusters a single frame correctly', async function() {
await processTrace(this, 'cls-single-frame.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.lengthOf(layoutShifts.clusters, 1);
assert.strictEqual(layoutShifts.clusters[0].clusterCumulativeScore, 0.29522728495836237);
});
it('creates a cluster after the maximum time gap between shifts', async function() {
await processTrace(this, 'cls-cluster-max-timeout.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.lengthOf(layoutShifts.clusters, 3);
// The first cluster should end because the maximum time gap between
// shifts ends, and thus the time between the last shift and the window
// end should be exactly MAX_SHIFT_TIME_DELTA;
const firstCluster = layoutShifts.clusters[0];
const firstClusterEvents = layoutShifts.clusters[0].events;
assert.strictEqual(
firstCluster.clusterWindow.max - firstClusterEvents[firstClusterEvents.length - 1].ts,
Trace.Handlers.ModelHandlers.LayoutShifts.MAX_SHIFT_TIME_DELTA);
// There are seven shifts in quick succession in the first cluster,
// only one shift in the second cluster and only one shift in the
// third cluster.
assert.lengthOf(layoutShifts.clusters[0].events, 7);
assert.lengthOf(layoutShifts.clusters[1].events, 1);
assert.lengthOf(layoutShifts.clusters[2].events, 1);
});
it('creates a cluster after a navigation', async function() {
await processTrace(this, 'cls-cluster-navigation.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
const {navigationsByFrameId, mainFrameId} = Trace.Handlers.ModelHandlers.Meta.data();
const navigations = navigationsByFrameId.get(mainFrameId);
if (!navigations || navigations.length === 0) {
assert.fail('No navigations found');
}
assert.strictEqual(layoutShifts.clusters[0].clusterWindow.max, navigations[0].ts);
// The first cluster happens before any navigation
assert.strictEqual(layoutShifts.clusters[0].navigationId, Trace.Types.Events.NO_NAVIGATION);
// We should see an initial cluster here from the first layout shifts,
// followed by 1 for each of the navigations themselves.
assert.strictEqual(layoutShifts.clusters.length, navigations.length + 1);
const secondCluster = layoutShifts.clusters[1];
// The second cluster should be marked to start at the first shift timestamp.
assert.strictEqual(secondCluster.clusterWindow.min, secondCluster.events[0].ts);
// The second cluster happened after the first navigation, so it should
// have navigationId set to the ID of the first navigation
assert.isDefined(secondCluster.navigationId);
assert.strictEqual(secondCluster.navigationId, navigations[0].args.data?.navigationId);
});
it('creates a cluster after exceeding the continuous shift limit', async function() {
await processTrace(this, 'cls-cluster-max-duration.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.lengthOf(layoutShifts.clusters, 2);
// Cluster must be closed as soon as MAX_CLUSTER_DURATION is reached, even if
// there is a gap greater than MAX_SHIFT_TIME_DELTA right after the max window
// length happens.
assert.strictEqual(
layoutShifts.clusters[0].clusterWindow.max - layoutShifts.clusters[0].clusterWindow.min,
Trace.Handlers.ModelHandlers.LayoutShifts.MAX_CLUSTER_DURATION);
});
it('sets the end of the last session window to the trace end time correctly', async function() {
await processTrace(this, 'cls-cluster-max-duration.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.strictEqual(
layoutShifts.clusters.at(-1)?.clusterWindow.max, Trace.Handlers.ModelHandlers.Meta.data().traceBounds.max);
});
it('sets the end of the last session window to the max gap between duration correctly', async function() {
await processTrace(this, 'cls-cluster-max-timeout.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
const lastWindow = layoutShifts.clusters.at(-1)?.clusterWindow;
const lastShiftInWindow = layoutShifts.clusters.at(-1)?.events.at(-1);
if (!lastWindow) {
assert.fail('Session window not found.');
}
if (!lastShiftInWindow) {
assert.fail('Session window not found.');
}
assert.strictEqual(
lastWindow.max, lastShiftInWindow.ts + Trace.Handlers.ModelHandlers.LayoutShifts.MAX_SHIFT_TIME_DELTA);
assert.isBelow(lastWindow.range, Trace.Handlers.ModelHandlers.LayoutShifts.MAX_CLUSTER_DURATION);
});
it('sets the end of the last session window to the max session duration correctly', async function() {
await processTrace(this, 'cls-last-cluster-max-duration.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
const lastWindow = layoutShifts.clusters.at(-1)?.clusterWindow;
const lastShiftInWindow = layoutShifts.clusters.at(-1)?.events.at(-1);
if (!lastWindow) {
assert.fail('Session window not found.');
}
if (!lastShiftInWindow) {
assert.fail('Session window not found.');
}
assert.strictEqual(lastWindow.range, Trace.Handlers.ModelHandlers.LayoutShifts.MAX_CLUSTER_DURATION);
});
it('demarcates cluster score windows correctly', async function() {
await processTrace(this, 'cls-multiple-frames.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.lengthOf(layoutShifts.clusters, 5);
for (const cluster of layoutShifts.clusters) {
let clusterScore = 0;
for (const event of cluster.events) {
const scoreBeforeEvent = clusterScore;
clusterScore += event.args.data ? event.args.data.weighted_score_delta : 0;
// Here we've crossed the threshold from Good to NI (but not Bad) so
// check that both the Good and NI windows values are set as expected.
if (scoreBeforeEvent < Trace.Handlers.ModelHandlers.LayoutShifts.LayoutShiftsThreshold.NEEDS_IMPROVEMENT &&
clusterScore >= Trace.Handlers.ModelHandlers.LayoutShifts.LayoutShiftsThreshold.NEEDS_IMPROVEMENT &&
clusterScore < Trace.Handlers.ModelHandlers.LayoutShifts.LayoutShiftsThreshold.BAD) {
assert.strictEqual(cluster.scoreWindows.good.max, event.ts - 1);
if (!cluster.scoreWindows.needsImprovement) {
assert.fail('No Needs Improvement window');
}
assert.strictEqual(cluster.scoreWindows.needsImprovement.min, event.ts);
}
// Here we have transitioned from either Good or NI to Bad, so
// again we assert that the Bad window starts when expected,
// and that either the NI or Good window finishes just prior.
if (scoreBeforeEvent < Trace.Handlers.ModelHandlers.LayoutShifts.LayoutShiftsThreshold.BAD &&
clusterScore >= Trace.Handlers.ModelHandlers.LayoutShifts.LayoutShiftsThreshold.BAD) {
if (!cluster.scoreWindows.bad) {
assert.fail('No Bad window');
}
if (cluster.scoreWindows.needsImprovement) {
assert.strictEqual(cluster.scoreWindows.needsImprovement.max, event.ts - 1);
} else {
assert.strictEqual(cluster.scoreWindows.good.max, event.ts - 1);
}
assert.strictEqual(cluster.scoreWindows.bad.min, event.ts);
}
}
}
});
it('calculates Cumulative Layout Shift correctly for multiple session windows', async function() {
await processTrace(this, 'cls-cluster-max-timeout.json.gz');
const layoutShifts = Trace.Handlers.ModelHandlers.LayoutShifts.data();
assert.lengthOf(layoutShifts.clusters, 3);
let globalCLS = 0;
let clusterCount = 1;
let clusterWithCLS = 0;
for (const cluster of layoutShifts.clusters) {
let clusterCumulativeScore = 0;
for (const shift of cluster.events) {
clusterCumulativeScore += shift.args.data?.weighted_score_delta || 0;
// Test the cumulative score until this shift.
assert.strictEqual(shift.parsedData.cumulativeWeightedScoreInWindow, clusterCumulativeScore);
// Test the score of this shift's session window.
assert.strictEqual(shift.parsedData.sessionWindowData.cumulativeWindowScore, cluster.clusterCumulativeScore);
// Test the id of this shift's session window.
assert.strictEqual(shift.parsedData.sessionWindowData.id, clusterCount);
}
clusterCount++;
// Test the accumulated
assert.strictEqual(cluster.clusterCumulativeScore, clusterCumulativeScore);
if (cluster.clusterCumulativeScore > globalCLS) {
globalCLS = cluster.clusterCumulativeScore;
clusterWithCLS = clusterCount - 1;
}
}
// Test the calculated CLS.
assert.strictEqual(layoutShifts.sessionMaxScore, globalCLS);
assert.strictEqual(layoutShifts.clsWindowID, clusterWithCLS);
});
it('calculates worst shift correctly for clusters', async function() {
await processTrace(this, 'cls-cluster-max-timeout.json.gz');
const clusters = Trace.Handlers.ModelHandlers.LayoutShifts.data().clusters;
assert.isNotEmpty(clusters);
for (const cluster of clusters) {
// Get the max shift score from the list of layout shifts.
const maxShiftScore = Math.max(...cluster.events.map(s => s.args.data?.weighted_score_delta ?? 0));
const gotShift = cluster.worstShiftEvent as Trace.Types.Events.SyntheticLayoutShift;
assert.isNotNull(gotShift);
// Make sure the worstShiftEvent's data matches the maxShiftScore.
assert.strictEqual(gotShift.args.data?.weighted_score_delta ?? 0, maxShiftScore);
}
});
it('correctly calculates the duration and start time of the clusters', async function() {
await processTrace(this, 'cls-cluster-max-timeout.json.gz');
const clusters = Trace.Handlers.ModelHandlers.LayoutShifts.data().clusters;
assert.isNotEmpty(clusters);
for (const cluster of clusters) {
// Earliest and latest layout shifts should match.
const earliestLayoutShiftTs = Math.min(...cluster.events.map(s => s.ts));
assert.strictEqual(cluster.events[0].ts, earliestLayoutShiftTs);
const latestLayoutShiftTs = Math.max(...cluster.events.map(s => s.ts));
assert.strictEqual(cluster.events[cluster.events.length - 1].ts, latestLayoutShiftTs);
// earliest layout shift ts should be the cluster's ts.
assert.strictEqual(cluster.ts, earliestLayoutShiftTs);
const lastShiftTimings = Trace.Helpers.Timing.eventTimingsMicroSeconds(cluster.events[cluster.events.length - 1]);
const wantEndTime = lastShiftTimings.endTime + Trace.Handlers.ModelHandlers.LayoutShifts.MAX_SHIFT_TIME_DELTA;
const dur = Trace.Types.Timing.Micro(wantEndTime - earliestLayoutShiftTs);
assert.strictEqual(cluster.dur || 0, dur);
}
});
});