chrome-devtools-frontend
Version:
Chrome DevTools UI
555 lines (487 loc) • 26 kB
text/typescript
// Copyright 2025 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 Platform from '../../../core/platform/platform.js';
import type * as Protocol from '../../../generated/protocol.js';
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
import {TraceLoader} from '../../../testing/TraceLoader.js';
import * as Trace from '../trace.js';
import type {PreconnectedOrigin} from './NetworkDependencyTree.js';
import type {InsightSetContextWithNavigation, RelatedEventsMap} from './types.js';
const {urlString} = Platform.DevToolsPath;
describeWithEnvironment('NetworkDependencyTree', function() {
let insight: Trace.Insights.Types.InsightModels['NetworkDependencyTree'];
before(async function() {
const {data, insights} = await processTrace(this, 'lcp-multiple-frames.json.gz');
const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
});
it('calculates network dependency tree', () => {
// The network dependency tree in this trace is, |app.js| took longer than |app.css|, so |app.js| will be first.
// | .../index.html (ts:566777570990, dur:5005590)
// |
// | | .../app.js (ts:566782574106, dur:11790)
// | | .../app.css (ts:566782573909, dur:7205)
assert.lengthOf(insight.rootNodes, 1);
const root = insight.rootNodes[0];
assert.strictEqual(root.request.args.data.url, 'http://localhost:8787/lcp-iframes/index.html');
assert.strictEqual(root.timeFromInitialRequest, Trace.Types.Timing.Micro(root.request.dur));
assert.lengthOf(root.children, 2);
const [child0, child1] = insight.rootNodes[0].children;
assert.strictEqual(child0.request.args.data.url, 'http://localhost:8787/lcp-iframes/app.js');
assert.strictEqual(
child0.timeFromInitialRequest,
Trace.Types.Timing.Micro(child0.request.ts + child0.request.dur - root.request.ts));
assert.lengthOf(child0.children, 0);
assert.strictEqual(child1.request.args.data.url, 'http://localhost:8787/lcp-iframes/app.css');
assert.strictEqual(
child1.timeFromInitialRequest,
Trace.Types.Timing.Micro(child1.request.ts + child1.request.dur - root.request.ts));
assert.lengthOf(child1.children, 0);
});
it('Calculate the max critical path latency', () => {
// The chain |index.html(root) -> app.js(child0)| is the longest
const root = insight.rootNodes[0];
const child0 = root.children[0];
assert.strictEqual(
insight.maxTime, Trace.Types.Timing.Micro(child0.request.ts + child0.request.dur - root.request.ts));
});
it('Marks the longest network dependency chain', () => {
const root = insight.rootNodes[0];
const [child0, child1] = root.children;
// The chain |index.html(root) -> app.js(child0)| is the longest
assert.isTrue(root.isLongest);
assert.isTrue(child0.isLongest);
// The |app.css| is not in the longest chain
assert.isNotTrue(child1.isLongest);
});
it('Store the all parents and children events for all requests', () => {
const root = insight.rootNodes[0];
const [child0, child1] = root.children;
// There are three chains from Lantern:
// |index.html(root)|
// |index.html(root) -> app.js(child0)|
// |index.html(root) -> app.css(child1)|
// Both child0 and child1 are related to the root
assert.sameDeepMembers([...root.relatedRequests], [root.request, child0.request, child1.request]);
// Only root and child0 are related to the child0
assert.sameDeepMembers([...child0.relatedRequests], [root.request, child0.request]);
// Only root and child1 are related to the child1
assert.sameDeepMembers([...child1.relatedRequests], [root.request, child1.request]);
});
it('Fail the audit when there at least one chain with at least two requests', () => {
assert.isTrue(insight.fail);
});
it('Does not fail the audit when there is only main doc request', async () => {
// Need to load a file with only main doc in the the critical requests chains.
const {data, insights} = await processTrace(this, 'image-delivery.json.gz');
const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
assert.isFalse(insight.fail);
});
it('Calculates the relatedEvents map (event to warning map)', async function() {
TraceLoader.setTestTimeout(this);
// Need to load a file with longer dependency chain for this test.
// Only those requests whose depth >= 2 will be added to the related events.
const {data, insights} = await processTrace(this, 'web-dev-screenshot-source-ids.json.gz');
const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
// For NetworkDependencyTree, the relatedEvents is a map format.
assert.isFalse(Array.isArray(insight.relatedEvents));
const relatedEvents = insight.relatedEvents as RelatedEventsMap;
// There are a few chains, let test the first chain
// |web.dev -> /css -> KFO7CnqEu9…UBA.woff2|
const root = insight.rootNodes[0];
const child0 = root.children[0];
const child00 = child0.children[0];
// Root's depth is 0, so there isn't any warning message
assert.deepEqual(relatedEvents.get(root.request), []);
// child0's depth is 1, so there isn't any warning message
assert.deepEqual(relatedEvents.get(child0.request), []);
// child00's depth is 2, so there is one warning message
assert.deepEqual(
relatedEvents.get(child00.request), [Trace.Insights.Models.NetworkDependencyTree.UIStrings.warningDescription]);
});
});
describe('generatePreconnectedOrigins', () => {
describe('generatePreconnectedOriginsFromDom', () => {
const mockParsedTrace = {
NetworkRequests: {
linkPreconnectEvents: [] as Trace.Types.Events.LinkPreconnect[],
byTime: [] as Trace.Types.Events.SyntheticNetworkRequest[],
},
} as Trace.Handlers.Types.ParsedTrace;
const mockContext = {} as InsightSetContextWithNavigation;
beforeEach(() => {
mockParsedTrace.NetworkRequests.linkPreconnectEvents.length = 0;
});
it('should mark preconnect origins as not unused when they match context requests', () => {
mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
args: {
data: {
url: 'https://example.com',
node_id: 1,
frame: 'frame-id',
},
},
} as Trace.Types.Events.LinkPreconnect);
const mockContextRequests: Trace.Types.Events.SyntheticNetworkRequest[] = [{
args: {
data: {
url: 'https://example.com/script.js',
},
},
} as Trace.Types.Events.SyntheticNetworkRequest];
const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(
mockParsedTrace, mockContext, mockContextRequests, /* preconnectCandidates */[]);
assert.deepEqual(preconnectOrigins, [{
node_id: 1 as Protocol.DOM.BackendNodeId,
frame: 'frame-id',
url: 'https://example.com',
unused: false,
crossorigin: false,
source: 'DOM'
}]);
});
it('should mark preconnect origins as unused when they do not match context requests', () => {
mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
args: {
data: {
url: 'https://example.com',
node_id: 1,
frame: 'frame-id',
},
},
} as Trace.Types.Events.LinkPreconnect);
const mockContextRequests: Trace.Types.Events.SyntheticNetworkRequest[] = [{
args: {
data: {
url: 'https://other.com/image.png',
},
},
} as Trace.Types.Events.SyntheticNetworkRequest];
const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(
mockParsedTrace, mockContext, mockContextRequests, /* preconnectCandidates */[]);
assert.deepEqual(preconnectOrigins, [{
node_id: 1 as Protocol.DOM.BackendNodeId,
frame: 'frame-id',
url: 'https://example.com',
unused: true,
crossorigin: false,
source: 'DOM'
}]);
});
it('sets crossorigin to true when a matching preconnect candidate exists', () => {
mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
args: {
data: {
url: 'https://example.com',
node_id: 1,
frame: 'frame-id',
},
},
} as Trace.Types.Events.LinkPreconnect);
const mockPreconnectCandidates: Trace.Insights.Models.NetworkDependencyTree.PreconnectCandidate[] =
[{origin: urlString`https://example.com`, wastedMs: 100 as Trace.Types.Timing.Milli}];
const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(
mockParsedTrace, mockContext, /* mockContextRequests */[],
/* preconnectCandidates */ mockPreconnectCandidates);
assert.deepEqual(preconnectOrigins, [{
node_id: 1 as Protocol.DOM.BackendNodeId,
frame: 'frame-id',
url: 'https://example.com',
unused: true,
crossorigin: true,
source: 'DOM'
}]);
});
it('sets crossorigin to false when no matching preconnect candidate exists', () => {
mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
args: {
data: {
url: 'https://example.com',
node_id: 1,
frame: 'frame-id',
},
},
} as Trace.Types.Events.LinkPreconnect);
const mockPreconnectCandidates: Trace.Insights.Models.NetworkDependencyTree.PreconnectCandidate[] =
[{origin: urlString`https://other.com`, wastedMs: 100 as Trace.Types.Timing.Milli}];
const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(
mockParsedTrace, mockContext, /* mockContextRequests */[],
/* preconnectCandidates */ mockPreconnectCandidates);
assert.deepEqual(preconnectOrigins, [{
node_id: 1 as Protocol.DOM.BackendNodeId,
frame: 'frame-id',
url: 'https://example.com',
unused: true,
crossorigin: false,
source: 'DOM'
}]);
});
});
describeWithEnvironment('PreconnectedOriginFromResponseHeader', function() {
let insight: Trace.Insights.Types.InsightModels['NetworkDependencyTree'];
let documentRequest: Trace.Types.Events.SyntheticNetworkRequest|undefined;
before(async function() {
const {data, insights} = await processTrace(this, 'preconnect-advice.json.gz');
const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
documentRequest =
data.NetworkRequests.byTime.find(req => req.args.data.requestId === firstNav.args.data?.navigationId);
});
it('correctly generate the preconnected origins', () => {
// There are 4 preconnected origins, 3 from DOM, and 1 from response header.
assert.lengthOf(insight.preconnectedOrigins, 4);
// A sanity check to avoid TS error.
assert.isDefined(documentRequest);
const expected: PreconnectedOrigin[] = [
{
node_id: 57 as Protocol.DOM.BackendNodeId,
frame: '3773BAB92FB5A26C6B03EAD6CF821791',
url: 'https://www.youtube.com/',
unused: true,
crossorigin: false,
source: 'DOM',
},
{
node_id: 58 as Protocol.DOM.BackendNodeId,
frame: '3773BAB92FB5A26C6B03EAD6CF821791',
url: 'https://www.google.com/',
unused: true,
crossorigin: false,
source: 'DOM',
},
{
node_id: 59 as Protocol.DOM.BackendNodeId,
frame: '3773BAB92FB5A26C6B03EAD6CF821791',
url: 'http://example.com/',
unused: true,
crossorigin: false,
source: 'DOM',
},
{
url: 'https://example.com/',
headerText: '<https://example.com/>; rel=preconnect',
request: documentRequest,
unused: true,
crossorigin: false,
source: 'ResponseHeader',
},
];
assert.deepEqual(insight.preconnectedOrigins, expected);
});
});
describe('handleLinkResponseHeader', () => {
it('should return an empty array for null or empty input', () => {
assert.deepEqual(Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(''), []);
assert.deepEqual(
Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(null as unknown as string), []);
});
it('should parse a valid preconnect link with quotes', () => {
const linkHeader = '<https://example.com>; rel="preconnect"';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, [{url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"'}]);
});
it('should parse a valid preconnect link without quotes', () => {
const linkHeader = '<https://example.com>; rel=preconnect';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, [{url: 'https://example.com', headerText: '<https://example.com>; rel=preconnect'}]);
});
it('should parse multiple preconnect links', () => {
const linkHeader = '<https://example.com>; rel="preconnect", <https://other.com>; rel=preconnect';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, [
{url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"'},
{url: 'https://other.com', headerText: '<https://other.com>; rel=preconnect'},
]);
});
it('should parse a preconnect link with other parameters', () => {
const linkHeader = '<https://example.com>; rel="preconnect"; crossorigin';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(
result, [{url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"; crossorigin'}]);
});
it('should parse a preconnect link with comma in urls', () => {
const linkHeader =
'<https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry>; rel="preconnect"';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(
result, [{
url: 'https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry',
headerText: '<https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry>; rel="preconnect"'
}]);
});
it('should ignore links with other rel values', () => {
const linkHeader = '<https://example.com>; rel="preload"';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, []);
});
it('should ignore invalid links (missing <>)', () => {
const linkHeader = 'https://example.com; rel="preconnect"';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, []);
});
it('should ignore invalid links (missing rel)', () => {
const linkHeader = '<https://example.com>; crossorigin';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, []);
});
it('should handle mixed valid and invalid links', () => {
const linkHeader =
'<https://example.com>; rel="preconnect", https://other.com; rel=preconnect, <https://another.com>; rel="preload"';
const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
assert.deepEqual(result, [{url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"'}]);
});
});
});
describeWithEnvironment('generatePreconnectCandidates', () => {
const mockParsedTrace = {
NetworkRequests: {
eventToInitiator: new Map<Trace.Types.Events.SyntheticNetworkRequest, Trace.Types.Events.Event>(),
byTime: [] as Trace.Types.Events.SyntheticNetworkRequest[],
linkPreconnectEvents: [] as Trace.Types.Events.LinkPreconnect[],
},
} as Trace.Handlers.Types.ParsedTrace;
const mockContext = {
// This is not need to calculate the data of this insight, but is needed to check this is a context with lantern data.
navigation: {} as Trace.Types.Events.NavigationStart,
lantern: {
simulator: {
getOptions: () => ({rtt: 200, additionalRttByOrigin: new Map<string, number>()}),
},
metrics: {
largestContentfulPaint: {
pessimisticGraph: {
traverse: (cb: (node: Trace.Lantern.Graph.Node) => void) => {
cb({type: 'network', request: {url: 'https://example.com/script.js'}} as Trace.Lantern.Graph.Node);
cb({type: 'network', request: {url: 'https://example.com/first.js'}} as Trace.Lantern.Graph.Node);
cb({type: 'network', request: {url: 'https://example.com/second.js'}} as Trace.Lantern.Graph.Node);
cb({type: 'network', request: {url: 'https://other.com/image.png'}} as Trace.Lantern.Graph.Node);
},
},
},
firstContentfulPaint: {
pessimisticGraph: {
traverse: (cb: (node: Trace.Lantern.Graph.Node) => void) => {
cb({type: 'network', request: {url: 'https://example.com/script.js'}} as Trace.Lantern.Graph.Node);
},
},
},
},
} as unknown as Trace.Insights.Types.LanternContext,
bounds: {min: 0, max: 1000000},
navigationId: 'main-request',
} as InsightSetContextWithNavigation;
const mainRequest: Trace.Types.Events.SyntheticNetworkRequest = {
args: {
data: {
url: 'https://main.com',
requestId: 'main-request',
syntheticData: {finishTime: 1_000},
timing: {connectEnd: 0, connectStart: 0}
},
},
ts: 0,
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
const validRequest: Trace.Types.Events.SyntheticNetworkRequest = {
args: {
data: {
url: 'https://example.com/script.js',
syntheticData: {sendStartTime: 2_000},
timing: {dnsStart: 100, dnsEnd: 200, connectStart: 300, connectEnd: 400},
},
},
ts: 1500,
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
beforeEach(() => {
mockParsedTrace.NetworkRequests.eventToInitiator.clear();
mockParsedTrace.NetworkRequests.byTime.length = 0;
mockParsedTrace.NetworkRequests.linkPreconnectEvents.length = 0;
mockParsedTrace.NetworkRequests.byTime.push(mainRequest);
});
it('generates preconnect results for valid requests', () => {
mockParsedTrace.NetworkRequests.byTime.push(validRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 1);
assert.strictEqual(preconnectCandidates[0].origin, 'https://example.com');
// |validRequest->sendStartTime| - |mainRequest->finishTime| + |validRequest->dnsStart|
assert.strictEqual(preconnectCandidates[0].wastedMs, 101);
});
it('generates preconnect results and sort them by wasted time', () => {
mockParsedTrace.NetworkRequests.byTime.push(validRequest);
const otherValidRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
otherValidRequest.args.data.url = 'https://other.com/image.png';
otherValidRequest.args.data.syntheticData.sendStartTime = Trace.Types.Timing.Micro(3_000);
mockParsedTrace.NetworkRequests.byTime.push(otherValidRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 2);
// other.com has a wasted time of 102 ms, while example.com has 101 ms. So other.com will be the first.
assert.strictEqual(preconnectCandidates[0].origin, 'https://other.com');
// |otherValidRequest->sendStartTime| - |mainRequest->finishTime| + |otherValidRequest->dnsStart|
assert.strictEqual(preconnectCandidates[0].wastedMs, 102);
assert.strictEqual(preconnectCandidates[1].origin, 'https://example.com');
// |validRequest->sendStartTime| - |mainRequest->finishTime| + |validRequest->dnsStart|
assert.strictEqual(preconnectCandidates[1].wastedMs, 101);
});
it('shouldn\'t suggest preconnect when requests have same origin as main request', () => {
const sameOriginRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
sameOriginRequest.args.data.url = 'https://main.com/some-resource';
mockParsedTrace.NetworkRequests.byTime.push(sameOriginRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 0);
});
it('shouldn\'t suggest preconnect when initiator is main resource', () => {
const initiatedByMainRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
initiatedByMainRequest.args.data.url = 'https://example.com/script.js';
mockParsedTrace.NetworkRequests.byTime.push(initiatedByMainRequest);
mockParsedTrace.NetworkRequests.eventToInitiator.set(initiatedByMainRequest, mainRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 0);
});
it('shouldn\'t suggest non http(s) protocols as preconnect', () => {
const nonHttpRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
nonHttpRequest.args.data.url = 'data:text/plain;base64,hello';
mockParsedTrace.NetworkRequests.byTime.push(nonHttpRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 0);
});
it('shouldn\'t suggest preconnect when request has been fired after 15s', () => {
const aboveThresholdRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
// sendStartTime is way above the threshold (15000ms)
aboveThresholdRequest.args.data.syntheticData.sendStartTime = Trace.Types.Timing.Micro(20_000_000);
mockParsedTrace.NetworkRequests.byTime.push(aboveThresholdRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 0);
});
it('shouldn\'t suggest preconnect when requests are not in LCP graph', () => {
const notInLCPRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
notInLCPRequest.args.data.url = 'https://not-in-lcp.com/some-resource';
mockParsedTrace.NetworkRequests.byTime.push(notInLCPRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 0);
});
it('should only list an origin once', () => {
const firstRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
firstRequest.args.data.url = 'https://example.com/first.js';
mockParsedTrace.NetworkRequests.byTime.push(firstRequest);
const secondRequest: Trace.Types.Events.SyntheticNetworkRequest = JSON.parse(JSON.stringify(validRequest));
secondRequest.args.data.url = 'https://example.com/second.js';
mockParsedTrace.NetworkRequests.byTime.push(secondRequest);
const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(
mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
assert.lengthOf(preconnectCandidates, 1);
assert.strictEqual(preconnectCandidates[0].origin, 'https://example.com');
// First request has a wasted time of 101 ms, while second request has 51 ms.
// So the final waste time will be the longer one: 101 ms.
assert.strictEqual(preconnectCandidates[0].wastedMs, 101);
});
});