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.
Files changed (51) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/bin/browsertime.js +28 -4
  3. package/browserscripts/pageinfo/loaf.js +15 -0
  4. package/browserscripts/pageinfo/loafSummary.js +24 -0
  5. package/lib/chrome/coverage.js +89 -19
  6. package/lib/chrome/mediawikiResourceLoader.js +225 -0
  7. package/lib/chrome/parseCpuTrace.js +87 -2
  8. package/lib/chrome/trace/blocking-time.js +189 -0
  9. package/lib/chrome/trace/domain-breakdown.js +86 -0
  10. package/lib/chrome/trace/frame-stability.js +135 -0
  11. package/lib/chrome/trace/function-costs.js +281 -0
  12. package/lib/chrome/trace/index.js +7 -0
  13. package/lib/chrome/trace/module-costs.js +137 -0
  14. package/lib/chrome/trace/non-composited-animations.js +50 -5
  15. package/lib/chrome/trace/selector-stats.js +110 -0
  16. package/lib/chrome/trace/style-invalidations.js +162 -0
  17. package/lib/chrome/trace/task-groups.js +28 -2
  18. package/lib/chrome/trace/timer-costs.js +183 -0
  19. package/lib/chrome/webdriver/chromium.js +128 -0
  20. package/lib/chrome/webdriver/traceUtilities.js +11 -1
  21. package/lib/core/engine/collector.js +12 -0
  22. package/lib/core/engine/index.js +6 -0
  23. package/lib/support/cli.js +1 -1
  24. package/lib/support/har/index.js +44 -1
  25. package/package.json +4 -4
  26. package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
  27. package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
  28. package/types/chrome/parseCpuTrace.d.ts +1 -26
  29. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  30. package/types/chrome/trace/blocking-time.d.ts +7 -0
  31. package/types/chrome/trace/blocking-time.d.ts.map +1 -0
  32. package/types/chrome/trace/domain-breakdown.d.ts +11 -0
  33. package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
  34. package/types/chrome/trace/frame-stability.d.ts +11 -0
  35. package/types/chrome/trace/frame-stability.d.ts.map +1 -0
  36. package/types/chrome/trace/function-costs.d.ts +6 -0
  37. package/types/chrome/trace/function-costs.d.ts.map +1 -0
  38. package/types/chrome/trace/index.d.ts +7 -0
  39. package/types/chrome/trace/index.d.ts.map +1 -1
  40. package/types/chrome/trace/module-costs.d.ts +20 -0
  41. package/types/chrome/trace/module-costs.d.ts.map +1 -0
  42. package/types/chrome/trace/non-composited-animations.d.ts +1 -25
  43. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
  44. package/types/chrome/trace/selector-stats.d.ts +8 -0
  45. package/types/chrome/trace/selector-stats.d.ts.map +1 -0
  46. package/types/chrome/trace/style-invalidations.d.ts +9 -0
  47. package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
  48. package/types/chrome/trace/task-groups.d.ts.map +1 -1
  49. package/types/chrome/trace/timer-costs.d.ts +10 -0
  50. package/types/chrome/trace/timer-costs.d.ts.map +1 -0
  51. package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Per-function JS cost from the V8 sampling profiler
3
+ * (disabled-by-default-v8.cpu_profiler, enabled on --enableProfileRun).
4
+ * The task-based analyses (scriptCosts, moduleCosts) attribute
5
+ * main-thread time to a URL or module; the sampled stacks answer the
6
+ * next question — which function inside that script the time was
7
+ * spent in.
8
+ *
9
+ * The category emits one Profile event per profiled V8 thread and a
10
+ * stream of ProfileChunk events carrying incremental call-tree nodes,
11
+ * sampled node ids and µs deltas between samples. Only the profile
12
+ * started on the inspected tab's main thread is read: the Profile
13
+ * event is emitted on the profiled thread, while its chunks arrive on
14
+ * V8's sampling thread and are tied back via the shared profile id +
15
+ * pid.
16
+ *
17
+ * Each sample's delta is credited as self time to the sampled frame
18
+ * and as total time to every distinct frame on the sampled stack
19
+ * (recursion counted once) — the definitions DevTools' bottom-up view
20
+ * uses. Frames aggregate per function identity (name + script +
21
+ * source position), so a function called from many sites is one row.
22
+ *
23
+ * V8 meta frames ((root), (program), (idle), (garbage collector))
24
+ * carry codeType 'other' and are skipped — idle is not cost and GC is
25
+ * already in cpu.categories. Engine frames without a script URL
26
+ * (e.g. querySelectorAll) are kept: the cost is real, it just has no
27
+ * source position.
28
+ *
29
+ * When the optional `bundles` map (url → location resolver, built by
30
+ * the coverage path) knows a frame's URL, the frame's position is
31
+ * resolved to the owning module inside the concatenated bundle and
32
+ * reported as `module`. V8 call-frame positions are 0-based while the
33
+ * resolvers — and the reported line/column — are 1-based (the trace
34
+ * event / DevTools convention). Frame URLs longer than 1024 chars are
35
+ * truncated by V8; they are recovered via unique prefix match against
36
+ * the bundle map so the reported url matches cpu.urls / moduleCosts.
37
+ *
38
+ * Returns [{ functionName, url?, line?, column?, module?, selfTime,
39
+ * totalTime, callees? }, …] in ms rounded to one decimal, sorted by
40
+ * selfTime desc, noise-filtered to self time ≥ 1 ms and capped at 50
41
+ * rows. `callees` (top 5 ≥ 1 ms, [{ functionName, url?, module?,
42
+ * value }]) breaks a function's non-self time down by what it called,
43
+ * so orchestrators (loaders, dispatchers) whose total dwarfs their
44
+ * self time show what they actually ran. Empty array when the trace
45
+ * carries no profiler data (the category is only enabled on profile
46
+ * runs).
47
+ */
48
+
49
+ import { findMainFrameIds } from './tracing-processor.js';
50
+
51
+ const REPORT_LIMIT_US = 1000;
52
+ const MAX_FUNCTIONS = 50;
53
+ const CALLEE_LIMIT_US = 1000;
54
+ const MAX_CALLEES = 5;
55
+ // V8 truncates callFrame.url in the trace (observed at 1024 chars),
56
+ // so long bundle URLs (MediaWiki load.php easily exceeds it) won't
57
+ // exact-match the coverage-path bundle map or the rest of the result.
58
+ const V8_URL_TRUNCATION_LIMIT = 1024;
59
+
60
+ function round(ms) {
61
+ return Math.round(ms * 10) / 10;
62
+ }
63
+
64
+ function functionKey(callFrame) {
65
+ return `${callFrame.functionName}|${callFrame.scriptId}|${callFrame.lineNumber}|${callFrame.columnNumber}`;
66
+ }
67
+
68
+ function isMetaFrame(callFrame) {
69
+ return callFrame.codeType === 'other';
70
+ }
71
+
72
+ // Recover the full bundle URL + resolver for a (possibly truncated)
73
+ // frame URL. Exact match first; for truncation-length URLs fall back
74
+ // to a unique prefix match, skipping when ambiguous.
75
+ function findBundle(bundles, url) {
76
+ if (!bundles || !url) return;
77
+ const exact = bundles.get(url);
78
+ if (exact) return { url, resolver: exact };
79
+ if (url.length < V8_URL_TRUNCATION_LIMIT) return;
80
+ let match;
81
+ for (const [bundleUrl, resolver] of bundles) {
82
+ if (bundleUrl.startsWith(url)) {
83
+ if (match) return;
84
+ match = { url: bundleUrl, resolver };
85
+ }
86
+ }
87
+ return match;
88
+ }
89
+
90
+ export function computeFunctionCosts(trace, bundles) {
91
+ const events = trace.traceEvents;
92
+ const { pid, tid } = findMainFrameIds(events);
93
+
94
+ const profileIds = new Set();
95
+ for (const event of events) {
96
+ if (event.name === 'Profile' && event.pid === pid && event.tid === tid) {
97
+ profileIds.add(event.id);
98
+ }
99
+ }
100
+ if (profileIds.size === 0) return [];
101
+
102
+ const nodes = new Map();
103
+ const samples = [];
104
+ const timeDeltas = [];
105
+ for (const event of events) {
106
+ if (
107
+ event.name !== 'ProfileChunk' ||
108
+ event.pid !== pid ||
109
+ !profileIds.has(event.id)
110
+ ) {
111
+ continue;
112
+ }
113
+ const data = event.args && event.args.data;
114
+ if (!data) continue;
115
+ const cpuProfile = data.cpuProfile;
116
+ if (cpuProfile && cpuProfile.nodes) {
117
+ for (const node of cpuProfile.nodes) nodes.set(node.id, node);
118
+ }
119
+ if (cpuProfile && cpuProfile.samples) samples.push(...cpuProfile.samples);
120
+ if (data.timeDeltas) timeDeltas.push(...data.timeDeltas);
121
+ }
122
+ if (samples.length === 0) return [];
123
+
124
+ // Lazily-named frames can miss their url; recover it from another
125
+ // node in the same script.
126
+ const scriptUrls = new Map();
127
+ for (const node of nodes.values()) {
128
+ const callFrame = node.callFrame;
129
+ if (callFrame.url && callFrame.scriptId) {
130
+ scriptUrls.set(callFrame.scriptId, callFrame.url);
131
+ }
132
+ }
133
+
134
+ // Distinct function keys on each node's stack, for total time.
135
+ const stackKeys = new Map();
136
+ function keysForNode(nodeId) {
137
+ const cached = stackKeys.get(nodeId);
138
+ if (cached) return cached;
139
+ const node = nodes.get(nodeId);
140
+ if (!node) return new Set();
141
+ const keys = new Set(node.parent ? keysForNode(node.parent) : undefined);
142
+ if (!isMetaFrame(node.callFrame)) keys.add(functionKey(node.callFrame));
143
+ stackKeys.set(nodeId, keys);
144
+ return keys;
145
+ }
146
+
147
+ const functions = new Map();
148
+ const nodesByKey = new Map();
149
+ const childNodes = new Map();
150
+ for (const node of nodes.values()) {
151
+ if (node.parent !== undefined) {
152
+ let siblings = childNodes.get(node.parent);
153
+ if (!siblings) {
154
+ siblings = [];
155
+ childNodes.set(node.parent, siblings);
156
+ }
157
+ siblings.push(node);
158
+ }
159
+ const callFrame = node.callFrame;
160
+ if (isMetaFrame(callFrame)) continue;
161
+ const key = functionKey(callFrame);
162
+ if (!functions.has(key)) {
163
+ functions.set(key, { callFrame, selfUs: 0, totalUs: 0 });
164
+ }
165
+ let keyNodes = nodesByKey.get(key);
166
+ if (!keyNodes) {
167
+ keyNodes = [];
168
+ nodesByKey.set(key, keyNodes);
169
+ }
170
+ keyNodes.push(node);
171
+ }
172
+
173
+ // Per-node inclusive time (its subtree's samples) — the cost of the
174
+ // call-tree edge from its parent, which is what the callee
175
+ // breakdown aggregates.
176
+ const nodeTotals = new Map();
177
+ for (const [index, nodeId] of samples.entries()) {
178
+ const delta = timeDeltas[index];
179
+ // The first delta rebases against the profile start and can be
180
+ // negative or missing; skip rather than credit negative time.
181
+ if (!delta || delta <= 0) continue;
182
+ const node = nodes.get(nodeId);
183
+ if (!node) continue;
184
+ if (!isMetaFrame(node.callFrame)) {
185
+ functions.get(functionKey(node.callFrame)).selfUs += delta;
186
+ }
187
+ for (const key of keysForNode(nodeId)) {
188
+ functions.get(key).totalUs += delta;
189
+ }
190
+ for (
191
+ let ancestor = node;
192
+ ancestor;
193
+ ancestor =
194
+ ancestor.parent === undefined ? undefined : nodes.get(ancestor.parent)
195
+ ) {
196
+ nodeTotals.set(ancestor.id, (nodeTotals.get(ancestor.id) || 0) + delta);
197
+ }
198
+ }
199
+
200
+ // Where a function's non-self time went: for every call-tree node of
201
+ // the function, each child's inclusive time is credited to the
202
+ // child's function identity. Answers "what did this orchestrator
203
+ // run" for loader/dispatcher rows whose total dwarfs their self
204
+ // time. Direct recursion shows the inner occurrence as a callee of
205
+ // itself, same as DevTools' top-down tree.
206
+ function calleesFor(key) {
207
+ const byCallee = new Map();
208
+ for (const node of nodesByKey.get(key) || []) {
209
+ for (const child of childNodes.get(node.id) || []) {
210
+ if (isMetaFrame(child.callFrame)) continue;
211
+ const childUs = nodeTotals.get(child.id);
212
+ if (!childUs) continue;
213
+ const childKey = functionKey(child.callFrame);
214
+ let entry = byCallee.get(childKey);
215
+ if (!entry) {
216
+ entry = { callFrame: child.callFrame, us: 0 };
217
+ byCallee.set(childKey, entry);
218
+ }
219
+ entry.us += childUs;
220
+ }
221
+ }
222
+ const callees = [];
223
+ for (const { callFrame, us } of [...byCallee.values()].toSorted(
224
+ (a, b) => b.us - a.us
225
+ )) {
226
+ if (us < CALLEE_LIMIT_US || callees.length >= MAX_CALLEES) break;
227
+ const callee = {
228
+ functionName: callFrame.functionName || '(anonymous)',
229
+ value: round(us / 1000)
230
+ };
231
+ const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
232
+ const bundle = findBundle(bundles, url);
233
+ if (bundle) {
234
+ callee.url = bundle.url;
235
+ } else if (url) {
236
+ callee.url = url;
237
+ }
238
+ if (bundle && callFrame.lineNumber !== undefined) {
239
+ const module_ = bundle.resolver.resolve(
240
+ callFrame.lineNumber + 1,
241
+ callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1
242
+ );
243
+ if (module_) callee.module = module_.name;
244
+ }
245
+ callees.push(callee);
246
+ }
247
+ return callees;
248
+ }
249
+
250
+ const costs = [];
251
+ for (const [key, { callFrame, selfUs, totalUs }] of functions.entries()) {
252
+ if (selfUs < REPORT_LIMIT_US) continue;
253
+ const url = callFrame.url || scriptUrls.get(callFrame.scriptId) || '';
254
+ const cost = {
255
+ functionName: callFrame.functionName || '(anonymous)',
256
+ selfTime: round(selfUs / 1000),
257
+ totalTime: round(totalUs / 1000)
258
+ };
259
+ const bundle = findBundle(bundles, url);
260
+ if (bundle) {
261
+ cost.url = bundle.url;
262
+ } else if (url) {
263
+ cost.url = url;
264
+ }
265
+ if (callFrame.lineNumber !== undefined) {
266
+ cost.line = callFrame.lineNumber + 1;
267
+ cost.column =
268
+ callFrame.columnNumber === undefined ? 1 : callFrame.columnNumber + 1;
269
+ if (bundle) {
270
+ const module_ = bundle.resolver.resolve(cost.line, cost.column);
271
+ if (module_) cost.module = module_.name;
272
+ }
273
+ }
274
+ const callees = calleesFor(key);
275
+ if (callees.length > 0) cost.callees = callees;
276
+ costs.push(cost);
277
+ }
278
+
279
+ costs.sort((a, b) => b.selfTime - a.selfTime);
280
+ return costs.slice(0, MAX_FUNCTIONS);
281
+ }
@@ -15,6 +15,13 @@ import { compute } from './main-thread-tasks.js';
15
15
  export { computeScriptCosts } from './script-costs.js';
16
16
  export { computeForcedReflows } from './forced-reflows.js';
17
17
  export { computeNonCompositedAnimations } from './non-composited-animations.js';
18
+ export { computeFrameStability } from './frame-stability.js';
19
+ export { computeFunctionCosts } from './function-costs.js';
20
+ export { computeStyleInvalidations } from './style-invalidations.js';
21
+ export { computeSelectorStats } from './selector-stats.js';
22
+ export { computeTimerCosts } from './timer-costs.js';
23
+ export { computeBlockingTime } from './blocking-time.js';
24
+ export { computeDomainBreakdown } from './domain-breakdown.js';
18
25
 
19
26
  /**
20
27
  * Compute the hierarchical main-thread task tree for a trace.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Per-module CPU attribution for script bundles that concatenate many
3
+ * logical modules into a single URL (e.g. MediaWiki ResourceLoader
4
+ * load.php responses). cpu.urls can only say "load.php used 267 ms of
5
+ * main thread"; this analysis splits that time across the modules
6
+ * inside each bundle so the owner of a slow module can be identified.
7
+ *
8
+ * This file is bundle-format agnostic. It walks the main-thread task
9
+ * tree and, for every task whose most-specific code location falls
10
+ * inside a known bundle URL, asks that bundle's resolver which module
11
+ * owns the source position. All knowledge about the bundle format
12
+ * (module delimiters, line/column → byte-offset math) lives in the
13
+ * resolver — see lib/chrome/mediawikiResourceLoader.js.
14
+ *
15
+ * Attribution rules per task (selfTime is exclusive of children, so
16
+ * nothing is counted twice):
17
+ * - EvaluateScript / v8.compile / v8.compileModule on a bundle URL:
18
+ * the event spans the whole script, not one module (module bodies
19
+ * are only registered at evaluate time and executed later), so the
20
+ * time goes to the bundle's `otherTime` bucket.
21
+ * - FunctionCall: args.data carries the definition site of the
22
+ * invoked function — resolve it to a module.
23
+ * - Any other event with a stack trace: the innermost frame with a
24
+ * URL decides — resolve when it is a bundle, skip the task when it
25
+ * points at code outside any bundle.
26
+ * - Tasks with no location of their own (layout, GC, microtasks
27
+ * triggered by module code) fall back to the inherited
28
+ * most-specific attributable URL; when that is a bundle the time
29
+ * is in-bundle but not attributable to one module → `otherTime`.
30
+ *
31
+ * Because only a task's own most-specific location (or the inherited
32
+ * chain when it has none) is credited, the per-bundle sum of module
33
+ * selfTime + otherTime is always ≤ that URL's total in cpu.urls,
34
+ * which counts a task for every URL anywhere in its chain.
35
+ *
36
+ * Times are in ms rounded to one decimal, matching the sibling
37
+ * analyses. Modules that round to 0 are dropped; bundles are sorted
38
+ * by total time desc and modules by selfTime desc.
39
+ */
40
+
41
+ import { compute } from './main-thread-tasks.js';
42
+
43
+ const WHOLE_SCRIPT_EVENTS = new Set([
44
+ 'EvaluateScript',
45
+ 'v8.compile',
46
+ 'v8.compileModule'
47
+ ]);
48
+
49
+ function round(ms) {
50
+ return Math.round(ms * 10) / 10;
51
+ }
52
+
53
+ function attributeTask(task, bundles) {
54
+ const event = task.event;
55
+ const argsData = (event.args && event.args.data) || {};
56
+
57
+ if (WHOLE_SCRIPT_EVENTS.has(event.name)) {
58
+ const url =
59
+ event.name === 'v8.compileModule' ? event.args.fileName : argsData.url;
60
+ return url && bundles.has(url) ? { url } : undefined;
61
+ }
62
+
63
+ if (event.name === 'FunctionCall' && argsData.url) {
64
+ const bundle = bundles.get(argsData.url);
65
+ if (!bundle) return;
66
+ return {
67
+ url: argsData.url,
68
+ module: bundle.resolve(argsData.lineNumber, argsData.columnNumber)
69
+ };
70
+ }
71
+
72
+ for (const frame of argsData.stackTrace || []) {
73
+ if (!frame.url) continue;
74
+ const bundle = bundles.get(frame.url);
75
+ if (!bundle) return;
76
+ return {
77
+ url: frame.url,
78
+ module: bundle.resolve(frame.lineNumber, frame.columnNumber)
79
+ };
80
+ }
81
+
82
+ const inherited = task.attributableURLs.at(-1);
83
+ return inherited && bundles.has(inherited) ? { url: inherited } : undefined;
84
+ }
85
+
86
+ /**
87
+ * @param {Object} trace Parsed Chrome trace.json (with .traceEvents).
88
+ * @param {Map<string, {resolve: Function}>} bundles Bundle URL →
89
+ * location resolver, built while collecting coverage.
90
+ * @returns {Array<{url:string,
91
+ * modules:Array<{name:string, version:string, selfTime:number}>,
92
+ * otherTime:number}>}
93
+ */
94
+ export function computeModuleCosts(trace, bundles) {
95
+ if (!bundles || bundles.size === 0) return [];
96
+
97
+ const byBundle = new Map();
98
+ for (const task of compute(trace)) {
99
+ if (!(task.selfTime > 0)) continue;
100
+ const attribution = attributeTask(task, bundles);
101
+ if (!attribution) continue;
102
+
103
+ let entry = byBundle.get(attribution.url);
104
+ if (!entry) {
105
+ entry = { byModule: new Map(), otherTime: 0 };
106
+ byBundle.set(attribution.url, entry);
107
+ }
108
+ const module = attribution.module;
109
+ if (module) {
110
+ const key = `${module.name}@${module.version}`;
111
+ const moduleEntry = entry.byModule.get(key);
112
+ if (moduleEntry) {
113
+ moduleEntry.selfTime += task.selfTime;
114
+ } else {
115
+ entry.byModule.set(key, { ...module, selfTime: task.selfTime });
116
+ }
117
+ } else {
118
+ entry.otherTime += task.selfTime;
119
+ }
120
+ }
121
+
122
+ const result = [];
123
+ for (const [url, entry] of byBundle) {
124
+ const modules = [];
125
+ let total = entry.otherTime;
126
+ for (const moduleEntry of entry.byModule.values()) {
127
+ total += moduleEntry.selfTime;
128
+ moduleEntry.selfTime = round(moduleEntry.selfTime);
129
+ if (moduleEntry.selfTime > 0) modules.push(moduleEntry);
130
+ }
131
+ modules.sort((a, b) => b.selfTime - a.selfTime);
132
+ result.push({ url, modules, otherTime: round(entry.otherTime), total });
133
+ }
134
+ result.sort((a, b) => b.total - a.total);
135
+ for (const entry of result) delete entry.total;
136
+ return result;
137
+ }
@@ -10,20 +10,64 @@
10
10
  *
11
11
  * Chrome marks these in the trace via `Animation` events whose
12
12
  * `args.data.compositeFailed` bitmask is non-zero, plus a list of
13
- * `unsupportedProperties` strings. We surface the raw values; the
14
- * consumer can map the bitmask to human reasons (see Lighthouse's
15
- * non-composited-animations audit for the canonical mapping).
13
+ * `unsupportedProperties` strings. Chrome often omits the property
14
+ * list (the bitmask is the only signal), so the bitmask is decoded
15
+ * into `failureReasons` strings here otherwise consumers show "an
16
+ * animation failed" with no way to say why.
16
17
  *
17
- * Returns: [{ name, id, compositeFailed, unsupportedProperties,
18
- * startTime }, …]
18
+ * The bit meanings come from Blink's CompositorAnimations
19
+ * FailureReason enum (compositor_animations.h, kFailureReasonCount
20
+ * = 21 as of Chrome ~M140): bit values are stable and never reused,
21
+ * so decoding old traces is safe. Bits 8/11/14 are retired in
22
+ * current Blink but kept here for traces from older Chrome versions.
23
+ * Unknown future bits decode to 'unknown reason (bit N)' instead of
24
+ * being dropped.
25
+ *
26
+ * Returns: [{ name, id, compositeFailed, failureReasons,
27
+ * unsupportedProperties, startTime }, …]
19
28
  * name — args.data.nodeName when present (rare)
20
29
  * id — args.data.id (compositor-internal)
21
30
  * compositeFailed — bitmask of failure reasons
31
+ * failureReasons — the bitmask decoded to human-readable strings
22
32
  * unsupportedProperties — array of property names that blocked
23
33
  * composite (e.g. ['top','box-shadow'])
24
34
  * startTime — event ts in microseconds (raw trace timestamp)
25
35
  */
26
36
 
37
+ // Blink FailureReason bit → human-readable string, in bit order.
38
+ const FAILURE_REASONS = [
39
+ 'accelerated animations disabled', // 1 << 0
40
+ 'effect suppressed by DevTools', // 1 << 1
41
+ 'invalid animation or effect', // 1 << 2
42
+ 'effect has unsupported timing parameters', // 1 << 3
43
+ "effect has composite mode other than 'replace'", // 1 << 4
44
+ 'target has invalid compositing state', // 1 << 5
45
+ 'target has another incompatible animation', // 1 << 6
46
+ 'target has CSS offset', // 1 << 7
47
+ 'target has multiple transform properties', // 1 << 8 (retired)
48
+ 'animation affects non-CSS properties', // 1 << 9
49
+ 'transform-related property cannot be accelerated on target', // 1 << 10
50
+ 'transform-related property depends on box size', // 1 << 11 (retired)
51
+ 'filter-related property may move pixels', // 1 << 12
52
+ 'unsupported CSS property', // 1 << 13
53
+ 'multiple transform animations on same target', // 1 << 14 (retired)
54
+ 'mixed keyframe value types', // 1 << 15
55
+ 'scroll timeline source has invalid compositing state', // 1 << 16
56
+ 'animation has no visible change', // 1 << 17
57
+ 'animation affects an !important property', // 1 << 18
58
+ 'SVG target has independent transform property', // 1 << 19
59
+ "effect has iteration composite mode other than 'replace'" // 1 << 20
60
+ ];
61
+
62
+ function decodeFailureReasons(bitmask) {
63
+ const reasons = [];
64
+ for (let bit = 0; bitmask >> bit > 0; bit++) {
65
+ if (((bitmask >> bit) & 1) === 0) continue;
66
+ reasons.push(FAILURE_REASONS[bit] || `unknown reason (bit ${bit})`);
67
+ }
68
+ return reasons;
69
+ }
70
+
27
71
  export function computeNonCompositedAnimations(trace) {
28
72
  const seen = new Set();
29
73
  const animations = [];
@@ -46,6 +90,7 @@ export function computeNonCompositedAnimations(trace) {
46
90
  name: data.nodeName || '',
47
91
  id: data.id || '',
48
92
  compositeFailed: failed,
93
+ failureReasons: decodeFailureReasons(failed),
49
94
  unsupportedProperties: unsupported,
50
95
  startTime: event.ts
51
96
  });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CSS selector matching cost, from Chrome's SelectorStats trace
3
+ * events (the disabled-by-default-blink.debug category, enabled on
4
+ * --enableProfileRun). For every style recalculation Chrome reports
5
+ * per-selector elapsed time, match attempts and match count — the
6
+ * same data DevTools' "CSS selector stats" checkbox shows. Style &
7
+ * layout often dominates main-thread blocking, and this is the only
8
+ * signal that says WHICH selectors the recalc time goes to.
9
+ *
10
+ * Selectors are aggregated across all recalcs in the trace. Two
11
+ * rankings matter and both are kept: elapsed time (what costs now)
12
+ * and match attempts with zero matches (pure waste — selectors
13
+ * evaluated against the whole document for rules that never apply,
14
+ * e.g. a lightbox selector on pages where the lightbox never opens).
15
+ *
16
+ * `styleSheetId` is Chrome's session-internal stylesheet handle —
17
+ * kept so consumers that also collect CSS coverage over CDP can join
18
+ * back to a stylesheet URL, but it is not stable across runs.
19
+ * 'ua-style-sheet' marks Chrome's own user-agent rules; those are
20
+ * not the page's to fix and are excluded from the reported lists
21
+ * (their time still counts in the totals).
22
+ *
23
+ * Returns undefined when the trace has no SelectorStats events (the
24
+ * category is only on for profile runs). Otherwise:
25
+ * { totalElapsed, totalMatchAttempts, totalMatchCount,
26
+ * selectors: [{ selector, styleSheetId?, elapsed,
27
+ * matchAttempts, matchCount }, …],
28
+ * wastedSelectors: [{ … same shape, matchCount always 0 }, …] }
29
+ * elapsed in ms with two decimals (individual selectors are sub-ms),
30
+ * both lists capped at 25 entries; selectors sorted by elapsed desc,
31
+ * wastedSelectors by matchAttempts desc.
32
+ */
33
+
34
+ const MAX_SELECTORS = 25;
35
+ const UA_STYLE_SHEET = 'ua-style-sheet';
36
+
37
+ function round2(ms) {
38
+ return Math.round(ms * 100) / 100;
39
+ }
40
+
41
+ export function computeSelectorStats(trace) {
42
+ const bySelector = new Map();
43
+ let sawSelectorStats = false;
44
+ let totalElapsedUs = 0;
45
+ let totalMatchAttempts = 0;
46
+ let totalMatchCount = 0;
47
+
48
+ for (const event of trace.traceEvents) {
49
+ if (event.name !== 'SelectorStats') continue;
50
+ sawSelectorStats = true;
51
+ const timings =
52
+ (event.args &&
53
+ event.args.selector_stats &&
54
+ event.args.selector_stats.selector_timings) ||
55
+ [];
56
+ for (const timing of timings) {
57
+ const elapsedUs = timing['elapsed (us)'] || 0;
58
+ totalElapsedUs += elapsedUs;
59
+ totalMatchAttempts += timing.match_attempts || 0;
60
+ totalMatchCount += timing.match_count || 0;
61
+ if (timing.style_sheet_id === UA_STYLE_SHEET) continue;
62
+ const key = `${timing.selector}|${timing.style_sheet_id || ''}`;
63
+ let entry = bySelector.get(key);
64
+ if (!entry) {
65
+ entry = {
66
+ selector: timing.selector,
67
+ styleSheetId: timing.style_sheet_id,
68
+ elapsedUs: 0,
69
+ matchAttempts: 0,
70
+ matchCount: 0
71
+ };
72
+ bySelector.set(key, entry);
73
+ }
74
+ entry.elapsedUs += elapsedUs;
75
+ entry.matchAttempts += timing.match_attempts || 0;
76
+ entry.matchCount += timing.match_count || 0;
77
+ }
78
+ }
79
+ if (!sawSelectorStats) return;
80
+
81
+ function publish(entry) {
82
+ const result = {
83
+ selector: entry.selector,
84
+ elapsed: round2(entry.elapsedUs / 1000),
85
+ matchAttempts: entry.matchAttempts,
86
+ matchCount: entry.matchCount
87
+ };
88
+ if (entry.styleSheetId) result.styleSheetId = entry.styleSheetId;
89
+ return result;
90
+ }
91
+
92
+ const all = [...bySelector.values()];
93
+ const selectors = all
94
+ .toSorted((a, b) => b.elapsedUs - a.elapsedUs)
95
+ .slice(0, MAX_SELECTORS)
96
+ .map(entry => publish(entry));
97
+ const wastedSelectors = all
98
+ .filter(entry => entry.matchCount === 0)
99
+ .toSorted((a, b) => b.matchAttempts - a.matchAttempts)
100
+ .slice(0, MAX_SELECTORS)
101
+ .map(entry => publish(entry));
102
+
103
+ return {
104
+ totalElapsed: round2(totalElapsedUs / 1000),
105
+ totalMatchAttempts,
106
+ totalMatchCount,
107
+ selectors,
108
+ wastedSelectors
109
+ };
110
+ }