browsertime 27.4.1 → 28.0.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 (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/browsertime.js +28 -4
  3. package/lib/chrome/coverage.js +89 -19
  4. package/lib/chrome/mediawikiResourceLoader.js +223 -0
  5. package/lib/chrome/parseCpuTrace.js +87 -2
  6. package/lib/chrome/trace/blocking-time.js +189 -0
  7. package/lib/chrome/trace/domain-breakdown.js +86 -0
  8. package/lib/chrome/trace/frame-stability.js +135 -0
  9. package/lib/chrome/trace/function-costs.js +281 -0
  10. package/lib/chrome/trace/index.js +7 -0
  11. package/lib/chrome/trace/module-costs.js +137 -0
  12. package/lib/chrome/trace/non-composited-animations.js +50 -5
  13. package/lib/chrome/trace/selector-stats.js +110 -0
  14. package/lib/chrome/trace/style-invalidations.js +162 -0
  15. package/lib/chrome/trace/task-groups.js +28 -2
  16. package/lib/chrome/trace/timer-costs.js +183 -0
  17. package/lib/chrome/webdriver/chromium.js +114 -0
  18. package/lib/chrome/webdriver/traceUtilities.js +11 -1
  19. package/lib/core/engine/collector.js +12 -0
  20. package/lib/core/engine/index.js +6 -0
  21. package/lib/core/engine/iteration.js +5 -0
  22. package/lib/support/cli.js +1 -1
  23. package/lib/support/har/index.js +44 -1
  24. package/package.json +6 -6
  25. package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
  26. package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
  27. package/types/chrome/parseCpuTrace.d.ts +1 -26
  28. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  29. package/types/chrome/trace/blocking-time.d.ts +7 -0
  30. package/types/chrome/trace/blocking-time.d.ts.map +1 -0
  31. package/types/chrome/trace/domain-breakdown.d.ts +11 -0
  32. package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
  33. package/types/chrome/trace/frame-stability.d.ts +11 -0
  34. package/types/chrome/trace/frame-stability.d.ts.map +1 -0
  35. package/types/chrome/trace/function-costs.d.ts +6 -0
  36. package/types/chrome/trace/function-costs.d.ts.map +1 -0
  37. package/types/chrome/trace/index.d.ts +7 -0
  38. package/types/chrome/trace/index.d.ts.map +1 -1
  39. package/types/chrome/trace/module-costs.d.ts +20 -0
  40. package/types/chrome/trace/module-costs.d.ts.map +1 -0
  41. package/types/chrome/trace/non-composited-animations.d.ts +1 -25
  42. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
  43. package/types/chrome/trace/selector-stats.d.ts +8 -0
  44. package/types/chrome/trace/selector-stats.d.ts.map +1 -0
  45. package/types/chrome/trace/style-invalidations.d.ts +9 -0
  46. package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
  47. package/types/chrome/trace/task-groups.d.ts.map +1 -1
  48. package/types/chrome/trace/timer-costs.d.ts +10 -0
  49. package/types/chrome/trace/timer-costs.d.ts.map +1 -0
  50. package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 28.0.0 - 2026-07-17
4
+
5
+ ### Breaking
6
+ * Browsertime now requires Node.js 22 or later. Node 20 reaches end of life and the TypeScript scripting support already needs a newer runtime [#2510](https://github.com/sitespeedio/browsertime/pull/2510).
7
+
8
+ ### Added
9
+ * New `mainDocument` field per iteration (any browser with a HAR): final URL, status, redirect count and response headers of the main document, with sensitive headers always removed. Tag runs by cache state (`x-cache`, `age`) or experiment enrollment (`server-timing`) without HAR post-processing [#2500](https://github.com/sitespeedio/browsertime/pull/2500).
10
+ * New `cpu.blocking` and `cpu.domains` keys (Chrome/Edge, `--cpu`): long-task blocking time per script (whole trace and before LCP) plus first vs third party CPU totals. Answers "which script should I fix first" [#2503](https://github.com/sitespeedio/browsertime/pull/2503).
11
+ * `cpu.blocking` also splits blocking time by kind of work (`kinds`: script, style/layout, HTML parsing, …), and blocking HTML-parse tasks are attributed to the document URL instead of the anonymous `unknown` bucket. A page blocked by parsing its own large HTML needs a completely different fix than one blocked by JavaScript [#2512](https://github.com/sitespeedio/browsertime/pull/2512).
12
+ * New `cpu.functionCosts` key (Chrome/Edge): the slowest JS functions by main-thread self time, with total time, source position and the owning ResourceLoader module for bundled code, parsed from the V8 sampling profiler that `--enableProfileRun` already enables. Says which function is slow instead of stopping at "this script used 300 ms" [#2511](https://github.com/sitespeedio/browsertime/pull/2511). Rows also carry `callees`, showing where an orchestrator's total went (for example which modules MediaWiki's `runScript` executed) [#2515](https://github.com/sitespeedio/browsertime/pull/2515).
13
+ * New `cpu.selectorStats` key (Chrome only, on `--enableProfileRun`): per-selector CSS matching cost, showing the slowest selectors by elapsed time plus the wasteful ones with thousands of match attempts and zero matches. The only signal that says which selectors the style-recalc time goes to [#2517](https://github.com/sitespeedio/browsertime/pull/2517).
14
+ * New `cpu.styleInvalidations` key (Chrome only, with `--chrome.timeline`): style/layout invalidations aggregated by reason, by trigger (which class/attribute keeps changing) and by source, resolved to the owning ResourceLoader module when coverage runs. The invalidation-tracking events were in every trace for years but never parsed [#2516](https://github.com/sitespeedio/browsertime/pull/2516) [#2521](https://github.com/sitespeedio/browsertime/pull/2521).
15
+ * New `cpu.timers` key (Chrome only, with `--chrome.timeline`): setTimeout/setInterval pressure per script (installs, fires, fire time, timeout-0 and recurring counts) plus the individual timers grouped by install site, resolved to the owning ResourceLoader module when coverage runs. On Wikipedia the timeout-0 offender turns out to be jquery's deferred machinery [#2518](https://github.com/sitespeedio/browsertime/pull/2518) [#2519](https://github.com/sitespeedio/browsertime/pull/2519) [#2520](https://github.com/sitespeedio/browsertime/pull/2520).
16
+ * `cpu.nonCompositedAnimations` entries now include `failureReasons`, the `compositeFailed` bitmask decoded via Blink's FailureReason enum, so consumers can tell a harmless "animation has no visible change" from a real "unsupported CSS property" [#2514](https://github.com/sitespeedio/browsertime/pull/2514).
17
+ * New `cpu.frames` key (Chrome/Edge, `--cpu`): presented/partial/dropped frames, effective FPS and the longest gap between presented frames, from Chrome's PipelineReporter events. Counts only drops Chrome flags as smoothness-affecting [#2506](https://github.com/sitespeedio/browsertime/pull/2506).
18
+ * Per-module coverage for MediaWiki ResourceLoader bundles: `load.php` coverage entries get a `modules` array with used/unused bytes per module, derived from the `mw.loader.impl` boundaries in the bundle source. Automatic, Chrome only [#2502](https://github.com/sitespeedio/browsertime/pull/2502).
19
+ * New `cpu.moduleCosts` key: per-module main-thread CPU time inside ResourceLoader bundles, when both trace and coverage are collected. Per-module `selfTime` plus an `otherTime` bucket for whole-bundle work [#2505](https://github.com/sitespeedio/browsertime/pull/2505).
20
+ * Stable `label` fields (`load.php[startup]`, `load.php[scripts]`, …) on ResourceLoader URLs in `cpu.urls`, `cpu.blocking`, `scriptCosts`, `forcedReflows` and coverage entries [#2504](https://github.com/sitespeedio/browsertime/pull/2504), and on `cpu.moduleCosts` [#2507](https://github.com/sitespeedio/browsertime/pull/2507). The raw URLs embed a `version=` parameter that changes every deploy and breaks per-bundle time series. Additive: `url` is untouched.
21
+ * Inline CSS coverage entries are labeled with the owning MediaWiki template (`TemplateStyles:rNNNNNNN`, from the style element's `data-mw-deduplicate` attribute). Inline sheets all share the document URL (or an empty one), so Wikipedia's CSS coverage collapsed into anonymous buckets and nobody could see which template ships the dead CSS [#2509](https://github.com/sitespeedio/browsertime/pull/2509).
22
+ * The `renderBlocking.recalculateStyle` summary (Chrome only) now includes the event `count` and the largest single event (`maxElements`, `maxDurationInMillis`), before FCP and LCP. Tells one massive style invalidation apart from thousands of small forced recalcs [#2496](https://github.com/sitespeedio/browsertime/pull/2496).
23
+ * Bumped `chromedriver` and `edgedriver` to 149 [#2487](https://github.com/sitespeedio/browsertime/pull/2487).
24
+ * Updated the Docker image to Chrome 149 / Firefox 151 / Edge 149 [#2486](https://github.com/sitespeedio/browsertime/pull/2486) [#2488](https://github.com/sitespeedio/browsertime/pull/2488).
25
+ * Updated `@sitespeed.io/throttle` to 6.0.1 [#2499](https://github.com/sitespeedio/browsertime/pull/2499).
26
+ * Releases are now built and published from GitHub Actions with npm provenance and an SBOM, so the published package can be verified against the source it was built from [#2491](https://github.com/sitespeedio/browsertime/pull/2491).
27
+
28
+ ### Changed
29
+ * Coverage from `--enableProfileRun` is now a single object marked `source: 'profileRun'` instead of a one-element array keyed by iteration; indexing it by run number read `undefined` and reported no coverage. `--chrome.coverage` keeps the per-iteration array shape [#2501](https://github.com/sitespeedio/browsertime/pull/2501).
30
+ * Document body-loading events (`DocumentLoader::*`, `NavigationBodyLoader::*`, `URLLoader::*`) now count as parse time and V8 interrupt/housekeeping as script evaluation in `cpu.categories`, instead of falling into `other`. What remains in `other` is almost entirely Chrome's task-scheduling overhead (RunTask self time), so consumers can present the bucket honestly instead of as unexplained mystery time [#2513](https://github.com/sitespeedio/browsertime/pull/2513).
31
+
32
+ ### Fixed
33
+ * `--enableProfileRun` no longer affects the measured runs: combined with `--cpu` it misnamed every measured trace as `trace-N-extra-run.json`, let the profile trace overwrite iteration 1's trace and suppressed the result log line [#2508](https://github.com/sitespeedio/browsertime/pull/2508).
34
+ * Navigation-script failures now include the URL and iteration number in the error message [#2489](https://github.com/sitespeedio/browsertime/pull/2489).
35
+
3
36
  ## 27.4.1 - 2026-05-25
4
37
 
5
38
  ### Fixed
@@ -85,6 +85,15 @@ async function run(urls, options) {
85
85
  }
86
86
  }
87
87
 
88
+ // To the CLI, enableProfileRun means "make one extra profile run",
89
+ // but inside the engine it means "this run IS the profile run"
90
+ // (extra-run trace naming, suppressed result log line). Keep it
91
+ // away from the measured runs and hand it back to the extra
92
+ // engine below, so measured traces keep their trace-N.json names
93
+ // and the profile trace cannot overwrite the first iteration's.
94
+ const makeProfileRun = options.enableProfileRun;
95
+ options.enableProfileRun = false;
96
+
88
97
  let engine = new Engine(options);
89
98
 
90
99
  const scriptCategories = await allScriptCategories();
@@ -159,10 +168,11 @@ async function run(urls, options) {
159
168
  }
160
169
  }
161
170
 
162
- if (options.enableProfileRun || options.enableVideoRun) {
171
+ if (makeProfileRun || options.enableVideoRun) {
163
172
  log.info('Make one extra run to collect trace/video information');
164
173
  options.iterations = 1;
165
- if (options.enableProfileRun) {
174
+ if (makeProfileRun) {
175
+ options.enableProfileRun = true;
166
176
  if (options.browser === 'firefox') {
167
177
  options.firefox.geckoProfiler = true;
168
178
  options.firefox.collectMozLog = true;
@@ -170,8 +180,12 @@ async function run(urls, options) {
170
180
  options.chrome.timeline = true;
171
181
  options.cpu = true;
172
182
  options.chrome.enableTraceScreenshots = true;
183
+ // v8.cpu_profiler feeds cpu.functionCosts; blink.debug
184
+ // emits SelectorStats for cpu.selectorStats. Both add
185
+ // overhead, which is fine here — this run is not measured.
173
186
  options.chrome.traceCategory = [
174
- 'disabled-by-default-v8.cpu_profiler'
187
+ 'disabled-by-default-v8.cpu_profiler',
188
+ 'disabled-by-default-blink.debug'
175
189
  ];
176
190
  options.chrome.coverage = true;
177
191
  }
@@ -202,14 +216,24 @@ async function run(urls, options) {
202
216
  // the main iterations didn't collect any (--enableProfileRun
203
217
  // without --chrome.coverage). Otherwise the main run's
204
218
  // per-iteration samples are what the user asked for.
219
+ // The profile run is a single side iteration, so its sample is
220
+ // published as one object marked with source instead of a
221
+ // one-element array pretending to be keyed by iteration.
205
222
  let mergedCoverage = false;
206
223
  for (const [i, pr] of profileResult.entries()) {
207
224
  const main = result[i];
208
225
  if (!main || !pr.coverage) continue;
226
+ if (pr.coverage.js.length === 0 && pr.coverage.css.length === 0) {
227
+ continue;
228
+ }
209
229
  if (main.coverage.js.length > 0 || main.coverage.css.length > 0) {
210
230
  continue;
211
231
  }
212
- main.coverage = pr.coverage;
232
+ main.coverage = {
233
+ source: 'profileRun',
234
+ js: pr.coverage.js[0],
235
+ css: pr.coverage.css[0]
236
+ };
213
237
  mergedCoverage = true;
214
238
  }
215
239
  if (mergedCoverage) {
@@ -1,5 +1,13 @@
1
1
  import { getLogger } from '@sitespeed.io/log';
2
2
 
3
+ import {
4
+ isResourceLoaderBundle,
5
+ labelForStyleAttributes,
6
+ labelForUrl,
7
+ resourceLoaderModuleCoverage,
8
+ resourceLoaderLocationResolver
9
+ } from './mediawikiResourceLoader.js';
10
+
3
11
  const log = getLogger('browsertime.chrome.coverage');
4
12
 
5
13
  // Sum the union length of half-open [start, end) ranges. Used for the
@@ -25,8 +33,8 @@ function unionLength(ranges) {
25
33
  return total;
26
34
  }
27
35
 
28
- // Count used bytes for a script under detailed (block-level) V8
29
- // coverage. The CDP returns nested ranges: the outermost range is the
36
+ // Paint a per-byte used/unused flag array for a script under detailed
37
+ // (block-level) V8 coverage. The CDP returns nested ranges: the outermost range is the
30
38
  // function body and inner ranges are sub-blocks. An inner range with
31
39
  // count == 0 inside a containing range with count > 0 represents dead
32
40
  // code in an otherwise-executed function — those bytes must read as
@@ -38,25 +46,29 @@ function unionLength(ranges) {
38
46
  // length descending), painting a per-byte coverage flag; inner ranges
39
47
  // overwrite outer ones, so the final flag at each byte reflects the
40
48
  // innermost range's count.
41
- export function usedJsBytes(scriptCoverage, totalBytes) {
49
+ export function paintJsCoverage(scriptCoverage, totalBytes) {
50
+ const used = new Uint8Array(totalBytes);
42
51
  const ranges = [];
43
52
  for (const fn of scriptCoverage.functions) {
44
53
  for (const r of fn.ranges) {
45
54
  if (r.endOffset > r.startOffset) ranges.push(r);
46
55
  }
47
56
  }
48
- if (ranges.length === 0) return 0;
49
57
  ranges.sort((a, b) =>
50
58
  a.startOffset === b.startOffset
51
59
  ? b.endOffset - a.endOffset
52
60
  : a.startOffset - b.startOffset
53
61
  );
54
- const used = new Uint8Array(totalBytes);
55
62
  for (const r of ranges) {
56
63
  const start = Math.max(0, r.startOffset);
57
64
  const end = Math.min(totalBytes, r.endOffset);
58
65
  if (end > start) used.fill(r.count > 0 ? 1 : 0, start, end);
59
66
  }
67
+ return used;
68
+ }
69
+
70
+ export function usedJsBytes(scriptCoverage, totalBytes) {
71
+ const used = paintJsCoverage(scriptCoverage, totalBytes);
60
72
  let count = 0;
61
73
  for (let i = 0; i < totalBytes; i++) {
62
74
  if (used[i] === 1) count++;
@@ -84,20 +96,21 @@ export class Coverage {
84
96
  constructor(cdp) {
85
97
  this.rawClient = cdp.getRawClient();
86
98
  this.started = false;
87
- this.styleSheetUrls = new Map();
99
+ this.styleSheets = new Map();
88
100
  this.disposeStyleSheetListener = undefined;
89
101
  }
90
102
 
91
103
  async start() {
92
104
  const client = this.rawClient;
93
- this.styleSheetUrls.clear();
105
+ this.styleSheets.clear();
94
106
  this.disposeStyleSheetListener = client.CSS.styleSheetAdded(
95
107
  ({ header }) => {
96
108
  if (header && header.styleSheetId) {
97
- this.styleSheetUrls.set(
98
- header.styleSheetId,
99
- header.sourceURL || header.sourceMapURL || ''
100
- );
109
+ this.styleSheets.set(header.styleSheetId, {
110
+ url: header.sourceURL || header.sourceMapURL || '',
111
+ ownerNode: header.ownerNode,
112
+ isInline: header.isInline
113
+ });
101
114
  }
102
115
  }
103
116
  );
@@ -121,9 +134,16 @@ export class Coverage {
121
134
 
122
135
  let js;
123
136
  let css;
137
+ // Bundle URL → source-location resolver for concatenated bundles
138
+ // (MediaWiki ResourceLoader). Not part of the coverage result —
139
+ // the caller hands it to the CPU trace parser so trace stack
140
+ // frames can be attributed to individual modules.
141
+ let resourceLoaderBundles;
124
142
  try {
125
143
  const jsResult = await client.Profiler.takePreciseCoverage();
126
- js = await this.#processJs(jsResult.result || []);
144
+ ({ js, resourceLoaderBundles } = await this.#processJs(
145
+ jsResult.result || []
146
+ ));
127
147
  } catch (error) {
128
148
  log.warn('Could not collect JS coverage: %s', error.message);
129
149
  js = {
@@ -161,12 +181,13 @@ export class Coverage {
161
181
  }
162
182
 
163
183
  this.started = false;
164
- return { js, css };
184
+ return { js, css, resourceLoaderBundles };
165
185
  }
166
186
 
167
187
  async #processJs(scripts) {
168
188
  const client = this.rawClient;
169
189
  const files = [];
190
+ const resourceLoaderBundles = new Map();
170
191
  for (const script of scripts) {
171
192
  if (!script.url || !/^https?:/i.test(script.url)) {
172
193
  continue;
@@ -185,15 +206,29 @@ export class Coverage {
185
206
 
186
207
  const usedBytes = usedJsBytes(script, totalBytes);
187
208
  const unusedBytes = totalBytes - usedBytes;
188
- files.push({
209
+ const file = {
189
210
  url: script.url,
190
211
  totalBytes,
191
212
  usedBytes,
192
213
  unusedBytes,
193
214
  unusedPercent: (unusedBytes / totalBytes) * 100
194
- });
215
+ };
216
+ const label = labelForUrl(script.url);
217
+ if (label) file.label = label;
218
+ if (isResourceLoaderBundle(script.url, source)) {
219
+ const modules = resourceLoaderModuleCoverage(
220
+ source,
221
+ paintJsCoverage(script, totalBytes)
222
+ );
223
+ if (modules) {
224
+ file.modules = modules;
225
+ const resolver = resourceLoaderLocationResolver(source);
226
+ if (resolver) resourceLoaderBundles.set(script.url, resolver);
227
+ }
228
+ }
229
+ files.push(file);
195
230
  }
196
- return { ...summarize(files), files };
231
+ return { js: { ...summarize(files), files }, resourceLoaderBundles };
197
232
  }
198
233
 
199
234
  async #processCss(ruleUsage) {
@@ -204,6 +239,11 @@ export class Coverage {
204
239
  list.push(u);
205
240
  byStylesheet.set(u.styleSheetId, list);
206
241
  }
242
+ // Inline stylesheets are only worth attributing on MediaWiki pages
243
+ // (TemplateStyles); ordinary pages skip the extra DOM roundtrips.
244
+ const mediaWiki = [...this.styleSheets.values()].some(sheet =>
245
+ labelForUrl(sheet.url)
246
+ );
207
247
  const files = [];
208
248
  for (const [styleSheetId, usages] of byStylesheet) {
209
249
  let text;
@@ -218,15 +258,45 @@ export class Coverage {
218
258
 
219
259
  const usedBytes = unionLength(usages.filter(u => u.used));
220
260
  const unusedBytes = totalBytes - usedBytes;
221
- files.push({
222
- url: this.styleSheetUrls.get(styleSheetId) || '',
261
+ const sheet = this.styleSheets.get(styleSheetId) || {};
262
+ const url = sheet.url || '';
263
+ const file = {
264
+ url,
223
265
  styleSheetId,
224
266
  totalBytes,
225
267
  usedBytes,
226
268
  unusedBytes,
227
269
  unusedPercent: (unusedBytes / totalBytes) * 100
228
- });
270
+ };
271
+ let label = labelForUrl(url);
272
+ if (!label && mediaWiki) {
273
+ label = await this.#inlineStyleLabel(sheet);
274
+ }
275
+ if (label) file.label = label;
276
+ files.push(file);
229
277
  }
230
278
  return { ...summarize(files), files };
231
279
  }
280
+
281
+ // Inline stylesheets have no URL of their own: parser-inserted
282
+ // <style> sheets report the document URL as sourceURL (isInline is
283
+ // true) and JS-injected <style> sheets report an empty string. The
284
+ // owning <style> element's attributes are the only identity they
285
+ // carry — MediaWiki TemplateStyles stamps data-mw-deduplicate there —
286
+ // so resolve them via the DOM domain. header.ownerNode is a backend
287
+ // node id, which DOM.describeNode accepts directly.
288
+ async #inlineStyleLabel({ ownerNode, isInline, url }) {
289
+ if (ownerNode === undefined || (!isInline && url)) return;
290
+ try {
291
+ const { node } = await this.rawClient.DOM.describeNode({
292
+ backendNodeId: ownerNode
293
+ });
294
+ return labelForStyleAttributes(node && node.attributes);
295
+ } catch (error) {
296
+ log.debug(
297
+ 'Could not resolve inline stylesheet owner node: %s',
298
+ error.message
299
+ );
300
+ }
301
+ }
232
302
  }
@@ -0,0 +1,223 @@
1
+ // MediaWiki-specific: per-module coverage breakdown for ResourceLoader
2
+ // bundles. MediaWiki serves many JS modules concatenated into a single
3
+ // load.php response. Each module in the bundle is delimited by an
4
+ // mw.loader.impl(...) call whose payload starts with "<name>@<versionHash>".
5
+ // Observed format (en.wikipedia.org, MediaWiki 1.4x, July 2026), one call
6
+ // per line:
7
+ //
8
+ // mw.loader.impl(function(){return["ext.popups.main@4i3g7",function(...
9
+ // mw.loader.impl(function(){return["mediawiki.base@h9kwf",{"main":...
10
+ //
11
+ // The delimiter is anchored to the start of a line (ResourceLoader emits
12
+ // each impl call on its own line), which keeps mw.loader.impl occurrences
13
+ // inside string literals from creating phantom modules unless the literal
14
+ // happens to reproduce the full delimiter at a line start — an accepted
15
+ // limitation. Trailing bundle content after the last module (for example
16
+ // mw.loader.state(...)) is attributed to the last module.
17
+ const MODULE_DELIMITER =
18
+ /^mw\.loader\.impl\(function\(\)\{return\["([\w.-]+)@([^"]*)",/gm;
19
+
20
+ export function isResourceLoaderBundle(url, source) {
21
+ return url.includes('load.php') || source.startsWith('mw.loader.impl(');
22
+ }
23
+
24
+ // MediaWiki-specific: stable, short labels for ResourceLoader load.php
25
+ // URLs. The raw URLs are >1000 chars and carry a version= parameter
26
+ // that changes on every deploy, so any dashboard keying a time series
27
+ // on the URL gets a brand-new key every week. The label is derived
28
+ // only from the request's role — the version, lang and skin parameters
29
+ // are never read — so the same bundle keeps the same label across
30
+ // deploys.
31
+ //
32
+ // Labeling rules (a URL qualifies when its path ends in /load.php):
33
+ // modules=startup -> load.php[startup]
34
+ // single module, only=styles -> load.php[styles:<module>]
35
+ // (e.g. load.php[styles:site.styles])
36
+ // single module, otherwise -> load.php[scripts:<module>]
37
+ // multi-module, only=styles -> load.php[styles]
38
+ // (the top-queue stylesheet batch)
39
+ // multi-module, only=scripts -> load.php[scripts:<first>]
40
+ // where <first> is the first name in
41
+ // the sorted expanded modules list
42
+ // (e.g. the base bundle
43
+ // jquery|mediawiki.base becomes
44
+ // load.php[scripts:jquery]); raw
45
+ // script batches are disambiguated
46
+ // this way so they never collide
47
+ // with the general bundle below
48
+ // multi-module, no only param -> load.php[scripts]
49
+ // (the big general batch of packaged
50
+ // mw.loader.impl modules)
51
+ //
52
+ // Several combined (no `only`) batches on one page — the main queue
53
+ // plus lazy-loaded batches — intentionally share load.php[scripts]:
54
+ // they are the same kind of payload and label-keyed dashboards want
55
+ // them summed. Exact identity always remains in the untouched url
56
+ // field; the label is additive.
57
+ const RESOURCE_LOADER_PATH = /\/load\.php$/;
58
+
59
+ // Port of MediaWiki's ResourceLoader::expandModuleNames: groups are
60
+ // pipe-separated; within a group, "foo.bar,baz" expands to foo.bar and
61
+ // foo.baz (the packer only groups modules sharing the prefix before
62
+ // their last dot, so suffixes never contain dots).
63
+ function expandModuleNames(packed) {
64
+ const names = [];
65
+ for (const group of packed.split('|')) {
66
+ if (!group.includes(',')) {
67
+ if (group) names.push(group);
68
+ continue;
69
+ }
70
+ const pos = group.lastIndexOf('.');
71
+ if (pos === -1) {
72
+ names.push(...group.split(','));
73
+ continue;
74
+ }
75
+ const prefix = group.slice(0, pos);
76
+ for (const suffix of group.slice(pos + 1).split(',')) {
77
+ names.push(`${prefix}.${suffix}`);
78
+ }
79
+ }
80
+ return names.toSorted();
81
+ }
82
+
83
+ export function labelForUrl(url) {
84
+ if (typeof url !== 'string' || !url.includes('load.php')) return;
85
+ let parsed;
86
+ try {
87
+ parsed = new URL(url);
88
+ } catch {
89
+ return;
90
+ }
91
+ if (!RESOURCE_LOADER_PATH.test(parsed.pathname)) return;
92
+
93
+ const only = parsed.searchParams.get('only');
94
+ const modules = expandModuleNames(parsed.searchParams.get('modules') || '');
95
+ if (modules.length === 1 && modules[0] === 'startup') {
96
+ return 'load.php[startup]';
97
+ }
98
+ const kind = only === 'styles' ? 'styles' : 'scripts';
99
+ if (modules.length === 1) {
100
+ return `load.php[${kind}:${modules[0]}]`;
101
+ }
102
+ if (modules.length > 1 && only === 'scripts') {
103
+ return `load.php[scripts:${modules[0]}]`;
104
+ }
105
+ return `load.php[${kind}]`;
106
+ }
107
+
108
+ // MediaWiki-specific: TemplateStyles. Each templated style block is
109
+ // emitted as <style data-mw-deduplicate="TemplateStyles:rNNNNNNN">, so
110
+ // the deduplication key is the only stable, human-actionable identity
111
+ // an inline stylesheet has — it points straight at the template
112
+ // revision that ships the CSS. Keys may carry a /suffix when the same
113
+ // revision is re-emitted under a different wrapper class (observed on
114
+ // en.wikipedia.org: "TemplateStyles:r1349637415/mw-parser-output/.tmulti");
115
+ // the full value is kept as the label. `attributes` is the flat
116
+ // [name, value, name, value, ...] array that CDP DOM.describeNode
117
+ // returns for the owning <style> element.
118
+ const TEMPLATE_STYLES_KEY = /^TemplateStyles:r\d+(\/|$)/;
119
+
120
+ export function labelForStyleAttributes(attributes) {
121
+ if (!Array.isArray(attributes)) return;
122
+ for (let index = 0; index + 1 < attributes.length; index += 2) {
123
+ if (
124
+ attributes[index] === 'data-mw-deduplicate' &&
125
+ TEMPLATE_STYLES_KEY.test(attributes[index + 1])
126
+ ) {
127
+ return attributes[index + 1];
128
+ }
129
+ }
130
+ }
131
+
132
+ function moduleBoundaries(source) {
133
+ const boundaries = [];
134
+ for (const match of source.matchAll(MODULE_DELIMITER)) {
135
+ boundaries.push({
136
+ name: match[1],
137
+ version: match[2],
138
+ start: match.index
139
+ });
140
+ }
141
+ return boundaries;
142
+ }
143
+
144
+ // Map a source position from a Chrome trace stack frame to the module
145
+ // that owns that byte of the bundle, for per-module CPU attribution.
146
+ // Trace event locations (FunctionCall args.data and stackTrace frames)
147
+ // are 1-based for both lineNumber and columnNumber — Blink adds 1 to
148
+ // V8's 0-based script positions when it emits devtools.timeline
149
+ // events. resolve() returns { name, version } when the position lands
150
+ // inside a named module, and undefined for the preamble or positions
151
+ // outside the source, so callers can bucket unattributable time
152
+ // separately. Returns undefined when the source contains no module
153
+ // delimiters (e.g. the startup bundle, which is plain JS).
154
+ export function resourceLoaderLocationResolver(source) {
155
+ const boundaries = moduleBoundaries(source);
156
+ if (boundaries.length === 0) return;
157
+
158
+ const sourceLength = source.length;
159
+ const lineOffsets = [0];
160
+ for (
161
+ let index = source.indexOf('\n');
162
+ index !== -1;
163
+ index = source.indexOf('\n', index + 1)
164
+ ) {
165
+ lineOffsets.push(index + 1);
166
+ }
167
+
168
+ return {
169
+ resolve(lineNumber, columnNumber) {
170
+ const line = lineNumber - 1;
171
+ if (line < 0 || line >= lineOffsets.length) return;
172
+ const offset = lineOffsets[line] + (columnNumber - 1);
173
+ if (offset < boundaries[0].start || offset >= sourceLength) return;
174
+ let low = 0;
175
+ let high = boundaries.length - 1;
176
+ while (low < high) {
177
+ const mid = (low + high + 1) >> 1;
178
+ if (boundaries[mid].start <= offset) {
179
+ low = mid;
180
+ } else {
181
+ high = mid - 1;
182
+ }
183
+ }
184
+ const { name, version } = boundaries[low];
185
+ return { name, version };
186
+ }
187
+ };
188
+ }
189
+
190
+ // Intersect the per-byte coverage flags (painted, a Uint8Array where 1 =
191
+ // used) with the module boundaries in the bundle source. Module i spans
192
+ // [start_i, start_i+1); the last module ends at the end of the source.
193
+ // Bytes before the first delimiter go to a '(preamble)' bucket. Returns
194
+ // undefined when the source contains no delimiters.
195
+ export function resourceLoaderModuleCoverage(source, painted) {
196
+ const boundaries = moduleBoundaries(source);
197
+ if (boundaries.length === 0) return;
198
+
199
+ if (boundaries[0].start > 0) {
200
+ boundaries.unshift({ name: '(preamble)', version: '', start: 0 });
201
+ }
202
+
203
+ const modules = [];
204
+ for (const [i, boundary] of boundaries.entries()) {
205
+ const end =
206
+ i + 1 < boundaries.length ? boundaries[i + 1].start : source.length;
207
+ const totalBytes = end - boundary.start;
208
+ let usedBytes = 0;
209
+ for (let b = boundary.start; b < end; b++) {
210
+ if (painted[b] === 1) usedBytes++;
211
+ }
212
+ const unusedBytes = totalBytes - usedBytes;
213
+ modules.push({
214
+ name: boundary.name,
215
+ version: boundary.version,
216
+ totalBytes,
217
+ usedBytes,
218
+ unusedBytes,
219
+ unusedPercent: totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0
220
+ });
221
+ }
222
+ return modules.toSorted((a, b) => b.unusedBytes - a.unusedBytes);
223
+ }
@@ -3,10 +3,28 @@ import {
3
3
  computeMainThreadTasks,
4
4
  computeScriptCosts,
5
5
  computeForcedReflows,
6
- computeNonCompositedAnimations
6
+ computeNonCompositedAnimations,
7
+ computeBlockingTime,
8
+ computeDomainBreakdown,
9
+ computeFrameStability,
10
+ computeStyleInvalidations,
11
+ computeSelectorStats,
12
+ computeTimerCosts
7
13
  } from './trace/index.js';
14
+ import { labelForUrl } from './mediawikiResourceLoader.js';
8
15
  const log = getLogger('browsertime.chrome.cpu');
9
16
 
17
+ // Additive label field for MediaWiki ResourceLoader URLs so per-bundle
18
+ // time series survive the weekly version= churn. The url field is
19
+ // untouched (browsertime.json shape is a public contract); non-MediaWiki
20
+ // URLs get no label and the output stays byte-identical to before.
21
+ function addResourceLoaderLabels(entries, urlField = 'url', field = 'label') {
22
+ for (const entry of entries) {
23
+ const label = labelForUrl(entry[urlField]);
24
+ if (label) entry[field] = label;
25
+ }
26
+ }
27
+
10
28
  function round(number_, decimals = 3) {
11
29
  const pow = Math.pow(10, decimals);
12
30
  return Math.round(number_ * pow) / pow;
@@ -105,6 +123,67 @@ export async function parseCPUTrace(tracelog, url) {
105
123
  } catch (error) {
106
124
  log.debug('computeNonCompositedAnimations failed: %s', error.message);
107
125
  }
126
+ // These return objects with summary totals, so on failure the
127
+ // key is omitted instead of publishing a misleading zero total.
128
+ let blocking;
129
+ try {
130
+ blocking = computeBlockingTime(tracelog);
131
+ } catch (error) {
132
+ log.debug('computeBlockingTime failed: %s', error.message);
133
+ }
134
+ let domains;
135
+ try {
136
+ domains = computeDomainBreakdown(tracelog, url);
137
+ } catch (error) {
138
+ log.debug('computeDomainBreakdown failed: %s', error.message);
139
+ }
140
+ let frames;
141
+ try {
142
+ frames = computeFrameStability(tracelog);
143
+ } catch (error) {
144
+ log.debug('computeFrameStability failed: %s', error.message);
145
+ }
146
+ let styleInvalidations;
147
+ try {
148
+ styleInvalidations = computeStyleInvalidations(tracelog);
149
+ } catch (error) {
150
+ log.debug('computeStyleInvalidations failed: %s', error.message);
151
+ }
152
+ // Only produces output when the SelectorStats trace category is
153
+ // on (--enableProfileRun); undefined omits the key.
154
+ let selectorStats;
155
+ try {
156
+ selectorStats = computeSelectorStats(tracelog);
157
+ } catch (error) {
158
+ log.debug('computeSelectorStats failed: %s', error.message);
159
+ }
160
+ let timers;
161
+ try {
162
+ timers = computeTimerCosts(tracelog);
163
+ } catch (error) {
164
+ log.debug('computeTimerCosts failed: %s', error.message);
165
+ }
166
+
167
+ addResourceLoaderLabels(cleanedUrls);
168
+ addResourceLoaderLabels(scriptCosts);
169
+ addResourceLoaderLabels(
170
+ forcedReflows,
171
+ 'triggeredByUrl',
172
+ 'triggeredByUrlLabel'
173
+ );
174
+ if (blocking) {
175
+ addResourceLoaderLabels(blocking.urls);
176
+ if (blocking.beforeLargestContentfulPaint) {
177
+ addResourceLoaderLabels(blocking.beforeLargestContentfulPaint.urls);
178
+ }
179
+ }
180
+ if (styleInvalidations) {
181
+ addResourceLoaderLabels(styleInvalidations.sources);
182
+ }
183
+ if (timers) {
184
+ addResourceLoaderLabels(timers.byUrl);
185
+ addResourceLoaderLabels(timers.sites);
186
+ }
108
187
 
109
188
  log.debug('Chrome trace log finished parsed and sorted.');
110
189
  return {
@@ -113,7 +192,13 @@ export async function parseCPUTrace(tracelog, url) {
113
192
  urls: cleanedUrls,
114
193
  scriptCosts,
115
194
  forcedReflows,
116
- nonCompositedAnimations
195
+ nonCompositedAnimations,
196
+ ...(blocking ? { blocking } : {}),
197
+ ...(domains ? { domains } : {}),
198
+ ...(frames ? { frames } : {}),
199
+ ...(styleInvalidations ? { styleInvalidations } : {}),
200
+ ...(selectorStats ? { selectorStats } : {}),
201
+ ...(timers ? { timers } : {})
117
202
  };
118
203
  } catch (error) {
119
204
  log.error(