browsertime 27.5.0 → 28.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -2
- package/bin/browsertime.js +28 -4
- package/browserscripts/pageinfo/loaf.js +15 -0
- package/browserscripts/pageinfo/loafSummary.js +24 -0
- package/lib/chrome/coverage.js +89 -19
- package/lib/chrome/mediawikiResourceLoader.js +225 -0
- package/lib/chrome/parseCpuTrace.js +87 -2
- package/lib/chrome/trace/blocking-time.js +189 -0
- package/lib/chrome/trace/domain-breakdown.js +86 -0
- package/lib/chrome/trace/frame-stability.js +135 -0
- package/lib/chrome/trace/function-costs.js +281 -0
- package/lib/chrome/trace/index.js +7 -0
- package/lib/chrome/trace/module-costs.js +137 -0
- package/lib/chrome/trace/non-composited-animations.js +50 -5
- package/lib/chrome/trace/selector-stats.js +110 -0
- package/lib/chrome/trace/style-invalidations.js +162 -0
- package/lib/chrome/trace/task-groups.js +28 -2
- package/lib/chrome/trace/timer-costs.js +183 -0
- package/lib/chrome/webdriver/chromium.js +128 -0
- package/lib/chrome/webdriver/traceUtilities.js +11 -1
- package/lib/core/engine/collector.js +12 -0
- package/lib/core/engine/index.js +6 -0
- package/lib/support/cli.js +1 -1
- package/lib/support/har/index.js +44 -1
- package/package.json +4 -4
- package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
- package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
- package/types/chrome/parseCpuTrace.d.ts +1 -26
- package/types/chrome/parseCpuTrace.d.ts.map +1 -1
- package/types/chrome/trace/blocking-time.d.ts +7 -0
- package/types/chrome/trace/blocking-time.d.ts.map +1 -0
- package/types/chrome/trace/domain-breakdown.d.ts +11 -0
- package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
- package/types/chrome/trace/frame-stability.d.ts +11 -0
- package/types/chrome/trace/frame-stability.d.ts.map +1 -0
- package/types/chrome/trace/function-costs.d.ts +6 -0
- package/types/chrome/trace/function-costs.d.ts.map +1 -0
- package/types/chrome/trace/index.d.ts +7 -0
- package/types/chrome/trace/index.d.ts.map +1 -1
- package/types/chrome/trace/module-costs.d.ts +20 -0
- package/types/chrome/trace/module-costs.d.ts.map +1 -0
- package/types/chrome/trace/non-composited-animations.d.ts +1 -25
- package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
- package/types/chrome/trace/selector-stats.d.ts +8 -0
- package/types/chrome/trace/selector-stats.d.ts.map +1 -0
- package/types/chrome/trace/style-invalidations.d.ts +9 -0
- package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
- package/types/chrome/trace/task-groups.d.ts.map +1 -1
- package/types/chrome/trace/timer-costs.d.ts +10 -0
- package/types/chrome/trace/timer-costs.d.ts.map +1 -0
- package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style/layout invalidation attribution, from the
|
|
3
|
+
* devtools.timeline.invalidationTracking events browsertime has
|
|
4
|
+
* always collected with --chrome.timeline but never parsed. The
|
|
5
|
+
* recalculate-style and layout totals say HOW MUCH invalidation work
|
|
6
|
+
* happened; these events say WHY and WHO:
|
|
7
|
+
*
|
|
8
|
+
* - StyleRecalcInvalidationTracking: one event per invalidated node
|
|
9
|
+
* with the reason ("Style rule change", "Node was inserted into
|
|
10
|
+
* tree", "Affected by :has()", …) and often the JS stack that
|
|
11
|
+
* caused it.
|
|
12
|
+
* - ScheduleStyleInvalidationTracking: the DOM change that scheduled
|
|
13
|
+
* an invalidation — which class/attribute/id/pseudo changed on
|
|
14
|
+
* which node.
|
|
15
|
+
* - LayoutInvalidationTracking: one event per node whose layout was
|
|
16
|
+
* invalidated, with reason and often a stack.
|
|
17
|
+
*
|
|
18
|
+
* Aggregated three ways: reason counts (what kind of churn),
|
|
19
|
+
* trigger counts (which class/attribute names keep changing) and
|
|
20
|
+
* source counts (which script did it, from the innermost stack frame
|
|
21
|
+
* with a URL). One noisy script toggling a class on N nodes shows up
|
|
22
|
+
* as N invalidations from that URL with that class as the trigger —
|
|
23
|
+
* exactly the row a developer can act on.
|
|
24
|
+
*
|
|
25
|
+
* Counts, not milliseconds: the events are instant markers, the time
|
|
26
|
+
* they cause is already in cpu.categories.styleLayout and the
|
|
27
|
+
* recalculateStyle summaries.
|
|
28
|
+
*
|
|
29
|
+
* When the optional `bundles` map (url → location resolver from the
|
|
30
|
+
* coverage path) is passed, source frames inside concatenated
|
|
31
|
+
* bundles resolve to the owning module — thousands of invalidations
|
|
32
|
+
* from one giant load.php URL split into per-module rows.
|
|
33
|
+
*
|
|
34
|
+
* Returns undefined when the trace has no invalidation events.
|
|
35
|
+
* Otherwise:
|
|
36
|
+
* { styleRecalcs, layoutInvalidations,
|
|
37
|
+
* recalcReasons: [{ reason, count }, …],
|
|
38
|
+
* layoutReasons: [{ reason, count }, …],
|
|
39
|
+
* triggers: [{ kind, name, count }, …],
|
|
40
|
+
* sources: [{ url, module?, count }, …] }
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const MAX_REASONS = 10;
|
|
44
|
+
const MAX_TRIGGERS = 15;
|
|
45
|
+
const MAX_SOURCES = 10;
|
|
46
|
+
|
|
47
|
+
function increment(map, key) {
|
|
48
|
+
if (!key) return;
|
|
49
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Source identity for an invalidation: the innermost stack frame
|
|
53
|
+
// with a URL, resolved to the owning module when the frame lands in
|
|
54
|
+
// a known concatenated bundle (trace stack-frame positions are
|
|
55
|
+
// 1-based, matching the resolvers). Without bundles the identity is
|
|
56
|
+
// just the URL — the pre-existing behaviour.
|
|
57
|
+
function sourceEntryFor(sources, data, bundles) {
|
|
58
|
+
for (const frame of data.stackTrace || []) {
|
|
59
|
+
if (!frame.url) continue;
|
|
60
|
+
let module_;
|
|
61
|
+
if (bundles && frame.lineNumber !== undefined) {
|
|
62
|
+
const resolver = bundles.get(frame.url);
|
|
63
|
+
if (resolver) {
|
|
64
|
+
module_ = resolver.resolve(frame.lineNumber, frame.columnNumber);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const key = module_ ? `${frame.url}|${module_.name}` : frame.url;
|
|
68
|
+
let entry = sources.get(key);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
entry = { url: frame.url, count: 0 };
|
|
71
|
+
if (module_) entry.module = module_.name;
|
|
72
|
+
sources.set(key, entry);
|
|
73
|
+
}
|
|
74
|
+
entry.count++;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function topEntries(map, limit, publish) {
|
|
80
|
+
return [...map.entries()]
|
|
81
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
82
|
+
.slice(0, limit)
|
|
83
|
+
.map(entry => publish(entry));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function computeStyleInvalidations(trace, bundles) {
|
|
87
|
+
const recalcReasons = new Map();
|
|
88
|
+
const layoutReasons = new Map();
|
|
89
|
+
const triggers = new Map();
|
|
90
|
+
const sources = new Map();
|
|
91
|
+
let styleRecalcs = 0;
|
|
92
|
+
let layoutInvalidations = 0;
|
|
93
|
+
let sawInvalidations = false;
|
|
94
|
+
|
|
95
|
+
for (const event of trace.traceEvents) {
|
|
96
|
+
const data = (event.args && event.args.data) || {};
|
|
97
|
+
switch (event.name) {
|
|
98
|
+
case 'StyleRecalcInvalidationTracking': {
|
|
99
|
+
sawInvalidations = true;
|
|
100
|
+
styleRecalcs++;
|
|
101
|
+
increment(recalcReasons, data.reason);
|
|
102
|
+
sourceEntryFor(sources, data, bundles);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'LayoutInvalidationTracking': {
|
|
106
|
+
sawInvalidations = true;
|
|
107
|
+
layoutInvalidations++;
|
|
108
|
+
increment(layoutReasons, data.reason);
|
|
109
|
+
sourceEntryFor(sources, data, bundles);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'ScheduleStyleInvalidationTracking': {
|
|
113
|
+
sawInvalidations = true;
|
|
114
|
+
if (data.changedClass) {
|
|
115
|
+
increment(triggers, `class|${data.changedClass}`);
|
|
116
|
+
}
|
|
117
|
+
if (data.changedAttribute) {
|
|
118
|
+
increment(triggers, `attribute|${data.changedAttribute}`);
|
|
119
|
+
}
|
|
120
|
+
if (data.changedId) {
|
|
121
|
+
increment(triggers, `id|${data.changedId}`);
|
|
122
|
+
}
|
|
123
|
+
if (data.changedPseudo) {
|
|
124
|
+
increment(triggers, `pseudo|${data.changedPseudo}`);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
default: {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!sawInvalidations) return;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
styleRecalcs,
|
|
137
|
+
layoutInvalidations,
|
|
138
|
+
recalcReasons: topEntries(
|
|
139
|
+
recalcReasons,
|
|
140
|
+
MAX_REASONS,
|
|
141
|
+
([reason, count]) => ({
|
|
142
|
+
reason,
|
|
143
|
+
count
|
|
144
|
+
})
|
|
145
|
+
),
|
|
146
|
+
layoutReasons: topEntries(
|
|
147
|
+
layoutReasons,
|
|
148
|
+
MAX_REASONS,
|
|
149
|
+
([reason, count]) => ({
|
|
150
|
+
reason,
|
|
151
|
+
count
|
|
152
|
+
})
|
|
153
|
+
),
|
|
154
|
+
triggers: topEntries(triggers, MAX_TRIGGERS, ([key, count]) => {
|
|
155
|
+
const [kind, ...name] = key.split('|');
|
|
156
|
+
return { kind, name: name.join('|'), count };
|
|
157
|
+
}),
|
|
158
|
+
sources: [...sources.values()]
|
|
159
|
+
.toSorted((a, b) => b.count - a.count)
|
|
160
|
+
.slice(0, MAX_SOURCES)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -43,7 +43,26 @@ export const taskGroups = {
|
|
|
43
43
|
'ResourceReceiveResponse',
|
|
44
44
|
'ResourceReceivedData',
|
|
45
45
|
'ResourceReceivedResponse',
|
|
46
|
-
'ResourceFinish'
|
|
46
|
+
'ResourceFinish',
|
|
47
|
+
'ResourceChangePriority',
|
|
48
|
+
// Document/navigation body loading internals (sampled from real
|
|
49
|
+
// Wikipedia traces, July 2026) — the main-thread side of
|
|
50
|
+
// streaming the document into the parser. Same rationale as the
|
|
51
|
+
// resource lifecycle events above.
|
|
52
|
+
'DocumentLoader::DocumentLoader',
|
|
53
|
+
'DocumentLoader::HandleData',
|
|
54
|
+
'DocumentLoader::CommitData',
|
|
55
|
+
'DocumentLoader::BodyDataReceivedImpl',
|
|
56
|
+
'DocumentLoader::BodyLoadingFinished',
|
|
57
|
+
'DocumentLoader::FinishedLoading',
|
|
58
|
+
'NavigationBodyLoader::OnReadable',
|
|
59
|
+
'NavigationBodyLoader::ReadFromDataPipe',
|
|
60
|
+
'URLLoader::Context::OnReceivedResponse',
|
|
61
|
+
'URLLoader::Context::OnCompletedRequest',
|
|
62
|
+
'URLLoader::Context::Cancel',
|
|
63
|
+
'ThrottlingURLLoader::OnReceiveResponse',
|
|
64
|
+
'ResourceRequestSender::OnReceivedResponse',
|
|
65
|
+
'ResourceRequestSender::OnRequestComplete'
|
|
47
66
|
]
|
|
48
67
|
},
|
|
49
68
|
styleLayout: {
|
|
@@ -129,7 +148,14 @@ export const taskGroups = {
|
|
|
129
148
|
'v8.callFunction',
|
|
130
149
|
'v8.callModuleMethod',
|
|
131
150
|
'XHRLoad',
|
|
132
|
-
'XHRReadyStateChange'
|
|
151
|
+
'XHRReadyStateChange',
|
|
152
|
+
// V8 housekeeping that only runs because JS is executing —
|
|
153
|
+
// interrupt handling, stack guards, constructor instantiation.
|
|
154
|
+
'v8.newInstance',
|
|
155
|
+
'V8.InvokeApiInterruptCallbacks',
|
|
156
|
+
'V8.HandleInterrupts',
|
|
157
|
+
'V8.BytecodeBudgetInterrupt',
|
|
158
|
+
'V8.StackGuard'
|
|
133
159
|
]
|
|
134
160
|
},
|
|
135
161
|
garbageCollection: {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timer pressure per script — how much setTimeout/setInterval churn
|
|
3
|
+
* a page runs, from trace events already collected with
|
|
4
|
+
* --chrome.timeline.
|
|
5
|
+
*
|
|
6
|
+
* Two sides are joined per URL:
|
|
7
|
+
* - TimerInstall events (instant): who schedules timers, how many,
|
|
8
|
+
* how many with timeout 0 (the "yield" pattern that turns into
|
|
9
|
+
* main-thread machine-gunning) and how many recurring
|
|
10
|
+
* (setInterval / singleShot: false). Attribution from the
|
|
11
|
+
* innermost stack frame with a URL.
|
|
12
|
+
* - TimerFire tasks from the main-thread task tree: what running
|
|
13
|
+
* those timers actually cost. main-thread-tasks.js already chains
|
|
14
|
+
* TimerFire back to the installing script's URLs, so the cost
|
|
15
|
+
* lands on the script that scheduled the timer even when the
|
|
16
|
+
* callback is anonymous.
|
|
17
|
+
*
|
|
18
|
+
* Polling loops show up as a high install/fire count with recurring
|
|
19
|
+
* installs on one URL; oversliced work shows up as hundreds of
|
|
20
|
+
* timeout-0 installs. Fire time is inclusive task duration — what
|
|
21
|
+
* the main thread was occupied with when the timer ran.
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined when the trace has no timer events. Otherwise:
|
|
24
|
+
* { installs, fires, fireTime, timeoutZeroInstalls,
|
|
25
|
+
* recurringInstalls,
|
|
26
|
+
* byUrl: [{ url, installs, fires, fireTime,
|
|
27
|
+
* timeoutZeroInstalls, recurringInstalls }, …],
|
|
28
|
+
* sites: [{ url, line?, column?, functionName?, timeout,
|
|
29
|
+
* recurring, installs, fires, fireTime }, …] }
|
|
30
|
+
* fireTime in ms with one decimal; byUrl sorted by fireTime desc,
|
|
31
|
+
* capped at 10 entries with sub-0.1 ms/zero-install rows dropped.
|
|
32
|
+
* `sites` lists the individual timers grouped by install site
|
|
33
|
+
* (installer source position + timeout + kind) — the closest thing
|
|
34
|
+
* to a timer's identity the trace offers — top 10 by fire time.
|
|
35
|
+
* Positions are 1-based (trace stack-frame convention).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { compute } from './main-thread-tasks.js';
|
|
39
|
+
|
|
40
|
+
const MAX_URLS = 10;
|
|
41
|
+
const MAX_SITES = 10;
|
|
42
|
+
const UNKNOWN = 'unknown';
|
|
43
|
+
|
|
44
|
+
function round(ms) {
|
|
45
|
+
return Math.round(ms * 10) / 10;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function installFrame(data) {
|
|
49
|
+
for (const frame of data.stackTrace || []) {
|
|
50
|
+
if (frame.url) return frame;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function computeTimerCosts(trace) {
|
|
55
|
+
const byUrl = new Map();
|
|
56
|
+
// Individual timers grouped by install site (installer source
|
|
57
|
+
// position + timeout + kind): a polling loop reinstalling from the
|
|
58
|
+
// same line collapses into one row with its install/fire counts.
|
|
59
|
+
const bySite = new Map();
|
|
60
|
+
const siteForTimerId = new Map();
|
|
61
|
+
let installs = 0;
|
|
62
|
+
let fires = 0;
|
|
63
|
+
let fireTimeMs = 0;
|
|
64
|
+
let timeoutZeroInstalls = 0;
|
|
65
|
+
let recurringInstalls = 0;
|
|
66
|
+
|
|
67
|
+
function entryFor(url) {
|
|
68
|
+
let entry = byUrl.get(url);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
entry = {
|
|
71
|
+
url,
|
|
72
|
+
installs: 0,
|
|
73
|
+
fires: 0,
|
|
74
|
+
fireTimeMs: 0,
|
|
75
|
+
timeoutZeroInstalls: 0,
|
|
76
|
+
recurringInstalls: 0
|
|
77
|
+
};
|
|
78
|
+
byUrl.set(url, entry);
|
|
79
|
+
}
|
|
80
|
+
return entry;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const event of trace.traceEvents) {
|
|
84
|
+
if (event.name !== 'TimerInstall') continue;
|
|
85
|
+
const data = (event.args && event.args.data) || {};
|
|
86
|
+
const frame = installFrame(data);
|
|
87
|
+
const entry = entryFor(frame ? frame.url : UNKNOWN);
|
|
88
|
+
installs++;
|
|
89
|
+
entry.installs++;
|
|
90
|
+
const timeout = data.timeout || 0;
|
|
91
|
+
if (timeout === 0) {
|
|
92
|
+
timeoutZeroInstalls++;
|
|
93
|
+
entry.timeoutZeroInstalls++;
|
|
94
|
+
}
|
|
95
|
+
const recurring = data.singleShot === false;
|
|
96
|
+
if (recurring) {
|
|
97
|
+
recurringInstalls++;
|
|
98
|
+
entry.recurringInstalls++;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const siteKey = frame
|
|
102
|
+
? `${frame.url}|${frame.lineNumber}|${frame.columnNumber}|${timeout}|${recurring}`
|
|
103
|
+
: `${UNKNOWN}|${timeout}|${recurring}`;
|
|
104
|
+
let site = bySite.get(siteKey);
|
|
105
|
+
if (!site) {
|
|
106
|
+
site = {
|
|
107
|
+
url: frame ? frame.url : UNKNOWN,
|
|
108
|
+
timeout,
|
|
109
|
+
recurring,
|
|
110
|
+
installs: 0,
|
|
111
|
+
fires: 0,
|
|
112
|
+
fireTimeMs: 0
|
|
113
|
+
};
|
|
114
|
+
if (frame) {
|
|
115
|
+
site.line = frame.lineNumber;
|
|
116
|
+
site.column = frame.columnNumber;
|
|
117
|
+
if (frame.functionName) site.functionName = frame.functionName;
|
|
118
|
+
}
|
|
119
|
+
bySite.set(siteKey, site);
|
|
120
|
+
}
|
|
121
|
+
site.installs++;
|
|
122
|
+
if (data.timerId !== undefined) siteForTimerId.set(data.timerId, site);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tasks = compute(trace);
|
|
126
|
+
for (const task of tasks) {
|
|
127
|
+
if (task.event.name !== 'TimerFire') continue;
|
|
128
|
+
const entry = entryFor(task.attributableURLs.at(-1) || UNKNOWN);
|
|
129
|
+
fires++;
|
|
130
|
+
fireTimeMs += task.duration;
|
|
131
|
+
entry.fires++;
|
|
132
|
+
entry.fireTimeMs += task.duration;
|
|
133
|
+
const fireData = (task.event.args && task.event.args.data) || {};
|
|
134
|
+
const site = siteForTimerId.get(fireData.timerId);
|
|
135
|
+
if (site) {
|
|
136
|
+
site.fires++;
|
|
137
|
+
site.fireTimeMs += task.duration;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (installs === 0 && fires === 0) return;
|
|
142
|
+
|
|
143
|
+
const urls = [...byUrl.values()]
|
|
144
|
+
.filter(entry => entry.fireTimeMs >= 0.1 || entry.installs > 0)
|
|
145
|
+
.toSorted((a, b) => b.fireTimeMs - a.fireTimeMs || b.installs - a.installs)
|
|
146
|
+
.slice(0, MAX_URLS)
|
|
147
|
+
.map(entry => ({
|
|
148
|
+
url: entry.url,
|
|
149
|
+
installs: entry.installs,
|
|
150
|
+
fires: entry.fires,
|
|
151
|
+
fireTime: round(entry.fireTimeMs),
|
|
152
|
+
timeoutZeroInstalls: entry.timeoutZeroInstalls,
|
|
153
|
+
recurringInstalls: entry.recurringInstalls
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
const sites = [...bySite.values()]
|
|
157
|
+
.toSorted((a, b) => b.fireTimeMs - a.fireTimeMs || b.installs - a.installs)
|
|
158
|
+
.slice(0, MAX_SITES)
|
|
159
|
+
.map(site => {
|
|
160
|
+
const published = {
|
|
161
|
+
url: site.url,
|
|
162
|
+
timeout: site.timeout,
|
|
163
|
+
recurring: site.recurring,
|
|
164
|
+
installs: site.installs,
|
|
165
|
+
fires: site.fires,
|
|
166
|
+
fireTime: round(site.fireTimeMs)
|
|
167
|
+
};
|
|
168
|
+
if (site.line !== undefined) published.line = site.line;
|
|
169
|
+
if (site.column !== undefined) published.column = site.column;
|
|
170
|
+
if (site.functionName) published.functionName = site.functionName;
|
|
171
|
+
return published;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
installs,
|
|
176
|
+
fires,
|
|
177
|
+
fireTime: round(fireTimeMs),
|
|
178
|
+
timeoutZeroInstalls,
|
|
179
|
+
recurringInstalls,
|
|
180
|
+
byUrl: urls,
|
|
181
|
+
sites
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -7,6 +7,10 @@ const log = getLogger('browsertime.chrome');
|
|
|
7
7
|
const { Type } = logging;
|
|
8
8
|
import { longTaskMetrics } from '../longTaskMetrics.js';
|
|
9
9
|
import { parseCPUTrace } from '../parseCpuTrace.js';
|
|
10
|
+
import { computeModuleCosts } from '../trace/module-costs.js';
|
|
11
|
+
import { computeFunctionCosts } from '../trace/function-costs.js';
|
|
12
|
+
import { computeStyleInvalidations } from '../trace/style-invalidations.js';
|
|
13
|
+
import { labelForUrl } from '../mediawikiResourceLoader.js';
|
|
10
14
|
import { getHar } from '../har.js';
|
|
11
15
|
import { getEmptyHAR, mergeHars } from '../../support/har/index.js';
|
|
12
16
|
import { toArray } from '../../support/util.js';
|
|
@@ -335,6 +339,12 @@ export class Chromium {
|
|
|
335
339
|
const coverage = await this.coverage.collect();
|
|
336
340
|
this.coverage = undefined;
|
|
337
341
|
if (coverage) {
|
|
342
|
+
// Bundle location resolvers are plumbing for the CPU trace
|
|
343
|
+
// parser (per-module CPU attribution), not part of the
|
|
344
|
+
// coverage result contract — lift them off before the
|
|
345
|
+
// coverage object is serialized.
|
|
346
|
+
this.resourceLoaderBundles = coverage.resourceLoaderBundles;
|
|
347
|
+
delete coverage.resourceLoaderBundles;
|
|
338
348
|
result.coverage = coverage;
|
|
339
349
|
}
|
|
340
350
|
}
|
|
@@ -385,6 +395,20 @@ export class Chromium {
|
|
|
385
395
|
// this hack fixes that
|
|
386
396
|
if (result.browserScripts && result.browserScripts.pageinfo) {
|
|
387
397
|
result.browserScripts.pageinfo.longTask = this.longTaskInfo;
|
|
398
|
+
|
|
399
|
+
// Resolve LoAF script URLs to ResourceLoader module labels the
|
|
400
|
+
// same way cpu.urls gets them — several frames all blaming one
|
|
401
|
+
// giant load.php URL say nothing without the module name.
|
|
402
|
+
if (Array.isArray(result.browserScripts.pageinfo.loaf)) {
|
|
403
|
+
for (const frame of result.browserScripts.pageinfo.loaf) {
|
|
404
|
+
for (const script of frame.scripts || []) {
|
|
405
|
+
const label = labelForUrl(script.sourceURL);
|
|
406
|
+
if (label) {
|
|
407
|
+
script.label = label;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
388
412
|
}
|
|
389
413
|
|
|
390
414
|
if (this.chrome.collectNetLog && this.chrome.android) {
|
|
@@ -416,6 +440,110 @@ export class Chromium {
|
|
|
416
440
|
result.extraJson[name] = trace;
|
|
417
441
|
|
|
418
442
|
const cpu = await parseCPUTrace(trace, result.url);
|
|
443
|
+
|
|
444
|
+
// Per-module CPU inside concatenated bundles (MediaWiki
|
|
445
|
+
// ResourceLoader). The only analysis that needs both the trace
|
|
446
|
+
// and the bundle boundaries from the coverage path, so it runs
|
|
447
|
+
// here where the two meet — only when coverage found bundles on
|
|
448
|
+
// the page, otherwise zero cost and no output key.
|
|
449
|
+
if (this.resourceLoaderBundles && this.resourceLoaderBundles.size > 0) {
|
|
450
|
+
try {
|
|
451
|
+
const moduleCosts = computeModuleCosts(
|
|
452
|
+
trace,
|
|
453
|
+
this.resourceLoaderBundles
|
|
454
|
+
);
|
|
455
|
+
if (moduleCosts.length > 0) {
|
|
456
|
+
for (const bundle of moduleCosts) {
|
|
457
|
+
const label = labelForUrl(bundle.url);
|
|
458
|
+
if (label) {
|
|
459
|
+
bundle.label = label;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
cpu.moduleCosts = moduleCosts;
|
|
463
|
+
}
|
|
464
|
+
} catch (error) {
|
|
465
|
+
log.debug('computeModuleCosts failed: %s', error.message);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Per-function self/total time from the V8 sampling profiler —
|
|
469
|
+
// only produces output on runs with the cpu_profiler trace
|
|
470
|
+
// category (--enableProfileRun). Reuses the bundle resolvers so
|
|
471
|
+
// functions inside concatenated bundles get a module name.
|
|
472
|
+
try {
|
|
473
|
+
const functionCosts = computeFunctionCosts(
|
|
474
|
+
trace,
|
|
475
|
+
this.resourceLoaderBundles
|
|
476
|
+
);
|
|
477
|
+
if (functionCosts.length > 0) {
|
|
478
|
+
for (const functionCost of functionCosts) {
|
|
479
|
+
const label = labelForUrl(functionCost.url);
|
|
480
|
+
if (label) {
|
|
481
|
+
functionCost.label = label;
|
|
482
|
+
}
|
|
483
|
+
for (const callee of functionCost.callees || []) {
|
|
484
|
+
const calleeLabel = labelForUrl(callee.url);
|
|
485
|
+
if (calleeLabel) {
|
|
486
|
+
callee.label = calleeLabel;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
cpu.functionCosts = functionCosts;
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
log.debug('computeFunctionCosts failed: %s', error.message);
|
|
494
|
+
}
|
|
495
|
+
// Invalidation sources resolve to modules the same way —
|
|
496
|
+
// thousands of invalidations from one giant load.php URL say
|
|
497
|
+
// nothing, the per-module split says whose code churns the
|
|
498
|
+
// DOM. Recomputing with the bundles replaces the unresolved
|
|
499
|
+
// sources parseCpuTrace produced.
|
|
500
|
+
if (
|
|
501
|
+
cpu.styleInvalidations &&
|
|
502
|
+
this.resourceLoaderBundles &&
|
|
503
|
+
this.resourceLoaderBundles.size > 0
|
|
504
|
+
) {
|
|
505
|
+
try {
|
|
506
|
+
const enriched = computeStyleInvalidations(
|
|
507
|
+
trace,
|
|
508
|
+
this.resourceLoaderBundles
|
|
509
|
+
);
|
|
510
|
+
if (enriched) {
|
|
511
|
+
for (const source of enriched.sources) {
|
|
512
|
+
const label = labelForUrl(source.url);
|
|
513
|
+
if (label) {
|
|
514
|
+
source.label = label;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
cpu.styleInvalidations = enriched;
|
|
518
|
+
}
|
|
519
|
+
} catch (error) {
|
|
520
|
+
log.debug(
|
|
521
|
+
'computeStyleInvalidations with bundles failed: %s',
|
|
522
|
+
error.message
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// Timer install sites inside concatenated bundles resolve to
|
|
527
|
+
// the owning module — "load.php[scripts]:418" says nothing,
|
|
528
|
+
// the module name says whose timer it is. Trace stack-frame
|
|
529
|
+
// positions are 1-based, matching the resolvers.
|
|
530
|
+
if (
|
|
531
|
+
cpu.timers &&
|
|
532
|
+
cpu.timers.sites &&
|
|
533
|
+
this.resourceLoaderBundles &&
|
|
534
|
+
this.resourceLoaderBundles.size > 0
|
|
535
|
+
) {
|
|
536
|
+
for (const site of cpu.timers.sites) {
|
|
537
|
+
if (site.line === undefined) continue;
|
|
538
|
+
const resolver = this.resourceLoaderBundles.get(site.url);
|
|
539
|
+
if (!resolver) continue;
|
|
540
|
+
const module_ = resolver.resolve(site.line, site.column);
|
|
541
|
+
if (module_) {
|
|
542
|
+
site.module = module_.name;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
this.resourceLoaderBundles = undefined;
|
|
419
547
|
result.cpu = cpu;
|
|
420
548
|
|
|
421
549
|
// Collect render blocking info
|
|
@@ -52,12 +52,22 @@ function getRecalculateStyleElementsAndTimeBefore(traceEvents, timestamp) {
|
|
|
52
52
|
);
|
|
53
53
|
let elements = 0;
|
|
54
54
|
let duration = 0;
|
|
55
|
+
let maxElements = 0;
|
|
56
|
+
let maxDuration = 0;
|
|
55
57
|
for (let a of updateLayoutTree) {
|
|
56
58
|
elements += a.args.elementCount;
|
|
57
59
|
duration += a.dur;
|
|
60
|
+
maxElements = Math.max(maxElements, a.args.elementCount);
|
|
61
|
+
maxDuration = Math.max(maxDuration, a.dur);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
count: updateLayoutTree.length,
|
|
66
|
+
elements,
|
|
67
|
+
durationInMillis: duration / 1000,
|
|
68
|
+
maxElements,
|
|
69
|
+
maxDurationInMillis: maxDuration / 1000
|
|
70
|
+
};
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export async function getRenderBlocking(trace) {
|
|
@@ -136,6 +136,18 @@ export class Collector {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Header values are strings so they are kept out of the statistics.
|
|
140
|
+
addMainDocument(url, mainDocument) {
|
|
141
|
+
const results =
|
|
142
|
+
this.allResults[url] || this.allResults[this.urlAndActualUrl[url]];
|
|
143
|
+
if (results) {
|
|
144
|
+
if (!results.mainDocument) {
|
|
145
|
+
results.mainDocument = [];
|
|
146
|
+
}
|
|
147
|
+
results.mainDocument.push(mainDocument);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
139
151
|
/**
|
|
140
152
|
* Collect all individual runs, add it to the statistics, and store
|
|
141
153
|
* data that needs to be on disk (trace logs, sceenshots etc).
|
package/lib/core/engine/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import {
|
|
19
19
|
getFullyLoaded,
|
|
20
20
|
getMainDocumentTimings,
|
|
21
|
+
getMainDocuments,
|
|
21
22
|
addExtraFieldsToHar
|
|
22
23
|
} from '../../support/har/index.js';
|
|
23
24
|
import { XVFB } from '../../support/xvfb.js';
|
|
@@ -374,6 +375,11 @@ export class Engine {
|
|
|
374
375
|
for (let timing of timings) {
|
|
375
376
|
collector.addMainDocumentTimings(timing.url, timing.timings);
|
|
376
377
|
}
|
|
378
|
+
|
|
379
|
+
const mainDocuments = getMainDocuments(extras.har);
|
|
380
|
+
for (let document of mainDocuments) {
|
|
381
|
+
collector.addMainDocument(document.url, document.mainDocument);
|
|
382
|
+
}
|
|
377
383
|
}
|
|
378
384
|
}
|
|
379
385
|
|
package/lib/support/cli.js
CHANGED
|
@@ -51,7 +51,7 @@ function hasbin(bin) {
|
|
|
51
51
|
function validateInput(argv) {
|
|
52
52
|
// Check NodeJS major version
|
|
53
53
|
const fullVersion = process.versions.node;
|
|
54
|
-
const minVersion =
|
|
54
|
+
const minVersion = 22;
|
|
55
55
|
const majorVersion = fullVersion.split('.')[0];
|
|
56
56
|
if (majorVersion < minVersion) {
|
|
57
57
|
return (
|
package/lib/support/har/index.js
CHANGED
|
@@ -269,6 +269,45 @@ export function getMainDocumentTimings(har) {
|
|
|
269
269
|
return [];
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
+
export function getMainDocuments(har) {
|
|
273
|
+
// Sometimes the HAR is dirty
|
|
274
|
+
try {
|
|
275
|
+
const mainDocuments = [];
|
|
276
|
+
for (let page of har.log.pages) {
|
|
277
|
+
const url = page._url;
|
|
278
|
+
if (url === undefined) continue;
|
|
279
|
+
|
|
280
|
+
const requests = getDocumentRequests(har.log.entries, page.id);
|
|
281
|
+
const finalEntry = requests.at(-1);
|
|
282
|
+
if (!finalEntry) continue;
|
|
283
|
+
|
|
284
|
+
const headers = {};
|
|
285
|
+
for (let header of finalEntry.response.headers || []) {
|
|
286
|
+
const name = header.name.toLowerCase();
|
|
287
|
+
// Always drop sensitive headers (the same list as
|
|
288
|
+
// --cleanSensitiveHeaders uses) since browsertime.json often ends
|
|
289
|
+
// up in dashboards even when the HAR stays local.
|
|
290
|
+
if (isSensitiveHeader(name)) continue;
|
|
291
|
+
headers[name] =
|
|
292
|
+
name in headers ? headers[name] + ', ' + header.value : header.value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
mainDocuments.push({
|
|
296
|
+
url,
|
|
297
|
+
mainDocument: {
|
|
298
|
+
url: finalEntry.request.url,
|
|
299
|
+
status: finalEntry.response.status,
|
|
300
|
+
redirects: requests.length - 1,
|
|
301
|
+
headers
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return mainDocuments;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
log.error('Could not get main document headers ' + error);
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
272
311
|
export function getFullyLoaded(har) {
|
|
273
312
|
const fullyLoaded = [];
|
|
274
313
|
const entries = [...har.log.entries];
|
|
@@ -464,8 +503,12 @@ export function addExtraFieldsToHar(totalResults, har, options) {
|
|
|
464
503
|
}
|
|
465
504
|
}
|
|
466
505
|
|
|
506
|
+
export function isSensitiveHeader(name) {
|
|
507
|
+
return sensitiveHeaders.has(name.toLowerCase());
|
|
508
|
+
}
|
|
509
|
+
|
|
467
510
|
export function cleanSensitiveHeaders(name, value) {
|
|
468
|
-
if (
|
|
511
|
+
if (isSensitiveHeader(name)) {
|
|
469
512
|
return '[REMOVED]';
|
|
470
513
|
}
|
|
471
514
|
return value;
|