rolldown-perf-kit 0.0.0-stage → 0.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/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { heavyPrepaintTypes, weightLabel } from './measure.mjs';
|
|
2
|
+
|
|
3
|
+
/** A gain in one paint metric must not hide a regression in the other. */
|
|
4
|
+
export function comparePaints(deltas, { noiseFloorMs, noiseFloorPct }) {
|
|
5
|
+
const metrics = Object.fromEntries(
|
|
6
|
+
['fcp', 'lcp'].map((paint) => {
|
|
7
|
+
const delta = deltas?.[`runtime.${paint}_ms`];
|
|
8
|
+
if (!delta || !Number.isFinite(delta.prev) || !Number.isFinite(delta.delta)) {
|
|
9
|
+
return [paint, { state: 'unknown' }];
|
|
10
|
+
}
|
|
11
|
+
const thresholdMs = Math.max(noiseFloorMs, (noiseFloorPct / 100) * delta.prev);
|
|
12
|
+
const state =
|
|
13
|
+
delta.delta <= -thresholdMs
|
|
14
|
+
? 'improved'
|
|
15
|
+
: delta.delta >= thresholdMs
|
|
16
|
+
? 'regressed'
|
|
17
|
+
: 'within-noise';
|
|
18
|
+
return [paint, { ...delta, thresholdMs, state }];
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
const states = Object.values(metrics).map((metric) => metric.state);
|
|
22
|
+
const state = states.includes('regressed')
|
|
23
|
+
? 'regressed'
|
|
24
|
+
: states.includes('unknown')
|
|
25
|
+
? 'unknown'
|
|
26
|
+
: states.includes('improved')
|
|
27
|
+
? 'improved'
|
|
28
|
+
: 'within-noise';
|
|
29
|
+
return { state, metrics };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Concurrent observations are candidates to inspect, not proof that paint waited. */
|
|
33
|
+
export function renderGapEvidence(report) {
|
|
34
|
+
const evidence = [];
|
|
35
|
+
for (const paint of ['fcp', 'lcp']) {
|
|
36
|
+
const scripts = report.postLoadScripts?.[paint];
|
|
37
|
+
if (scripts?.count > 0) {
|
|
38
|
+
evidence.push({
|
|
39
|
+
kind: 'script',
|
|
40
|
+
paint,
|
|
41
|
+
detail: `${scripts.count} script(s) started after load and finished before ${paint.toUpperCase()} (${(scripts.bytes / 1024).toFixed(1)}KiB)`,
|
|
42
|
+
next: `Inspect chunkDiscovery.${paint} and the request initiator. Check which downloads rendering awaits before changing preload timing.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const fetches = report.gatingFetches ?? [];
|
|
47
|
+
if (fetches.length) {
|
|
48
|
+
evidence.push({
|
|
49
|
+
kind: 'fetch',
|
|
50
|
+
paint: 'fcp',
|
|
51
|
+
detail: `fetch/XHR before FCP: ${fetches.join('; ')}`,
|
|
52
|
+
next: 'Check whether initial rendering awaits these responses. Test moving only optional data reads after paint.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const row of heavyPrepaintTypes(report.resourceWeight)) {
|
|
56
|
+
evidence.push({
|
|
57
|
+
kind: row.type,
|
|
58
|
+
paint: 'fcp',
|
|
59
|
+
detail: `before FCP: ${weightLabel(row)}`,
|
|
60
|
+
next:
|
|
61
|
+
row.type === 'font'
|
|
62
|
+
? 'Check which fonts visible text needs. Test a smaller font subset or fewer font variants before paint.'
|
|
63
|
+
: 'Check which image is the LCP element. Keep its request early. Test deferring images outside the first view.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return evidence;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function chunkDiscoveryAdvice(paint) {
|
|
70
|
+
const target = paint === 'fcp' ? 'the first visible content' : 'the LCP element';
|
|
71
|
+
return (
|
|
72
|
+
`Trace the import chain and request initiator in the source. Check whether the chunk renders ${target}. ` +
|
|
73
|
+
'If paint needs it, test an earlier preload or a different chunk boundary. ' +
|
|
74
|
+
'If paint does not need it, test deferring its load and work. Compare both FCP and LCP with the pinned baseline.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The original build parity compares every later build against (the "anchor").
|
|
2
|
+
// Saved once - by the first scan / measure / parity of a target - as a full copy
|
|
3
|
+
// of the build directory, not as screenshots: each check re-renders it next to the
|
|
4
|
+
// candidate in the same browser session, so a Chrome update, a newly installed
|
|
5
|
+
// font or machine load can never surface as a difference.
|
|
6
|
+
//
|
|
7
|
+
// Unlike the perf baseline, the optimize loop never re-pins it. Compared to the
|
|
8
|
+
// step before it, a broken step looks clean and becomes the reference for the next
|
|
9
|
+
// one; compared to the original, every step answers the only question that
|
|
10
|
+
// matters - does this build still render what the original rendered?
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set(['node_modules', '.git']);
|
|
18
|
+
/** Discovered entry pages beyond this are listed, not pinned (pin them with --pages). */
|
|
19
|
+
export const DISCOVER_LIMIT = 12;
|
|
20
|
+
|
|
21
|
+
export function parityPaths(targetDir) {
|
|
22
|
+
const dir = path.join(targetDir, 'parity');
|
|
23
|
+
return {
|
|
24
|
+
dir,
|
|
25
|
+
anchorDir: path.join(dir, 'anchor'),
|
|
26
|
+
anchorDist: path.join(dir, 'anchor', 'dist'),
|
|
27
|
+
manifest: path.join(dir, 'anchor', 'manifest.json'),
|
|
28
|
+
report: path.join(dir, 'report.json'),
|
|
29
|
+
approvals: path.join(dir, 'approvals.json'),
|
|
30
|
+
pages: path.join(dir, 'pages'),
|
|
31
|
+
// recover mode: present while a check has found a difference not yet fixed
|
|
32
|
+
recover: path.join(dir, 'recover.json'),
|
|
33
|
+
// every check, in order, with a copy of its evidence images
|
|
34
|
+
history: path.join(dir, 'history.jsonl'),
|
|
35
|
+
snapshots: path.join(dir, 'snapshots'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const excluded = (file, exclude) =>
|
|
40
|
+
exclude.some((e) => file === e || file.startsWith(e + path.sep));
|
|
41
|
+
|
|
42
|
+
/** Every file under dir as a sorted list of '/'-separated relative paths. */
|
|
43
|
+
function listFiles(dir, exclude = []) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const visit = (current) => {
|
|
46
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
47
|
+
const full = path.join(current, entry.name);
|
|
48
|
+
if (excluded(full, exclude)) continue;
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (!SKIP_DIRS.has(entry.name)) visit(full);
|
|
51
|
+
} else if (entry.isFile()) {
|
|
52
|
+
out.push(path.relative(dir, full).split(path.sep).join('/'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
visit(dir);
|
|
57
|
+
// Code-unit order, not locale order: the hash must not depend on the machine.
|
|
58
|
+
return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Content hash of a build: every file's path and bytes. Two builds with the same
|
|
63
|
+
* hash render the same by construction, which lets parity skip the candidate
|
|
64
|
+
* captures entirely (first scan, or every change reverted).
|
|
65
|
+
*/
|
|
66
|
+
export function hashDist(dir, { exclude = [] } = {}) {
|
|
67
|
+
const hash = crypto.createHash('sha256');
|
|
68
|
+
for (const rel of listFiles(dir, exclude)) {
|
|
69
|
+
hash.update(`${rel}\0`);
|
|
70
|
+
hash.update(
|
|
71
|
+
crypto
|
|
72
|
+
.createHash('sha256')
|
|
73
|
+
.update(fs.readFileSync(path.join(dir, rel)))
|
|
74
|
+
.digest(),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return hash.digest('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Does this HTML file load bundled output (a local script, stylesheet or modulepreload)? */
|
|
81
|
+
function loadsBundle(html) {
|
|
82
|
+
const local = (url) => Boolean(url) && !/^(?:[a-z]+:|\/\/)/i.test(url);
|
|
83
|
+
for (const tag of html.match(/<(?:script|link)\b[^>]*>/gi) ?? []) {
|
|
84
|
+
const attr = (name) => tag.match(new RegExp(`\\b${name}\\s*=\\s*["']?([^"'\\s>]+)`, 'i'))?.[1];
|
|
85
|
+
if (/^<script/i.test(tag) && local(attr('src'))) return true;
|
|
86
|
+
if (
|
|
87
|
+
/^<link/i.test(tag) &&
|
|
88
|
+
/stylesheet|modulepreload/i.test(attr('rel') ?? '') &&
|
|
89
|
+
local(attr('href'))
|
|
90
|
+
) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The build's entry pages: index.html plus every other HTML file that loads
|
|
99
|
+
* bundled output. Self-contained HTML (a bundle-analyzer stats.html, a static
|
|
100
|
+
* 404) is skipped - its content follows the bundle, not the app, and would differ
|
|
101
|
+
* after every legitimate change.
|
|
102
|
+
*/
|
|
103
|
+
export function discoverPages(distDir, { exclude = [] } = {}) {
|
|
104
|
+
const pages = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
for (const rel of listFiles(distDir, exclude)) {
|
|
107
|
+
if (!rel.endsWith('.html')) continue;
|
|
108
|
+
const page =
|
|
109
|
+
rel === 'index.html'
|
|
110
|
+
? '/'
|
|
111
|
+
: rel.endsWith('/index.html')
|
|
112
|
+
? `/${rel.slice(0, -'index.html'.length)}`
|
|
113
|
+
: `/${rel}`;
|
|
114
|
+
if (page === '/' || loadsBundle(fs.readFileSync(path.join(distDir, rel), 'utf8'))) {
|
|
115
|
+
pages.push(page);
|
|
116
|
+
} else {
|
|
117
|
+
skipped.push(rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
pages.sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b)));
|
|
121
|
+
return { pages: pages.slice(0, DISCOVER_LIMIT), more: pages.slice(DISCOVER_LIMIT), skipped };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Normalize a --pages entry to a server path, or throw with the expected shape. */
|
|
125
|
+
export function normalizePagePath(input) {
|
|
126
|
+
const page = String(input).trim();
|
|
127
|
+
if (!page.startsWith('/') || page.startsWith('//')) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`--pages entries are paths on the app's own origin, starting with "/" (got "${page}")`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return page;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The app's commit and whether its tree had uncommitted changes (build output excluded). */
|
|
136
|
+
export function gitState(appRoot, distDir) {
|
|
137
|
+
try {
|
|
138
|
+
const run = (args) =>
|
|
139
|
+
execFileSync('git', ['-C', appRoot, ...args], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
142
|
+
timeout: 10_000,
|
|
143
|
+
}).trim();
|
|
144
|
+
const sha = run(['rev-parse', '--short', 'HEAD']);
|
|
145
|
+
const relDist = path.relative(appRoot, distDir);
|
|
146
|
+
const pathspec = relDist && !relDist.startsWith('..') ? ['.', `:(exclude)${relDist}`] : ['.'];
|
|
147
|
+
const dirty = run(['status', '--porcelain', '--', ...pathspec]).length > 0;
|
|
148
|
+
return { sha, dirty };
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Copy the build into the parity state and return its manifest (the caller writes
|
|
156
|
+
* it). `exclude` keeps the lab's own state out when it lives inside the build dir.
|
|
157
|
+
*/
|
|
158
|
+
export function saveAnchor({
|
|
159
|
+
dist,
|
|
160
|
+
paths,
|
|
161
|
+
pages,
|
|
162
|
+
masks = [],
|
|
163
|
+
git = null,
|
|
164
|
+
late = false,
|
|
165
|
+
exclude = [],
|
|
166
|
+
}) {
|
|
167
|
+
fs.rmSync(paths.anchorDir, { recursive: true, force: true });
|
|
168
|
+
fs.mkdirSync(paths.anchorDist, { recursive: true });
|
|
169
|
+
fs.cpSync(dist, paths.anchorDist, {
|
|
170
|
+
recursive: true,
|
|
171
|
+
filter: (src) => !excluded(src, exclude) && !SKIP_DIRS.has(path.basename(src)),
|
|
172
|
+
});
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
return {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
createdAtMs: now,
|
|
177
|
+
dist,
|
|
178
|
+
distHash: hashDist(paths.anchorDist),
|
|
179
|
+
git,
|
|
180
|
+
late,
|
|
181
|
+
pages: pages.map((page) => ({ path: page, addedAtMs: now })),
|
|
182
|
+
masks: masks.map((selector) => ({ selector, addedAtMs: now })),
|
|
183
|
+
replaced: [],
|
|
184
|
+
lcp: null,
|
|
185
|
+
};
|
|
186
|
+
}
|