@progen-labs/spectra 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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-BvBsPok5.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +551 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +750 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +689 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +118 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +200 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance comparison of two commits ("before" vs "after").
|
|
3
|
+
*
|
|
4
|
+
* Pure functions, no I/O: the browser uses this to draw the Compare tab and the
|
|
5
|
+
* server uses the very same module to render the PDF, so the two can never
|
|
6
|
+
* disagree.
|
|
7
|
+
*
|
|
8
|
+
* Input: each side is one or more profiler reports (snapshots) of the same
|
|
9
|
+
* scenario taken on one commit. Several runs per side are reduced to their
|
|
10
|
+
* median, and a difference only counts when it is larger than
|
|
11
|
+
* - an absolute floor (a few ms is never a regression),
|
|
12
|
+
* - a relative floor (a few percent is never a regression), and
|
|
13
|
+
* - the run-to-run spread observed on either side (noise).
|
|
14
|
+
* Every measured quantity here is "lower is better".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const COMPARE_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
// ------------------------------------------------------------------ numbers
|
|
20
|
+
|
|
21
|
+
export function median(values) {
|
|
22
|
+
if (!values.length) return null;
|
|
23
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
24
|
+
const mid = sorted.length >> 1;
|
|
25
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const finite = v => typeof v === 'number' && Number.isFinite(v);
|
|
29
|
+
|
|
30
|
+
function stats(values) {
|
|
31
|
+
const v = values.filter(finite);
|
|
32
|
+
if (!v.length) return { value: null, min: null, max: null, n: 0 };
|
|
33
|
+
return {
|
|
34
|
+
value: median(v),
|
|
35
|
+
min: Math.min(...v),
|
|
36
|
+
max: Math.max(...v),
|
|
37
|
+
n: v.length,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Relative change; null when there is no baseline to divide by. */
|
|
42
|
+
function pctChange(before, after) {
|
|
43
|
+
if (!finite(before) || !finite(after)) return null;
|
|
44
|
+
if (before === 0) return after === 0 ? 0 : null;
|
|
45
|
+
return (after - before) / Math.abs(before);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decides whether `after` differs from `before` beyond the thresholds and the
|
|
50
|
+
* observed noise. Lower is better.
|
|
51
|
+
* @returns {{verdict: 'improved'|'degraded'|'same'|'n/a', delta: number|null, pct: number|null, threshold: number}}
|
|
52
|
+
*/
|
|
53
|
+
export function judge(beforeValues, afterValues, { minAbs, minRel }) {
|
|
54
|
+
const b = stats(beforeValues);
|
|
55
|
+
const a = stats(afterValues);
|
|
56
|
+
if (b.value === null || a.value === null) {
|
|
57
|
+
return {
|
|
58
|
+
verdict: 'n/a',
|
|
59
|
+
delta: null,
|
|
60
|
+
pct: null,
|
|
61
|
+
threshold: 0,
|
|
62
|
+
before: b,
|
|
63
|
+
after: a,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const delta = a.value - b.value;
|
|
67
|
+
const spread = Math.max(b.max - b.min, a.max - a.min);
|
|
68
|
+
const threshold = Math.max(minAbs, minRel * Math.abs(b.value), spread);
|
|
69
|
+
const verdict =
|
|
70
|
+
Math.abs(delta) < threshold ? 'same' : delta < 0 ? 'improved' : 'degraded';
|
|
71
|
+
return {
|
|
72
|
+
verdict,
|
|
73
|
+
delta,
|
|
74
|
+
pct: pctChange(b.value, a.value),
|
|
75
|
+
threshold,
|
|
76
|
+
before: b,
|
|
77
|
+
after: a,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ------------------------------------------------------------------ formatting
|
|
82
|
+
|
|
83
|
+
export function formatValue(unit, v) {
|
|
84
|
+
if (!finite(v)) return '–';
|
|
85
|
+
switch (unit) {
|
|
86
|
+
case 'ms':
|
|
87
|
+
if (v >= 1000) return `${(v / 1000).toFixed(2)} s`;
|
|
88
|
+
if (v >= 100) return `${Math.round(v)} ms`;
|
|
89
|
+
if (v >= 1) return `${v.toFixed(1)} ms`;
|
|
90
|
+
return `${v.toFixed(2)} ms`;
|
|
91
|
+
case 'score':
|
|
92
|
+
return v.toFixed(v !== 0 && Math.abs(v) < 0.01 ? 4 : 3);
|
|
93
|
+
case 'MB':
|
|
94
|
+
return `${v.toFixed(1)} MB`;
|
|
95
|
+
case 'bytes':
|
|
96
|
+
if (v < 1024) return `${Math.round(v)} B`;
|
|
97
|
+
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
|
|
98
|
+
return `${(v / 1024 / 1024).toFixed(2)} MB`;
|
|
99
|
+
default:
|
|
100
|
+
return Math.round(v).toLocaleString('en-US');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** '+12%', '−42%', 'new' (no baseline), '0%'. */
|
|
105
|
+
export function formatPct(pct) {
|
|
106
|
+
if (pct === null || pct === undefined) return 'new';
|
|
107
|
+
const n = Math.round(Math.abs(pct) * 100);
|
|
108
|
+
if (n === 0) return '0%';
|
|
109
|
+
return `${pct > 0 ? '+' : '−'}${n}%`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatDelta(unit, delta) {
|
|
113
|
+
if (!finite(delta)) return '–';
|
|
114
|
+
const body = formatValue(unit, Math.abs(delta));
|
|
115
|
+
if (delta === 0) return body;
|
|
116
|
+
return `${delta > 0 ? '+' : '−'}${body}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------------ headline metrics
|
|
120
|
+
|
|
121
|
+
const reactSum = (r, key) =>
|
|
122
|
+
r.react?.detected
|
|
123
|
+
? r.react.components.reduce((s, c) => s + (c[key] || 0), 0)
|
|
124
|
+
: null;
|
|
125
|
+
const reactOnly = (r, get) => (r.react?.detected ? get(r.react) : null);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `minAbs` / `minRel`: a change smaller than either is noise.
|
|
129
|
+
* `key`: counts toward the overall verdict.
|
|
130
|
+
*/
|
|
131
|
+
export const METRICS = [
|
|
132
|
+
{
|
|
133
|
+
id: 'tbt',
|
|
134
|
+
group: 'Responsiveness',
|
|
135
|
+
label: 'Total blocking time',
|
|
136
|
+
unit: 'ms',
|
|
137
|
+
get: r => r.vitals.tbtMs,
|
|
138
|
+
minAbs: 50,
|
|
139
|
+
minRel: 0.1,
|
|
140
|
+
key: true,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'longTasks',
|
|
144
|
+
group: 'Responsiveness',
|
|
145
|
+
label: 'Long tasks (> 50 ms)',
|
|
146
|
+
unit: 'count',
|
|
147
|
+
get: r => r.vitals.longTaskCount,
|
|
148
|
+
minAbs: 1,
|
|
149
|
+
minRel: 0.15,
|
|
150
|
+
key: true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'maxTask',
|
|
154
|
+
group: 'Responsiveness',
|
|
155
|
+
label: 'Longest task',
|
|
156
|
+
unit: 'ms',
|
|
157
|
+
get: r => r.vitals.maxLongTaskMs,
|
|
158
|
+
minAbs: 25,
|
|
159
|
+
minRel: 0.1,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'inp',
|
|
163
|
+
group: 'Responsiveness',
|
|
164
|
+
label: 'Slowest interaction',
|
|
165
|
+
unit: 'ms',
|
|
166
|
+
get: r => r.vitals.inpMs,
|
|
167
|
+
minAbs: 40,
|
|
168
|
+
minRel: 0.1,
|
|
169
|
+
key: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: 'lcp',
|
|
173
|
+
group: 'Loading',
|
|
174
|
+
label: 'Largest contentful paint',
|
|
175
|
+
unit: 'ms',
|
|
176
|
+
get: r => r.vitals.lcpMs,
|
|
177
|
+
minAbs: 100,
|
|
178
|
+
minRel: 0.05,
|
|
179
|
+
key: true,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'fcp',
|
|
183
|
+
group: 'Loading',
|
|
184
|
+
label: 'First contentful paint',
|
|
185
|
+
unit: 'ms',
|
|
186
|
+
get: r => r.vitals.fcpMs,
|
|
187
|
+
minAbs: 50,
|
|
188
|
+
minRel: 0.05,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'cls',
|
|
192
|
+
group: 'Visual stability',
|
|
193
|
+
label: 'Layout shift (CLS)',
|
|
194
|
+
unit: 'score',
|
|
195
|
+
get: r => r.vitals.cls,
|
|
196
|
+
minAbs: 0.01,
|
|
197
|
+
minRel: 0.2,
|
|
198
|
+
key: true,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'cpu',
|
|
202
|
+
group: 'CPU and main thread',
|
|
203
|
+
label: 'JavaScript CPU time',
|
|
204
|
+
unit: 'ms',
|
|
205
|
+
get: r => r.totals.activeMs,
|
|
206
|
+
minAbs: 50,
|
|
207
|
+
minRel: 0.1,
|
|
208
|
+
key: true,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'scripting',
|
|
212
|
+
group: 'CPU and main thread',
|
|
213
|
+
label: 'Scripting',
|
|
214
|
+
unit: 'ms',
|
|
215
|
+
get: r => r.totals.scriptingMs,
|
|
216
|
+
minAbs: 50,
|
|
217
|
+
minRel: 0.1,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'layout',
|
|
221
|
+
group: 'CPU and main thread',
|
|
222
|
+
label: 'Layout',
|
|
223
|
+
unit: 'ms',
|
|
224
|
+
get: r => r.totals.layoutMs,
|
|
225
|
+
minAbs: 20,
|
|
226
|
+
minRel: 0.15,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'style',
|
|
230
|
+
group: 'CPU and main thread',
|
|
231
|
+
label: 'Style recalculation',
|
|
232
|
+
unit: 'ms',
|
|
233
|
+
get: r => r.totals.styleMs,
|
|
234
|
+
minAbs: 20,
|
|
235
|
+
minRel: 0.15,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'paint',
|
|
239
|
+
group: 'CPU and main thread',
|
|
240
|
+
label: 'Paint',
|
|
241
|
+
unit: 'ms',
|
|
242
|
+
get: r => r.totals.paintMs,
|
|
243
|
+
minAbs: 20,
|
|
244
|
+
minRel: 0.15,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'gc',
|
|
248
|
+
group: 'CPU and main thread',
|
|
249
|
+
label: 'Garbage collection',
|
|
250
|
+
unit: 'ms',
|
|
251
|
+
get: r => r.totals.gcMs,
|
|
252
|
+
minAbs: 20,
|
|
253
|
+
minRel: 0.2,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'compile',
|
|
257
|
+
group: 'CPU and main thread',
|
|
258
|
+
label: 'Script compile / parse',
|
|
259
|
+
unit: 'ms',
|
|
260
|
+
get: r => r.totals.compileMs,
|
|
261
|
+
minAbs: 20,
|
|
262
|
+
minRel: 0.2,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'layouts',
|
|
266
|
+
group: 'Layout work',
|
|
267
|
+
label: 'Layouts performed',
|
|
268
|
+
unit: 'count',
|
|
269
|
+
get: r => r.totals.layoutCount,
|
|
270
|
+
minAbs: 10,
|
|
271
|
+
minRel: 0.15,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: 'recalcs',
|
|
275
|
+
group: 'Layout work',
|
|
276
|
+
label: 'Style recalculations',
|
|
277
|
+
unit: 'count',
|
|
278
|
+
get: r => r.totals.styleRecalcCount,
|
|
279
|
+
minAbs: 10,
|
|
280
|
+
minRel: 0.15,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: 'forced',
|
|
284
|
+
group: 'Layout work',
|
|
285
|
+
label: 'Forced reflows',
|
|
286
|
+
unit: 'count',
|
|
287
|
+
get: r => r.totals.forcedLayoutCount + r.totals.forcedStyleCount,
|
|
288
|
+
minAbs: 5,
|
|
289
|
+
minRel: 0.2,
|
|
290
|
+
key: true,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'forcedMs',
|
|
294
|
+
group: 'Layout work',
|
|
295
|
+
label: 'Time in forced reflows',
|
|
296
|
+
unit: 'ms',
|
|
297
|
+
get: r => r.totals.forcedMs,
|
|
298
|
+
minAbs: 10,
|
|
299
|
+
minRel: 0.2,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: 'renders',
|
|
303
|
+
group: 'React',
|
|
304
|
+
label: 'Component renders',
|
|
305
|
+
unit: 'count',
|
|
306
|
+
get: r => reactSum(r, 'renders'),
|
|
307
|
+
minAbs: 50,
|
|
308
|
+
minRel: 0.15,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'wasted',
|
|
312
|
+
group: 'React',
|
|
313
|
+
label: 'Wasted renders',
|
|
314
|
+
unit: 'count',
|
|
315
|
+
get: r => reactSum(r, 'wasted'),
|
|
316
|
+
minAbs: 25,
|
|
317
|
+
minRel: 0.15,
|
|
318
|
+
key: true,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'renderMs',
|
|
322
|
+
group: 'React',
|
|
323
|
+
label: 'React render time',
|
|
324
|
+
unit: 'ms',
|
|
325
|
+
get: r => reactOnly(r, x => x.commits.totalMs),
|
|
326
|
+
minAbs: 25,
|
|
327
|
+
minRel: 0.1,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'maxCommit',
|
|
331
|
+
group: 'React',
|
|
332
|
+
label: 'Slowest React commit',
|
|
333
|
+
unit: 'ms',
|
|
334
|
+
get: r => reactOnly(r, x => x.commits.maxMs),
|
|
335
|
+
minAbs: 10,
|
|
336
|
+
minRel: 0.15,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: 'dom',
|
|
340
|
+
group: 'Memory and DOM',
|
|
341
|
+
label: 'DOM nodes',
|
|
342
|
+
unit: 'count',
|
|
343
|
+
get: r => r.memory.domNodes,
|
|
344
|
+
minAbs: 100,
|
|
345
|
+
minRel: 0.05,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'listeners',
|
|
349
|
+
group: 'Memory and DOM',
|
|
350
|
+
label: 'Event listeners',
|
|
351
|
+
unit: 'count',
|
|
352
|
+
get: r => r.memory.listeners,
|
|
353
|
+
minAbs: 50,
|
|
354
|
+
minRel: 0.1,
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'heapGrowth',
|
|
358
|
+
group: 'Memory and DOM',
|
|
359
|
+
label: 'JS heap growth',
|
|
360
|
+
unit: 'MB',
|
|
361
|
+
get: r => r.memory.heapGrowthMb,
|
|
362
|
+
minAbs: 5,
|
|
363
|
+
minRel: 0.15,
|
|
364
|
+
key: true,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: 'requests',
|
|
368
|
+
group: 'Network',
|
|
369
|
+
label: 'Requests',
|
|
370
|
+
unit: 'count',
|
|
371
|
+
get: r => r.network.count,
|
|
372
|
+
minAbs: 3,
|
|
373
|
+
minRel: 0.1,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: 'transferred',
|
|
377
|
+
group: 'Network',
|
|
378
|
+
label: 'Data transferred',
|
|
379
|
+
unit: 'bytes',
|
|
380
|
+
get: r => r.network.totalEncoded,
|
|
381
|
+
minAbs: 100 * 1024,
|
|
382
|
+
minRel: 0.05,
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
function compareMetrics(beforeRuns, afterRuns) {
|
|
387
|
+
return METRICS.map(def => {
|
|
388
|
+
const bv = beforeRuns.map(def.get);
|
|
389
|
+
const av = afterRuns.map(def.get);
|
|
390
|
+
const j = judge(bv, av, def);
|
|
391
|
+
return {
|
|
392
|
+
id: def.id,
|
|
393
|
+
group: def.group,
|
|
394
|
+
label: def.label,
|
|
395
|
+
unit: def.unit,
|
|
396
|
+
key: Boolean(def.key),
|
|
397
|
+
before: j.before,
|
|
398
|
+
after: j.after,
|
|
399
|
+
delta: j.delta,
|
|
400
|
+
pct: j.pct,
|
|
401
|
+
verdict: j.verdict,
|
|
402
|
+
threshold: j.threshold,
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ------------------------------------------------------------------ entities
|
|
408
|
+
|
|
409
|
+
const MAX_LISTED = 40;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Generic "which files/components/functions changed" diff.
|
|
413
|
+
* `extract(report)` returns Map(key -> {values: {sub: number}, meta}).
|
|
414
|
+
* The first sub-metric doubles as the presence test for added/removed.
|
|
415
|
+
*/
|
|
416
|
+
function compareEntities(
|
|
417
|
+
beforeRuns,
|
|
418
|
+
afterRuns,
|
|
419
|
+
{ extract, subs, presenceMin, scoreOf },
|
|
420
|
+
) {
|
|
421
|
+
const beforeMaps = beforeRuns.map(extract);
|
|
422
|
+
const afterMaps = afterRuns.map(extract);
|
|
423
|
+
const keys = new Set();
|
|
424
|
+
[...beforeMaps, ...afterMaps].forEach(m => m.forEach((_, k) => keys.add(k)));
|
|
425
|
+
|
|
426
|
+
const valuesOf = (maps, key, sub) =>
|
|
427
|
+
maps.map(m => m.get(key)?.values[sub] ?? 0);
|
|
428
|
+
const metaOf = key => {
|
|
429
|
+
for (const m of [...afterMaps, ...beforeMaps])
|
|
430
|
+
if (m.has(key)) return m.get(key).meta;
|
|
431
|
+
return {};
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const buckets = {
|
|
435
|
+
improved: [],
|
|
436
|
+
degraded: [],
|
|
437
|
+
mixed: [],
|
|
438
|
+
added: [],
|
|
439
|
+
removed: [],
|
|
440
|
+
};
|
|
441
|
+
let unchanged = 0;
|
|
442
|
+
|
|
443
|
+
for (const key of keys) {
|
|
444
|
+
const changes = subs.map(sub => {
|
|
445
|
+
const j = judge(
|
|
446
|
+
valuesOf(beforeMaps, key, sub.id),
|
|
447
|
+
valuesOf(afterMaps, key, sub.id),
|
|
448
|
+
sub,
|
|
449
|
+
);
|
|
450
|
+
return {
|
|
451
|
+
id: sub.id,
|
|
452
|
+
label: sub.label,
|
|
453
|
+
unit: sub.unit,
|
|
454
|
+
before: j.before.value,
|
|
455
|
+
after: j.after.value,
|
|
456
|
+
delta: j.delta,
|
|
457
|
+
pct: j.pct,
|
|
458
|
+
verdict: j.verdict,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
const presence = changes[0];
|
|
462
|
+
const entity = { key, ...metaOf(key), changes };
|
|
463
|
+
entity.score = scoreOf(changes);
|
|
464
|
+
|
|
465
|
+
if (presence.before === 0 && presence.after >= presenceMin) {
|
|
466
|
+
buckets.added.push(entity);
|
|
467
|
+
} else if (presence.after === 0 && presence.before >= presenceMin) {
|
|
468
|
+
buckets.removed.push(entity);
|
|
469
|
+
} else {
|
|
470
|
+
const worse = changes.some(c => c.verdict === 'degraded');
|
|
471
|
+
const better = changes.some(c => c.verdict === 'improved');
|
|
472
|
+
if (worse && better) buckets.mixed.push(entity);
|
|
473
|
+
else if (worse) buckets.degraded.push(entity);
|
|
474
|
+
else if (better) buckets.improved.push(entity);
|
|
475
|
+
else unchanged += 1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const out = { unchanged, compared: keys.size };
|
|
480
|
+
for (const [name, list] of Object.entries(buckets)) {
|
|
481
|
+
list.sort((a, b) => b.score - a.score);
|
|
482
|
+
out[name] = list.slice(0, MAX_LISTED);
|
|
483
|
+
out[`${name}Total`] = list.length;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const SUBS = {
|
|
489
|
+
components: [
|
|
490
|
+
{ id: 'renders', label: 'Renders', unit: 'count', minAbs: 20, minRel: 0.2 },
|
|
491
|
+
{
|
|
492
|
+
id: 'wasted',
|
|
493
|
+
label: 'Wasted renders',
|
|
494
|
+
unit: 'count',
|
|
495
|
+
minAbs: 10,
|
|
496
|
+
minRel: 0.2,
|
|
497
|
+
},
|
|
498
|
+
{ id: 'selfMs', label: 'Render time', unit: 'ms', minAbs: 5, minRel: 0.2 },
|
|
499
|
+
],
|
|
500
|
+
files: [
|
|
501
|
+
{ id: 'selfMs', label: 'CPU time', unit: 'ms', minAbs: 10, minRel: 0.15 },
|
|
502
|
+
{
|
|
503
|
+
id: 'blockMs',
|
|
504
|
+
label: 'Blocking time',
|
|
505
|
+
unit: 'ms',
|
|
506
|
+
minAbs: 20,
|
|
507
|
+
minRel: 0.15,
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
id: 'reactMs',
|
|
511
|
+
label: 'React render time',
|
|
512
|
+
unit: 'ms',
|
|
513
|
+
minAbs: 5,
|
|
514
|
+
minRel: 0.2,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'forcedCount',
|
|
518
|
+
label: 'Forced reflows',
|
|
519
|
+
unit: 'count',
|
|
520
|
+
minAbs: 5,
|
|
521
|
+
minRel: 0.2,
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
functions: [
|
|
525
|
+
{ id: 'selfMs', label: 'Self time', unit: 'ms', minAbs: 10, minRel: 0.2 },
|
|
526
|
+
],
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const byId = (subs, id) => subs.find(s => s.id === id);
|
|
530
|
+
const changeOf = (changes, id) => changes.find(c => c.id === id);
|
|
531
|
+
const absDelta = (changes, id) => Math.abs(changeOf(changes, id)?.delta ?? 0);
|
|
532
|
+
|
|
533
|
+
function compareComponents(beforeRuns, afterRuns) {
|
|
534
|
+
return compareEntities(beforeRuns, afterRuns, {
|
|
535
|
+
subs: SUBS.components,
|
|
536
|
+
presenceMin: 20,
|
|
537
|
+
scoreOf: ch =>
|
|
538
|
+
absDelta(ch, 'selfMs') +
|
|
539
|
+
0.01 * absDelta(ch, 'wasted') +
|
|
540
|
+
0.001 * absDelta(ch, 'renders'),
|
|
541
|
+
extract: r => {
|
|
542
|
+
const map = new Map();
|
|
543
|
+
for (const c of r.react?.components || []) {
|
|
544
|
+
map.set(`${c.name}|${c.file || ''}`, {
|
|
545
|
+
values: { renders: c.renders, wasted: c.wasted, selfMs: c.selfMs },
|
|
546
|
+
meta: {
|
|
547
|
+
name: c.name,
|
|
548
|
+
file: c.file,
|
|
549
|
+
line: c.line,
|
|
550
|
+
lineKind: c.lineKind,
|
|
551
|
+
abs: c.abs,
|
|
552
|
+
vendor: Boolean(c.vendor),
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return map;
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function compareFiles(beforeRuns, afterRuns) {
|
|
562
|
+
return compareEntities(beforeRuns, afterRuns, {
|
|
563
|
+
subs: SUBS.files,
|
|
564
|
+
presenceMin: 10,
|
|
565
|
+
scoreOf: ch =>
|
|
566
|
+
absDelta(ch, 'selfMs') +
|
|
567
|
+
absDelta(ch, 'blockMs') +
|
|
568
|
+
absDelta(ch, 'reactMs'),
|
|
569
|
+
extract: r => {
|
|
570
|
+
const map = new Map();
|
|
571
|
+
for (const f of r.files || []) {
|
|
572
|
+
map.set(f.path, {
|
|
573
|
+
values: {
|
|
574
|
+
selfMs: f.selfMs,
|
|
575
|
+
blockMs: f.blockMs,
|
|
576
|
+
reactMs: f.reactMs,
|
|
577
|
+
forcedCount: f.forcedCount,
|
|
578
|
+
},
|
|
579
|
+
meta: { name: f.path, file: f.path, vendor: Boolean(f.vendor) },
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return map;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function compareFunctions(beforeRuns, afterRuns) {
|
|
588
|
+
return compareEntities(beforeRuns, afterRuns, {
|
|
589
|
+
subs: SUBS.functions,
|
|
590
|
+
presenceMin: 10,
|
|
591
|
+
scoreOf: ch => absDelta(ch, 'selfMs'),
|
|
592
|
+
extract: r => {
|
|
593
|
+
const map = new Map();
|
|
594
|
+
for (const f of r.files || []) {
|
|
595
|
+
for (const fn of f.functions || []) {
|
|
596
|
+
map.set(`${f.path}::${fn.name}`, {
|
|
597
|
+
values: { selfMs: fn.selfMs },
|
|
598
|
+
meta: {
|
|
599
|
+
name: fn.name,
|
|
600
|
+
file: f.path,
|
|
601
|
+
line: fn.line,
|
|
602
|
+
lineKind: fn.lineKind,
|
|
603
|
+
abs: fn.abs,
|
|
604
|
+
vendor: Boolean(f.vendor),
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return map;
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ------------------------------------------------------------------ findings
|
|
615
|
+
|
|
616
|
+
/** Stable identity of a finding across recordings (ids like long-task-3 are positional). */
|
|
617
|
+
export function findingKey(f) {
|
|
618
|
+
const first = f.culprits?.[0];
|
|
619
|
+
if (f.id.startsWith('long-task-'))
|
|
620
|
+
return `long-task:${first?.file || ''}:${first?.fn || ''}`;
|
|
621
|
+
if (f.id.startsWith('interaction-')) return 'slow-interaction';
|
|
622
|
+
if (f.id.startsWith('commit-')) return 'slow-react-commit';
|
|
623
|
+
if (
|
|
624
|
+
f.id.startsWith('uncompressed-') ||
|
|
625
|
+
f.id.startsWith('large-') ||
|
|
626
|
+
f.id.startsWith('slow-request-')
|
|
627
|
+
) {
|
|
628
|
+
return `${f.id.replace(/-\d+$/, '')}:${f.detail || f.title}`;
|
|
629
|
+
}
|
|
630
|
+
return f.id;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** A finding "exists" on a side when it appears in at least half of that side's runs. */
|
|
634
|
+
function presentFindings(runs) {
|
|
635
|
+
const seen = new Map();
|
|
636
|
+
for (const r of runs) {
|
|
637
|
+
const keys = new Set();
|
|
638
|
+
for (const f of r.findings || []) {
|
|
639
|
+
const key = findingKey(f);
|
|
640
|
+
if (keys.has(key)) continue;
|
|
641
|
+
keys.add(key);
|
|
642
|
+
const cur = seen.get(key) || { count: 0, best: f };
|
|
643
|
+
cur.count += 1;
|
|
644
|
+
if (f.impactMs > cur.best.impactMs) cur.best = f;
|
|
645
|
+
seen.set(key, cur);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const need = Math.ceil(runs.length / 2);
|
|
649
|
+
const out = new Map();
|
|
650
|
+
for (const [key, { count, best }] of seen)
|
|
651
|
+
if (count >= need) out.set(key, best);
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
656
|
+
const brief = f => ({
|
|
657
|
+
id: f.id,
|
|
658
|
+
severity: f.severity,
|
|
659
|
+
category: f.category,
|
|
660
|
+
title: f.title,
|
|
661
|
+
impactMs: Math.round(f.impactMs || 0),
|
|
662
|
+
fix: f.fix,
|
|
663
|
+
culprits: (f.culprits || []).slice(0, 3),
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
function compareFindings(beforeRuns, afterRuns) {
|
|
667
|
+
const b = presentFindings(beforeRuns);
|
|
668
|
+
const a = presentFindings(afterRuns);
|
|
669
|
+
const ranked = list =>
|
|
670
|
+
list.sort(
|
|
671
|
+
(x, y) =>
|
|
672
|
+
SEVERITY_RANK[x.severity] - SEVERITY_RANK[y.severity] ||
|
|
673
|
+
y.impactMs - x.impactMs,
|
|
674
|
+
);
|
|
675
|
+
const added = [];
|
|
676
|
+
const resolved = [];
|
|
677
|
+
const persisting = [];
|
|
678
|
+
for (const [key, f] of a) {
|
|
679
|
+
if (!b.has(key)) added.push(brief(f));
|
|
680
|
+
else
|
|
681
|
+
persisting.push({
|
|
682
|
+
...brief(f),
|
|
683
|
+
beforeTitle: b.get(key).title,
|
|
684
|
+
beforeImpactMs: Math.round(b.get(key).impactMs || 0),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
for (const [key, f] of b) if (!a.has(key)) resolved.push(brief(f));
|
|
688
|
+
return {
|
|
689
|
+
added: ranked(added),
|
|
690
|
+
resolved: ranked(resolved),
|
|
691
|
+
persisting: ranked(persisting),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ------------------------------------------------------------------ comparability
|
|
696
|
+
|
|
697
|
+
const pathOf = url => {
|
|
698
|
+
try {
|
|
699
|
+
return new URL(url).pathname.replace(/\/+$/, '') || '/';
|
|
700
|
+
} catch {
|
|
701
|
+
return url || '';
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const unique = list => [...new Set(list)];
|
|
705
|
+
|
|
706
|
+
function comparability(before, after) {
|
|
707
|
+
const checks = [];
|
|
708
|
+
const add = (level, id, title, detail) =>
|
|
709
|
+
checks.push({ level, id, title, detail });
|
|
710
|
+
const all = [...before.runs, ...after.runs];
|
|
711
|
+
const bm = before.runs.map(r => r.meta);
|
|
712
|
+
const am = after.runs.map(r => r.meta);
|
|
713
|
+
|
|
714
|
+
const failed = all.filter(r => r.meta.navStatus >= 400);
|
|
715
|
+
if (failed.length) {
|
|
716
|
+
add(
|
|
717
|
+
'bad',
|
|
718
|
+
'failed-page',
|
|
719
|
+
'A recording profiled an error page',
|
|
720
|
+
`${failed.length} run(s) had an HTTP error on the page itself (e.g. ${failed[0].meta.navStatus}). Not signed in?`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const paths = unique(all.map(r => pathOf(r.meta.url)));
|
|
725
|
+
add(
|
|
726
|
+
paths.length === 1 ? 'ok' : 'warn',
|
|
727
|
+
'page',
|
|
728
|
+
paths.length === 1 ? 'Same page' : 'Different pages',
|
|
729
|
+
paths.length === 1
|
|
730
|
+
? paths[0]
|
|
731
|
+
: `The recordings are of different pages (${paths.slice(0, 3).join(', ')}). Compare like with like.`,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const modes = unique(all.map(r => r.meta.mode));
|
|
735
|
+
add(
|
|
736
|
+
modes.length === 1 ? 'ok' : 'warn',
|
|
737
|
+
'mode',
|
|
738
|
+
modes.length === 1
|
|
739
|
+
? `Same mode (${modes[0] === 'load' ? 'page load' : 'interaction'})`
|
|
740
|
+
: 'Different recording modes',
|
|
741
|
+
modes.length === 1
|
|
742
|
+
? ''
|
|
743
|
+
: 'Page-load and interaction recordings measure different things.',
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const throttles = unique(all.map(r => r.meta.cpuThrottle || 1));
|
|
747
|
+
add(
|
|
748
|
+
throttles.length === 1 ? 'ok' : 'warn',
|
|
749
|
+
'cpu',
|
|
750
|
+
throttles.length === 1
|
|
751
|
+
? `Same CPU setting (${throttles[0]}×)`
|
|
752
|
+
: 'Different CPU throttling',
|
|
753
|
+
throttles.length === 1
|
|
754
|
+
? ''
|
|
755
|
+
: `Recorded at ${throttles.join('× and ')}× slowdown.`,
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
const bd = median(bm.map(m => m.durationMs));
|
|
759
|
+
const ad = median(am.map(m => m.durationMs));
|
|
760
|
+
const ratio = Math.max(bd, ad) / Math.max(Math.min(bd, ad), 1);
|
|
761
|
+
if (before.runs[0]?.meta.mode !== 'load') {
|
|
762
|
+
add(
|
|
763
|
+
ratio <= 1.25 ? 'ok' : 'warn',
|
|
764
|
+
'duration',
|
|
765
|
+
ratio <= 1.25 ? 'Similar recording length' : 'Recording lengths differ',
|
|
766
|
+
`${formatValue('ms', bd)} vs ${formatValue('ms', ad)}.${ratio <= 1.25 ? '' : ' Totals such as CPU time and render counts grow with length, so they are not directly comparable.'}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const nb = before.runs.length;
|
|
771
|
+
const na = after.runs.length;
|
|
772
|
+
const minRuns = Math.min(nb, na);
|
|
773
|
+
add(
|
|
774
|
+
minRuns >= 3 ? 'ok' : 'warn',
|
|
775
|
+
'runs',
|
|
776
|
+
`${nb} run${nb > 1 ? 's' : ''} before, ${na} after`,
|
|
777
|
+
minRuns >= 3
|
|
778
|
+
? 'Medians are used and differences must exceed the run-to-run spread.'
|
|
779
|
+
: 'Run-to-run noise could not be measured well. Record each side at least 3 times for a trustworthy verdict.',
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const noReact = all.filter(r => !r.react?.detected || !r.react?.hasTimings);
|
|
783
|
+
if (noReact.length)
|
|
784
|
+
add(
|
|
785
|
+
'warn',
|
|
786
|
+
'react',
|
|
787
|
+
'React timings missing in some runs',
|
|
788
|
+
`${noReact.length} run(s) had no React data (production build, or the probe loaded after React). React metrics are skipped or partial.`,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const attributions = unique(all.map(r => r.meta.attribution));
|
|
792
|
+
if (attributions.length > 1)
|
|
793
|
+
add(
|
|
794
|
+
'warn',
|
|
795
|
+
'attribution',
|
|
796
|
+
'Different file-attribution modes',
|
|
797
|
+
`${attributions.join(' / ')}: function lines may differ between sides.`,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
const idle = all.filter(r => r.totals.activeMs < 300);
|
|
801
|
+
if (idle.length)
|
|
802
|
+
add(
|
|
803
|
+
'warn',
|
|
804
|
+
'idle',
|
|
805
|
+
'Some recordings were nearly idle',
|
|
806
|
+
`${idle.length} run(s) executed under 300 ms of JavaScript: little was measured.`,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
const dirty = [before, after].flatMap((s, i) =>
|
|
810
|
+
s.git?.dirty ? [i === 0 ? 'before' : 'after'] : [],
|
|
811
|
+
);
|
|
812
|
+
if (dirty.length)
|
|
813
|
+
add(
|
|
814
|
+
'warn',
|
|
815
|
+
'dirty',
|
|
816
|
+
'Uncommitted changes while recording',
|
|
817
|
+
`The ${dirty.join(' and ')} snapshot was taken with uncommitted edits, so its commit hash may not describe exactly what was measured.`,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
for (const [name, side] of [
|
|
821
|
+
['before', before],
|
|
822
|
+
['after', after],
|
|
823
|
+
]) {
|
|
824
|
+
const hashes = unique(side.runs.map(r => r.meta.git?.hash).filter(Boolean));
|
|
825
|
+
if (hashes.length > 1)
|
|
826
|
+
add(
|
|
827
|
+
'warn',
|
|
828
|
+
`mixed-${name}`,
|
|
829
|
+
`Runs on the ${name} side come from different commits`,
|
|
830
|
+
hashes.map(h => h.slice(0, 7)).join(', '),
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (before.git?.hash && before.git.hash === after.git?.hash) {
|
|
834
|
+
add(
|
|
835
|
+
'warn',
|
|
836
|
+
'same-commit',
|
|
837
|
+
'Both sides are the same commit',
|
|
838
|
+
'This is an A/A comparison: any difference shown is measurement noise.',
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
return checks;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ------------------------------------------------------------------ verdict
|
|
845
|
+
|
|
846
|
+
function overall(metrics) {
|
|
847
|
+
const key = metrics.filter(m => m.key && m.verdict !== 'n/a');
|
|
848
|
+
const improved = key.filter(m => m.verdict === 'improved').length;
|
|
849
|
+
const degraded = key.filter(m => m.verdict === 'degraded').length;
|
|
850
|
+
if (!improved && !degraded) return 'neutral';
|
|
851
|
+
if (!degraded) return 'improved';
|
|
852
|
+
if (!improved) return 'regressed';
|
|
853
|
+
return 'mixed';
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const sentence = m =>
|
|
857
|
+
`${m.label}: ${formatValue(m.unit, m.before.value)} → ${formatValue(m.unit, m.after.value)} (${formatPct(m.pct)})`;
|
|
858
|
+
const magnitude = m => (m.pct === null ? Infinity : Math.abs(m.pct));
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Biggest component changes as plain sentences. A component that used to render
|
|
862
|
+
* and no longer does (or the reverse) is a cost going away (or appearing), so it
|
|
863
|
+
* counts as an improvement (or a degradation) here.
|
|
864
|
+
*/
|
|
865
|
+
function entityHeadlines(entities) {
|
|
866
|
+
const line = (e, kind) => {
|
|
867
|
+
const c = e.changes.find(x => x.verdict !== 'same') || e.changes[0];
|
|
868
|
+
return {
|
|
869
|
+
kind,
|
|
870
|
+
score: e.score,
|
|
871
|
+
text: `${e.name}${e.file && e.file !== e.name ? ` (${e.file})` : ''} — ${c.label.toLowerCase()}: ${formatValue(c.unit, c.before)} → ${formatValue(c.unit, c.after)} (${formatPct(c.pct)})`,
|
|
872
|
+
};
|
|
873
|
+
};
|
|
874
|
+
const worse = [
|
|
875
|
+
...entities.degraded.map(e => line(e, 'degraded')),
|
|
876
|
+
...entities.added.map(e => line(e, 'degraded')),
|
|
877
|
+
];
|
|
878
|
+
const better = [
|
|
879
|
+
...entities.improved.map(e => line(e, 'improved')),
|
|
880
|
+
...entities.removed.map(e => line(e, 'improved')),
|
|
881
|
+
];
|
|
882
|
+
const top = (list, n) =>
|
|
883
|
+
list
|
|
884
|
+
.sort((a, b) => b.score - a.score)
|
|
885
|
+
.slice(0, n)
|
|
886
|
+
.map(({ kind, text }) => ({ kind, text }));
|
|
887
|
+
return [...top(worse, 2), ...top(better, 3)];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ------------------------------------------------------------------ public API
|
|
891
|
+
|
|
892
|
+
function sideInfo(side) {
|
|
893
|
+
const first = side.runs[0];
|
|
894
|
+
const git =
|
|
895
|
+
side.git && (side.git.hash || side.git.author)
|
|
896
|
+
? side.git
|
|
897
|
+
: first?.meta.git || null;
|
|
898
|
+
return {
|
|
899
|
+
git,
|
|
900
|
+
label: side.label || first?.meta.label || '',
|
|
901
|
+
runs: side.runs.map(r => ({
|
|
902
|
+
id: r.id,
|
|
903
|
+
createdAt: r.createdAt,
|
|
904
|
+
title: r.meta.title,
|
|
905
|
+
url: r.meta.url,
|
|
906
|
+
mode: r.meta.mode,
|
|
907
|
+
durationMs: r.meta.durationMs,
|
|
908
|
+
cpuThrottle: r.meta.cpuThrottle || 1,
|
|
909
|
+
attribution: r.meta.attribution,
|
|
910
|
+
reactVersion: r.react?.version || '',
|
|
911
|
+
commit: r.meta.git?.shortHash || '',
|
|
912
|
+
})),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* @param {{runs: object[], git?: object, label?: string}} before
|
|
918
|
+
* @param {{runs: object[], git?: object, label?: string}} after
|
|
919
|
+
*/
|
|
920
|
+
export function compareSides(before, after) {
|
|
921
|
+
if (!before?.runs?.length || !after?.runs?.length) return null;
|
|
922
|
+
const metrics = compareMetrics(before.runs, after.runs);
|
|
923
|
+
const components = compareComponents(before.runs, after.runs);
|
|
924
|
+
const files = compareFiles(before.runs, after.runs);
|
|
925
|
+
const functions = compareFunctions(before.runs, after.runs);
|
|
926
|
+
const findings = compareFindings(before.runs, after.runs);
|
|
927
|
+
const checks = comparability(before, after);
|
|
928
|
+
|
|
929
|
+
const count = verdict => metrics.filter(m => m.verdict === verdict).length;
|
|
930
|
+
const verdict = overall(metrics);
|
|
931
|
+
const keyMoves = verdict_ =>
|
|
932
|
+
metrics
|
|
933
|
+
.filter(m => m.key && m.verdict === verdict_)
|
|
934
|
+
.sort((a, b) => magnitude(b) - magnitude(a));
|
|
935
|
+
const warns = checks.filter(c => c.level === 'warn').length;
|
|
936
|
+
const confidence =
|
|
937
|
+
checks.some(c => c.level === 'bad') || warns >= 3
|
|
938
|
+
? 'low'
|
|
939
|
+
: warns > 0 || Math.min(before.runs.length, after.runs.length) < 3
|
|
940
|
+
? 'medium'
|
|
941
|
+
: 'high';
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
version: COMPARE_VERSION,
|
|
945
|
+
generatedAt: new Date().toISOString(),
|
|
946
|
+
before: sideInfo(before),
|
|
947
|
+
after: sideInfo(after),
|
|
948
|
+
checks,
|
|
949
|
+
confidence,
|
|
950
|
+
verdict,
|
|
951
|
+
counts: {
|
|
952
|
+
improved: count('improved'),
|
|
953
|
+
degraded: count('degraded'),
|
|
954
|
+
same: count('same'),
|
|
955
|
+
unavailable: count('n/a'),
|
|
956
|
+
},
|
|
957
|
+
headlines: [
|
|
958
|
+
...keyMoves('degraded')
|
|
959
|
+
.slice(0, 3)
|
|
960
|
+
.map(m => ({ kind: 'degraded', text: sentence(m) })),
|
|
961
|
+
...keyMoves('improved')
|
|
962
|
+
.slice(0, 3)
|
|
963
|
+
.map(m => ({ kind: 'improved', text: sentence(m) })),
|
|
964
|
+
...entityHeadlines(components),
|
|
965
|
+
],
|
|
966
|
+
metrics,
|
|
967
|
+
components,
|
|
968
|
+
files,
|
|
969
|
+
functions,
|
|
970
|
+
findings,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Lookup used by the UI to show which sub-metric changed. */
|
|
975
|
+
export const subLabel = (entityType, id) =>
|
|
976
|
+
byId(SUBS[entityType], id)?.label || id;
|
|
977
|
+
export { changeOf };
|