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,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 { elements, durationInMillis: duration / 1000 };
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).
@@ -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
 
@@ -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 = 20;
54
+ const minVersion = 22;
55
55
  const majorVersion = fullVersion.split('.')[0];
56
56
  if (majorVersion < minVersion) {
57
57
  return (
@@ -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 (sensitiveHeaders.has(name.toLowerCase())) {
511
+ if (isSensitiveHeader(name)) {
469
512
  return '[REMOVED]';
470
513
  }
471
514
  return value;