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
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
# Browsertime changelog (we do [semantic versioning](https://semver.org))
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 28.1.0 - 2026-07-17
|
|
4
4
|
|
|
5
5
|
### Added
|
|
6
|
+
* Long Animation Frames (`pageinfo.loaf`) now ship every per-script field the LoAF API exposes (`startTime`, `duration`, `executionStart`, `pauseDuration`) plus `startTime` and `firstUIEventTimestamp` per frame — enough to attribute blocking time to individual scripts, place each frame on the page timeline and spot frames where an input was waiting [#2522](https://github.com/sitespeedio/browsertime/pull/2522) [#2523](https://github.com/sitespeedio/browsertime/pull/2523) [#2526](https://github.com/sitespeedio/browsertime/pull/2526).
|
|
7
|
+
* New `pageinfo.loafSummary` key (`totalFrames`, `totalBlockingDuration`, `totalDuration`): `pageinfo.loaf` only ships the 10 frames with the most blocking, so totals computed from it understate busy pages [#2524](https://github.com/sitespeedio/browsertime/pull/2524).
|
|
8
|
+
* LoAF script URLs get the same ResourceLoader `label`s as `cpu.urls` (`load.php[startup]` and friends) [#2525](https://github.com/sitespeedio/browsertime/pull/2525).
|
|
9
|
+
* Multi-module `load.php` batches are labeled with their first module name — pages lazy-load several general batches, so the bare `load.php[scripts]` label collided as soon as there was more than one [#2527](https://github.com/sitespeedio/browsertime/pull/2527).
|
|
10
|
+
|
|
11
|
+
## 28.0.0 - 2026-07-17
|
|
12
|
+
|
|
13
|
+
### Breaking
|
|
14
|
+
* 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).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
* 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).
|
|
18
|
+
* 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).
|
|
19
|
+
* `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).
|
|
20
|
+
* 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).
|
|
21
|
+
* 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).
|
|
22
|
+
* 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).
|
|
23
|
+
* 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).
|
|
24
|
+
* `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).
|
|
25
|
+
* 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).
|
|
26
|
+
* 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).
|
|
27
|
+
* 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).
|
|
28
|
+
* 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.
|
|
29
|
+
* 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).
|
|
30
|
+
* 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).
|
|
6
31
|
* Bumped `chromedriver` and `edgedriver` to 149 [#2487](https://github.com/sitespeedio/browsertime/pull/2487).
|
|
7
32
|
* 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).
|
|
33
|
+
* Updated `@sitespeed.io/throttle` to 6.0.1 [#2499](https://github.com/sitespeedio/browsertime/pull/2499).
|
|
34
|
+
* 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).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
* 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).
|
|
38
|
+
* 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).
|
|
8
39
|
|
|
9
40
|
### Fixed
|
|
10
|
-
*
|
|
41
|
+
* `--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).
|
|
42
|
+
* Navigation-script failures now include the URL and iteration number in the error message [#2489](https://github.com/sitespeedio/browsertime/pull/2489).
|
|
11
43
|
|
|
12
44
|
## 27.4.1 - 2026-05-25
|
|
13
45
|
|
package/bin/browsertime.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 =
|
|
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) {
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
// re-package
|
|
18
18
|
for (let entry of longestBlockingLoAFs) {
|
|
19
19
|
const info = {};
|
|
20
|
+
// startTime places the frame on the page timeline (before or
|
|
21
|
+
// after FCP/LCP/load); firstUIEventTimestamp > 0 means an input
|
|
22
|
+
// was waiting while this frame blocked — the INP connection.
|
|
23
|
+
info.startTime = entry.startTime;
|
|
24
|
+
info.firstUIEventTimestamp = entry.firstUIEventTimestamp;
|
|
20
25
|
info.blockingDuration = entry.blockingDuration;
|
|
21
26
|
info.duration = entry.duration;
|
|
22
27
|
info.styleAndLayoutStart = entry.styleAndLayoutStart;
|
|
@@ -29,6 +34,16 @@
|
|
|
29
34
|
info.scripts = [];
|
|
30
35
|
for (let script of entry.scripts) {
|
|
31
36
|
const s = {};
|
|
37
|
+
// startTime + duration make per-script attribution possible:
|
|
38
|
+
// without them a frame's blocking can only be credited to
|
|
39
|
+
// every script that ran in it.
|
|
40
|
+
s.startTime = script.startTime;
|
|
41
|
+
s.duration = script.duration;
|
|
42
|
+
// executionStart - startTime is queue/compile delay before the
|
|
43
|
+
// script ran; pauseDuration separates "computed the whole time"
|
|
44
|
+
// from "sat in a sync pause" (alert, sync XHR).
|
|
45
|
+
s.executionStart = script.executionStart;
|
|
46
|
+
s.pauseDuration = script.pauseDuration;
|
|
32
47
|
s.forcedStyleAndLayoutDuration = script.forcedStyleAndLayoutDuration;
|
|
33
48
|
s.invoker = script.invoker;
|
|
34
49
|
s.invokerType = script.invokerType;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
// The loaf script ships only the 10 frames with the most blocking
|
|
3
|
+
// time, so its numbers understate busy pages. These totals are
|
|
4
|
+
// computed over every long animation frame before the cut.
|
|
5
|
+
if (
|
|
6
|
+
PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')
|
|
7
|
+
) {
|
|
8
|
+
const observer = new PerformanceObserver(list => {});
|
|
9
|
+
observer.observe({type: 'long-animation-frame', buffered: true});
|
|
10
|
+
const entries = observer.takeRecords();
|
|
11
|
+
|
|
12
|
+
let totalBlockingDuration = 0;
|
|
13
|
+
let totalDuration = 0;
|
|
14
|
+
for (let entry of entries) {
|
|
15
|
+
totalBlockingDuration += entry.blockingDuration;
|
|
16
|
+
totalDuration += entry.duration;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
totalFrames: entries.length,
|
|
20
|
+
totalBlockingDuration,
|
|
21
|
+
totalDuration
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
})();
|
package/lib/chrome/coverage.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
105
|
+
this.styleSheets.clear();
|
|
94
106
|
this.disposeStyleSheetListener = client.CSS.styleSheetAdded(
|
|
95
107
|
({ header }) => {
|
|
96
108
|
if (header && header.styleSheetId) {
|
|
97
|
-
this.
|
|
98
|
-
header.
|
|
99
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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,225 @@
|
|
|
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
|
+
// Every multi-module batch carries its (alphabetically) first module
|
|
100
|
+
// name. Real pages lazy-load several general batches (no `only=`)
|
|
101
|
+
// over time, so a bare load.php[scripts] label collides as soon as
|
|
102
|
+
// there is more than one — and the single-module case already names
|
|
103
|
+
// general batches like raw ones.
|
|
104
|
+
if (modules.length > 0) {
|
|
105
|
+
return `load.php[${kind}:${modules[0]}]`;
|
|
106
|
+
}
|
|
107
|
+
return `load.php[${kind}]`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MediaWiki-specific: TemplateStyles. Each templated style block is
|
|
111
|
+
// emitted as <style data-mw-deduplicate="TemplateStyles:rNNNNNNN">, so
|
|
112
|
+
// the deduplication key is the only stable, human-actionable identity
|
|
113
|
+
// an inline stylesheet has — it points straight at the template
|
|
114
|
+
// revision that ships the CSS. Keys may carry a /suffix when the same
|
|
115
|
+
// revision is re-emitted under a different wrapper class (observed on
|
|
116
|
+
// en.wikipedia.org: "TemplateStyles:r1349637415/mw-parser-output/.tmulti");
|
|
117
|
+
// the full value is kept as the label. `attributes` is the flat
|
|
118
|
+
// [name, value, name, value, ...] array that CDP DOM.describeNode
|
|
119
|
+
// returns for the owning <style> element.
|
|
120
|
+
const TEMPLATE_STYLES_KEY = /^TemplateStyles:r\d+(\/|$)/;
|
|
121
|
+
|
|
122
|
+
export function labelForStyleAttributes(attributes) {
|
|
123
|
+
if (!Array.isArray(attributes)) return;
|
|
124
|
+
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
|
125
|
+
if (
|
|
126
|
+
attributes[index] === 'data-mw-deduplicate' &&
|
|
127
|
+
TEMPLATE_STYLES_KEY.test(attributes[index + 1])
|
|
128
|
+
) {
|
|
129
|
+
return attributes[index + 1];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function moduleBoundaries(source) {
|
|
135
|
+
const boundaries = [];
|
|
136
|
+
for (const match of source.matchAll(MODULE_DELIMITER)) {
|
|
137
|
+
boundaries.push({
|
|
138
|
+
name: match[1],
|
|
139
|
+
version: match[2],
|
|
140
|
+
start: match.index
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return boundaries;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Map a source position from a Chrome trace stack frame to the module
|
|
147
|
+
// that owns that byte of the bundle, for per-module CPU attribution.
|
|
148
|
+
// Trace event locations (FunctionCall args.data and stackTrace frames)
|
|
149
|
+
// are 1-based for both lineNumber and columnNumber — Blink adds 1 to
|
|
150
|
+
// V8's 0-based script positions when it emits devtools.timeline
|
|
151
|
+
// events. resolve() returns { name, version } when the position lands
|
|
152
|
+
// inside a named module, and undefined for the preamble or positions
|
|
153
|
+
// outside the source, so callers can bucket unattributable time
|
|
154
|
+
// separately. Returns undefined when the source contains no module
|
|
155
|
+
// delimiters (e.g. the startup bundle, which is plain JS).
|
|
156
|
+
export function resourceLoaderLocationResolver(source) {
|
|
157
|
+
const boundaries = moduleBoundaries(source);
|
|
158
|
+
if (boundaries.length === 0) return;
|
|
159
|
+
|
|
160
|
+
const sourceLength = source.length;
|
|
161
|
+
const lineOffsets = [0];
|
|
162
|
+
for (
|
|
163
|
+
let index = source.indexOf('\n');
|
|
164
|
+
index !== -1;
|
|
165
|
+
index = source.indexOf('\n', index + 1)
|
|
166
|
+
) {
|
|
167
|
+
lineOffsets.push(index + 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
resolve(lineNumber, columnNumber) {
|
|
172
|
+
const line = lineNumber - 1;
|
|
173
|
+
if (line < 0 || line >= lineOffsets.length) return;
|
|
174
|
+
const offset = lineOffsets[line] + (columnNumber - 1);
|
|
175
|
+
if (offset < boundaries[0].start || offset >= sourceLength) return;
|
|
176
|
+
let low = 0;
|
|
177
|
+
let high = boundaries.length - 1;
|
|
178
|
+
while (low < high) {
|
|
179
|
+
const mid = (low + high + 1) >> 1;
|
|
180
|
+
if (boundaries[mid].start <= offset) {
|
|
181
|
+
low = mid;
|
|
182
|
+
} else {
|
|
183
|
+
high = mid - 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const { name, version } = boundaries[low];
|
|
187
|
+
return { name, version };
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Intersect the per-byte coverage flags (painted, a Uint8Array where 1 =
|
|
193
|
+
// used) with the module boundaries in the bundle source. Module i spans
|
|
194
|
+
// [start_i, start_i+1); the last module ends at the end of the source.
|
|
195
|
+
// Bytes before the first delimiter go to a '(preamble)' bucket. Returns
|
|
196
|
+
// undefined when the source contains no delimiters.
|
|
197
|
+
export function resourceLoaderModuleCoverage(source, painted) {
|
|
198
|
+
const boundaries = moduleBoundaries(source);
|
|
199
|
+
if (boundaries.length === 0) return;
|
|
200
|
+
|
|
201
|
+
if (boundaries[0].start > 0) {
|
|
202
|
+
boundaries.unshift({ name: '(preamble)', version: '', start: 0 });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const modules = [];
|
|
206
|
+
for (const [i, boundary] of boundaries.entries()) {
|
|
207
|
+
const end =
|
|
208
|
+
i + 1 < boundaries.length ? boundaries[i + 1].start : source.length;
|
|
209
|
+
const totalBytes = end - boundary.start;
|
|
210
|
+
let usedBytes = 0;
|
|
211
|
+
for (let b = boundary.start; b < end; b++) {
|
|
212
|
+
if (painted[b] === 1) usedBytes++;
|
|
213
|
+
}
|
|
214
|
+
const unusedBytes = totalBytes - usedBytes;
|
|
215
|
+
modules.push({
|
|
216
|
+
name: boundary.name,
|
|
217
|
+
version: boundary.version,
|
|
218
|
+
totalBytes,
|
|
219
|
+
usedBytes,
|
|
220
|
+
unusedBytes,
|
|
221
|
+
unusedPercent: totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return modules.toSorted((a, b) => b.unusedBytes - a.unusedBytes);
|
|
225
|
+
}
|