@mui/internal-benchmark 0.0.3-canary.2 → 0.0.3-canary.21
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/DiscreteMetric.d.mts +15 -0
- package/DiscreteMetric.mjs +24 -0
- package/ElementTiming.d.mts +2 -1
- package/LICENSE +1 -1
- package/Metric.d.mts +24 -0
- package/Metric.mjs +89 -0
- package/README.md +136 -2
- package/ScalarMetric.d.mts +23 -0
- package/ScalarMetric.mjs +42 -0
- package/ciReport.d.mts +61 -0
- package/ciReport.mjs +14 -1
- package/index.d.mts +12 -1
- package/index.mjs +221 -90
- package/metricsGate.d.mts +7 -0
- package/metricsGate.mjs +21 -0
- package/package.json +6 -6
- package/profileSession.d.mts +2 -0
- package/profileSession.mjs +95 -0
- package/reactRecording.d.mts +30 -0
- package/reactRecording.mjs +76 -0
- package/reporter.d.mts +2 -0
- package/reporter.mjs +105 -47
- package/stats.d.mts +11 -1
- package/stats.mjs +20 -0
- package/taskMetaAugmentation.d.mts +3 -1
- package/types.d.mts +75 -7
- package/vitest.d.mts +18 -1
- package/vitest.mjs +54 -14
package/reporter.mjs
CHANGED
|
@@ -1,46 +1,33 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import * as fs from 'node:fs/promises';
|
|
3
3
|
import { benchmarkUploadSchema, getCiMetadata } from "./ciReport.mjs";
|
|
4
|
-
import { calculateMean,
|
|
4
|
+
import { calculateMean, aggregateSamples } from "./stats.mjs";
|
|
5
5
|
import { dim, red, green, yellow, cyan, printTable, fileUrl } from "./format.mjs";
|
|
6
6
|
import { uploadCiReport } from "./upload.mjs";
|
|
7
7
|
import { syncPrComment } from "./syncPrComment.mjs";
|
|
8
8
|
// Import for TaskMeta augmentation side effect
|
|
9
9
|
import "./taskMetaAugmentation.mjs";
|
|
10
|
-
const byNumeric = (a, b) => a - b;
|
|
11
10
|
function getEventKey(event) {
|
|
12
11
|
return `${event.id}:${event.phase}`;
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let values = metricValues.get(metric.name);
|
|
20
|
-
if (!values) {
|
|
21
|
-
values = [];
|
|
22
|
-
metricValues.set(metric.name, values);
|
|
23
|
-
}
|
|
24
|
-
values.push(metric.value);
|
|
25
|
-
}
|
|
13
|
+
|
|
14
|
+
/** Order-insensitive deep equality, treating a missing key and an `undefined` value as equal. */
|
|
15
|
+
function deepEqual(first, second) {
|
|
16
|
+
if (first === second) {
|
|
17
|
+
return true;
|
|
26
18
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Apply IQR filtering
|
|
30
|
-
const sorted = [...values].sort(byNumeric);
|
|
31
|
-
const q1 = quantile(sorted, 0.25);
|
|
32
|
-
const q3 = quantile(sorted, 0.75);
|
|
33
|
-
const filtered = values.filter(d => !isOutlier(d, q1, q3));
|
|
34
|
-
const used = filtered.length > 0 ? filtered : values;
|
|
35
|
-
const mean = calculateMean(used);
|
|
36
|
-
const stdDev = calculateStdDev(used, mean);
|
|
37
|
-
result[name] = {
|
|
38
|
-
mean,
|
|
39
|
-
stdDev,
|
|
40
|
-
outliers: values.length - used.length
|
|
41
|
-
};
|
|
19
|
+
if (typeof first !== 'object' || first === null || typeof second !== 'object' || second === null) {
|
|
20
|
+
return false;
|
|
42
21
|
}
|
|
43
|
-
|
|
22
|
+
const firstRecord = first;
|
|
23
|
+
const secondRecord = second;
|
|
24
|
+
const keys = new Set([...Object.keys(firstRecord), ...Object.keys(secondRecord)]);
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
if (!deepEqual(firstRecord[key], secondRecord[key])) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
44
31
|
}
|
|
45
32
|
function generateReportFromIterations(iterations) {
|
|
46
33
|
if (iterations.length === 0) {
|
|
@@ -69,13 +56,11 @@ function generateReportFromIterations(iterations) {
|
|
|
69
56
|
const renderStats = [];
|
|
70
57
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
71
58
|
const durations = iterations.map(iteration => iteration.renders[index].actualDuration);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const iqrMean = calculateMean(used);
|
|
78
|
-
const iqrStdDev = calculateStdDev(used, iqrMean);
|
|
59
|
+
const {
|
|
60
|
+
mean: iqrMean,
|
|
61
|
+
stdDev: iqrStdDev,
|
|
62
|
+
outliers
|
|
63
|
+
} = aggregateSamples(durations);
|
|
79
64
|
const coefficientOfVariation = iqrMean > 0 ? iqrStdDev / iqrMean : 0;
|
|
80
65
|
if (iqrMean > 1 && coefficientOfVariation > 0.1) {
|
|
81
66
|
const event = firstIteration.renders[index];
|
|
@@ -85,7 +70,7 @@ function generateReportFromIterations(iterations) {
|
|
|
85
70
|
event: firstIteration.renders[index],
|
|
86
71
|
iqrMean,
|
|
87
72
|
iqrStdDev,
|
|
88
|
-
outliers
|
|
73
|
+
outliers
|
|
89
74
|
});
|
|
90
75
|
}
|
|
91
76
|
|
|
@@ -119,8 +104,8 @@ function generateReportFromIterations(iterations) {
|
|
|
119
104
|
totalDuration += iqrMean;
|
|
120
105
|
}
|
|
121
106
|
|
|
122
|
-
//
|
|
123
|
-
const metrics =
|
|
107
|
+
// Custom + paint metrics are merged separately from `task.meta.benchmarkMetrics`.
|
|
108
|
+
const metrics = {};
|
|
124
109
|
return {
|
|
125
110
|
iterations: iterationCount,
|
|
126
111
|
totalDuration,
|
|
@@ -167,21 +152,71 @@ function printDurationMatrix(name, report, footer) {
|
|
|
167
152
|
width: 4
|
|
168
153
|
}], rows, footer, name);
|
|
169
154
|
}
|
|
170
|
-
|
|
155
|
+
|
|
156
|
+
/** Strips a `#sub-series` suffix to recover the metric name used to look up its definition. */
|
|
157
|
+
function baseMetricName(key) {
|
|
158
|
+
const hashIndex = key.indexOf('#');
|
|
159
|
+
return hashIndex === -1 ? key : key.slice(0, hashIndex);
|
|
160
|
+
}
|
|
161
|
+
function formatMetricValue(value, definition) {
|
|
162
|
+
if (definition?.format) {
|
|
163
|
+
return new Intl.NumberFormat(undefined, definition.format).format(value);
|
|
164
|
+
}
|
|
165
|
+
return value.toFixed(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merges aggregated custom metrics (already stats, not raw samples) into a report entry, keyed
|
|
170
|
+
* `name` or `name#id` for sub-series, and collects each metric's config into the shared
|
|
171
|
+
* top-level definitions. For a metric-only test, derives the iteration count from the samples.
|
|
172
|
+
*/
|
|
173
|
+
function mergeCustomMetrics(report, customMetrics, definitions) {
|
|
174
|
+
let maxCount = 0;
|
|
175
|
+
for (const [metricName, metric] of Object.entries(customMetrics)) {
|
|
176
|
+
for (const [seriesId, stats] of Object.entries(metric.series)) {
|
|
177
|
+
const key = seriesId === '' ? metricName : `${metricName}#${seriesId}`;
|
|
178
|
+
report.metrics[key] = {
|
|
179
|
+
mean: stats.mean,
|
|
180
|
+
stdDev: stats.stdDev,
|
|
181
|
+
outliers: stats.outliers
|
|
182
|
+
};
|
|
183
|
+
maxCount = Math.max(maxCount, stats.count);
|
|
184
|
+
}
|
|
185
|
+
const definition = {
|
|
186
|
+
kind: metric.kind,
|
|
187
|
+
format: metric.config.format,
|
|
188
|
+
alarm: metric.config.alarm
|
|
189
|
+
};
|
|
190
|
+
// A metric name maps to one definition. Reusing it across benchmarks is fine when the config
|
|
191
|
+
// matches (e.g. the harness `bench:paint`), but conflicting config would silently apply
|
|
192
|
+
// last-write-wins to every entry — reject it instead.
|
|
193
|
+
const existing = definitions[metricName];
|
|
194
|
+
if (existing && !deepEqual(existing, definition)) {
|
|
195
|
+
throw new Error(`Benchmark metric "${metricName}" is defined with conflicting configuration across ` + `benchmarks. A metric name must map to a single kind, format, and alarm.`);
|
|
196
|
+
}
|
|
197
|
+
definitions[metricName] = definition;
|
|
198
|
+
}
|
|
199
|
+
if (report.iterations === 0) {
|
|
200
|
+
report.iterations = maxCount;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function printMetricsTable(name, metrics, iterationCount, definitions) {
|
|
171
204
|
const entries = Object.entries(metrics);
|
|
172
205
|
if (entries.length === 0) {
|
|
173
206
|
return;
|
|
174
207
|
}
|
|
175
208
|
const rows = entries.map(([metricName, stats]) => {
|
|
176
|
-
const
|
|
209
|
+
const definition = definitions[baseMetricName(metricName)];
|
|
210
|
+
const iqrStr = `${formatMetricValue(stats.mean, definition)}±${formatMetricValue(stats.stdDev, definition)}`;
|
|
177
211
|
const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
|
|
178
|
-
|
|
212
|
+
const label = definition?.alarm ? `${metricName} ⚠` : metricName;
|
|
213
|
+
return [label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), stats.outliers > 0 ? yellow(String(stats.outliers).padStart(4)) : dim('0'.padStart(4))];
|
|
179
214
|
});
|
|
180
215
|
printTable([{
|
|
181
216
|
header: 'Metric',
|
|
182
217
|
width: LABEL_WIDTH
|
|
183
218
|
}, {
|
|
184
|
-
header: 'Mean±σ
|
|
219
|
+
header: 'Mean±σ',
|
|
185
220
|
width: STAT_WIDTH
|
|
186
221
|
}, {
|
|
187
222
|
header: 'Var%',
|
|
@@ -204,28 +239,47 @@ async function loadBaselineReport(baselinePath) {
|
|
|
204
239
|
}
|
|
205
240
|
class BenchmarkReporter {
|
|
206
241
|
benchmarks = {};
|
|
242
|
+
metricDefinitions = {};
|
|
207
243
|
hasFailures = false;
|
|
208
244
|
constructor(options) {
|
|
209
245
|
this.outputPath = options?.outputPath ?? process.env.BENCHMARK_OUTPUT_PATH ?? path.resolve(process.cwd(), 'benchmarks', 'results.json');
|
|
210
246
|
this.upload = options?.upload ?? process.env.BENCHMARK_UPLOAD === 'true';
|
|
211
247
|
this.baselinePath = options?.baselinePath ?? process.env.BENCHMARK_BASELINE_PATH;
|
|
212
248
|
}
|
|
249
|
+
|
|
250
|
+
// Reset accumulated state at the start of every run so watch-mode re-runs start clean (the
|
|
251
|
+
// reporter instance is reused across runs). Otherwise stale benchmarks/definitions linger — and
|
|
252
|
+
// an edited metric config would conflict with its own previous-run definition.
|
|
253
|
+
onTestRunStart() {
|
|
254
|
+
this.benchmarks = {};
|
|
255
|
+
this.metricDefinitions = {};
|
|
256
|
+
this.hasFailures = false;
|
|
257
|
+
}
|
|
213
258
|
onTestCaseResult(testCase) {
|
|
214
259
|
if (testCase.result().state === 'failed') {
|
|
215
260
|
this.hasFailures = true;
|
|
216
261
|
}
|
|
217
262
|
const meta = testCase.meta();
|
|
218
263
|
const iterations = meta.benchmarkIterations;
|
|
219
|
-
|
|
264
|
+
const customMetrics = meta.benchmarkMetrics;
|
|
265
|
+
if (!iterations && !customMetrics) {
|
|
220
266
|
console.warn(yellow(` No iterations recorded for: ${testCase.fullName}`));
|
|
221
267
|
return;
|
|
222
268
|
}
|
|
223
269
|
const name = meta.benchmarkName ?? testCase.fullName;
|
|
224
|
-
const report = generateReportFromIterations(iterations)
|
|
270
|
+
const report = iterations ? generateReportFromIterations(iterations) : {
|
|
271
|
+
iterations: 0,
|
|
272
|
+
totalDuration: 0,
|
|
273
|
+
renders: [],
|
|
274
|
+
metrics: {}
|
|
275
|
+
};
|
|
276
|
+
if (customMetrics) {
|
|
277
|
+
mergeCustomMetrics(report, customMetrics, this.metricDefinitions);
|
|
278
|
+
}
|
|
225
279
|
this.benchmarks[name] = report;
|
|
226
280
|
const summary = dim('Total: ') + green(`${report.totalDuration.toFixed(2)}ms`) + dim(` (${report.renders.length} renders, ${report.iterations} iterations)`);
|
|
227
281
|
printDurationMatrix(`${name} — React`, report, summary);
|
|
228
|
-
printMetricsTable(name, report.metrics, report.iterations);
|
|
282
|
+
printMetricsTable(name, report.metrics, report.iterations, this.metricDefinitions);
|
|
229
283
|
}
|
|
230
284
|
async onTestRunEnd() {
|
|
231
285
|
const count = Object.keys(this.benchmarks).length;
|
|
@@ -237,11 +291,15 @@ class BenchmarkReporter {
|
|
|
237
291
|
console.log(` ${name}: ${result.totalDuration.toFixed(2)}ms ${dim(`(${result.renders.length} renders, ${result.iterations} iterations)`)}`);
|
|
238
292
|
}
|
|
239
293
|
const baseline = this.baselinePath ? await loadBaselineReport(this.baselinePath) : undefined;
|
|
294
|
+
const hasMetricDefinitions = Object.keys(this.metricDefinitions).length > 0;
|
|
240
295
|
const results = {
|
|
241
296
|
version: 1,
|
|
242
297
|
reportType: 'benchmark',
|
|
243
298
|
...(await getCiMetadata()),
|
|
244
299
|
report: this.benchmarks,
|
|
300
|
+
...(hasMetricDefinitions ? {
|
|
301
|
+
metricDefinitions: this.metricDefinitions
|
|
302
|
+
} : {}),
|
|
245
303
|
...(baseline ? {
|
|
246
304
|
base: baseline
|
|
247
305
|
} : {})
|
package/stats.d.mts
CHANGED
|
@@ -7,4 +7,14 @@ export declare function quantile(sorted: number[], q: number): number;
|
|
|
7
7
|
*
|
|
8
8
|
* See https://en.wikipedia.org/wiki/Interquartile_range#Outliers
|
|
9
9
|
*/
|
|
10
|
-
export declare function isOutlier(value: number, q1: number, q3: number): boolean;
|
|
10
|
+
export declare function isOutlier(value: number, q1: number, q3: number): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
13
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
14
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
15
|
+
*/
|
|
16
|
+
export declare function aggregateSamples(values: number[]): {
|
|
17
|
+
mean: number;
|
|
18
|
+
stdDev: number;
|
|
19
|
+
outliers: number;
|
|
20
|
+
};
|
package/stats.mjs
CHANGED
|
@@ -24,4 +24,24 @@ export function quantile(sorted, q) {
|
|
|
24
24
|
export function isOutlier(value, q1, q3) {
|
|
25
25
|
const iqr = q3 - q1;
|
|
26
26
|
return value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
31
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
32
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
33
|
+
*/
|
|
34
|
+
export function aggregateSamples(values) {
|
|
35
|
+
const sorted = values.toSorted((first, second) => first - second);
|
|
36
|
+
const q1 = quantile(sorted, 0.25);
|
|
37
|
+
const q3 = quantile(sorted, 0.75);
|
|
38
|
+
const filtered = values.filter(value => !isOutlier(value, q1, q3));
|
|
39
|
+
const used = filtered.length > 0 ? filtered : values;
|
|
40
|
+
const mean = calculateMean(used);
|
|
41
|
+
const stdDev = calculateStdDev(used, mean);
|
|
42
|
+
return {
|
|
43
|
+
mean,
|
|
44
|
+
stdDev,
|
|
45
|
+
outliers: values.length - used.length
|
|
46
|
+
};
|
|
27
47
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { IterationData } from "./types.mjs";
|
|
1
|
+
import type { IterationData, MetricReport } from "./types.mjs";
|
|
2
2
|
declare module 'vitest' {
|
|
3
3
|
interface TaskMeta {
|
|
4
4
|
benchmarkName?: string;
|
|
5
5
|
benchmarkIterations?: IterationData[];
|
|
6
|
+
/** Custom metrics recorded via `ScalarMetric`/`DiscreteMetric`, keyed by metric name. */
|
|
7
|
+
benchmarkMetrics?: Record<string, MetricReport>;
|
|
6
8
|
}
|
|
7
9
|
}
|
package/types.d.mts
CHANGED
|
@@ -13,15 +13,8 @@ export interface RenderEvent {
|
|
|
13
13
|
/** Start time in milliseconds (from performance.now()) */
|
|
14
14
|
startTime: number;
|
|
15
15
|
}
|
|
16
|
-
export interface BenchmarkMetric {
|
|
17
|
-
/** Metric name, e.g. "paint:bench", "paint:grid-header" */
|
|
18
|
-
name: string;
|
|
19
|
-
/** Measured value in ms */
|
|
20
|
-
value: number;
|
|
21
|
-
}
|
|
22
16
|
export interface IterationData {
|
|
23
17
|
renders: RenderEvent[];
|
|
24
|
-
metrics: BenchmarkMetric[];
|
|
25
18
|
}
|
|
26
19
|
export interface InteractionContext {
|
|
27
20
|
/**
|
|
@@ -30,4 +23,79 @@ export interface InteractionContext {
|
|
|
30
23
|
* @param timeout - Timeout in ms. Default: 5000. Pass 0 or Infinity to rely on the test timeout.
|
|
31
24
|
*/
|
|
32
25
|
waitForElementTiming: (identifier: string, timeout?: number) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Pause recording of the harness's React render/paint measurements. Custom metrics keep
|
|
28
|
+
* recording. Throws if recording is already paused.
|
|
29
|
+
*/
|
|
30
|
+
pauseReactRecording: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Resume recording of the harness's React render/paint measurements. Throws if recording is
|
|
33
|
+
* already active.
|
|
34
|
+
*/
|
|
35
|
+
resumeReactRecording: () => void;
|
|
36
|
+
}
|
|
37
|
+
export interface BenchmarkCaseRuntime {
|
|
38
|
+
mount: () => void;
|
|
39
|
+
/**
|
|
40
|
+
* Runs the case's interaction with the harness context. Absent when the case has no interaction —
|
|
41
|
+
* the profiling panel uses its presence to decide whether to show the "Run interaction" button.
|
|
42
|
+
*/
|
|
43
|
+
interact?: () => Promise<void>;
|
|
44
|
+
unmount: () => void;
|
|
45
|
+
isMounted: () => boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether a custom metric measures a continuous value or a discrete count.
|
|
49
|
+
* - `scalar` — continuous measurements (timings, sizes); compared with a relative noise band.
|
|
50
|
+
* - `discrete` — counts/events; compared as exact integers.
|
|
51
|
+
*/
|
|
52
|
+
export type MetricKind = 'scalar' | 'discrete';
|
|
53
|
+
/** Which direction of change counts as a regression for a metric in alarm mode. */
|
|
54
|
+
export type MetricDirection = 'lowerIsBetter' | 'higherIsBetter';
|
|
55
|
+
export interface MetricAlarm {
|
|
56
|
+
/** Defaults to `lowerIsBetter`. */
|
|
57
|
+
direction?: MetricDirection;
|
|
58
|
+
/**
|
|
59
|
+
* Softer band: a regression past `warn` (but within `error`) is flagged as a warning.
|
|
60
|
+
* Scalar metrics: a relative fraction (`0.1` = 10%). Discrete metrics: an absolute count delta.
|
|
61
|
+
*/
|
|
62
|
+
warn?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Harder band: a regression past `error` is flagged as an error (the alarm). When **both**
|
|
65
|
+
* `warn` and `error` are omitted, `error` defaults to the dashboard's global noise band; with
|
|
66
|
+
* only `warn` set there is no error band (warning-only).
|
|
67
|
+
* Scalar metrics: a relative fraction (`0.25` = 25%). Discrete metrics: an absolute count delta.
|
|
68
|
+
*/
|
|
69
|
+
error?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface MetricConfig {
|
|
72
|
+
name: string;
|
|
73
|
+
/** Display formatting applied by the reporter and dashboard via `Intl.NumberFormat`. */
|
|
74
|
+
format?: Intl.NumberFormatOptions;
|
|
75
|
+
/**
|
|
76
|
+
* Regression judgment. Its presence opts the metric into alarming; when omitted the metric
|
|
77
|
+
* is informational (the diff is shown but never flagged).
|
|
78
|
+
*/
|
|
79
|
+
alarm?: MetricAlarm;
|
|
80
|
+
}
|
|
81
|
+
/** Aggregated stats for a single metric sub-series, as they cross the browser→runner boundary. */
|
|
82
|
+
export interface MetricSampleStats {
|
|
83
|
+
mean: number;
|
|
84
|
+
stdDev: number;
|
|
85
|
+
outliers: number;
|
|
86
|
+
/** Number of recorded samples (used to derive iteration counts; stripped from the report). */
|
|
87
|
+
count: number;
|
|
88
|
+
}
|
|
89
|
+
/** A custom metric's aggregated data attached to `task.meta`, keyed by metric name. */
|
|
90
|
+
export interface MetricReport {
|
|
91
|
+
kind: MetricKind;
|
|
92
|
+
config: MetricConfig;
|
|
93
|
+
/** Aggregated stats keyed by sub-series id (`''` is the base series). */
|
|
94
|
+
series: Record<string, MetricSampleStats>;
|
|
95
|
+
}
|
|
96
|
+
/** Per-metric configuration hoisted to the top level of the report (keyed by metric name). */
|
|
97
|
+
export interface MetricDefinition {
|
|
98
|
+
kind: MetricKind;
|
|
99
|
+
format?: Intl.NumberFormatOptions;
|
|
100
|
+
alarm?: MetricAlarm;
|
|
33
101
|
}
|
package/vitest.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ViteUserConfig } from 'vitest/config';
|
|
1
|
+
import type { ViteUserConfig } from 'vitest/config';
|
|
2
2
|
export interface CreateBenchmarkVitestConfigOptions {
|
|
3
3
|
/**
|
|
4
4
|
* Path to save benchmark results JSON file. If not provided, results will not be saved to disk.
|
|
@@ -15,5 +15,22 @@ export interface CreateBenchmarkVitestConfigOptions {
|
|
|
15
15
|
* Additional Chromium launch arguments.
|
|
16
16
|
*/
|
|
17
17
|
launchArgs?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Run each `benchmark()` case as an interactive profiling session in a headed
|
|
20
|
+
* browser instead of the automated measurement loop. Each case renders a
|
|
21
|
+
* control panel with Render / Unmount / Finish buttons so you can start the
|
|
22
|
+
* DevTools profiler before the component mounts. Profiling auto-opens DevTools
|
|
23
|
+
* and runs headed (pass `viewport` to size the window). Also settable via
|
|
24
|
+
* `BENCHMARK_PROFILE=true`.
|
|
25
|
+
*/
|
|
26
|
+
profile?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Browser viewport — and, in profile mode, the matching window size. Defaults to 1920x1080 for
|
|
29
|
+
* both measurement and profiling. Also settable via `BENCHMARK_VIEWPORT` (e.g. `2560x1440`).
|
|
30
|
+
*/
|
|
31
|
+
viewport?: {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
18
35
|
}
|
|
19
36
|
export declare function createBenchmarkVitestConfig(options?: CreateBenchmarkVitestConfigOptions): ViteUserConfig;
|
package/vitest.mjs
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
1
|
import react from '@vitejs/plugin-react';
|
|
2
2
|
import { playwright } from '@vitest/browser-playwright';
|
|
3
|
+
// Default viewport for all benchmark runs (measurement and profiling alike) — a desktop size is
|
|
4
|
+
// more representative for component benchmarks than Vitest's phone-sized 414x896 browser default.
|
|
5
|
+
const DEFAULT_VIEWPORT = {
|
|
6
|
+
width: 1920,
|
|
7
|
+
height: 1080
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Explicit viewport from the `viewport` option or the `BENCHMARK_VIEWPORT` env var
|
|
11
|
+
// (`<width>x<height>`); undefined when neither is set, so the caller can apply the default.
|
|
12
|
+
function resolveViewport(option) {
|
|
13
|
+
if (option) {
|
|
14
|
+
return option;
|
|
15
|
+
}
|
|
16
|
+
const env = process.env.BENCHMARK_VIEWPORT;
|
|
17
|
+
const match = env ? env.split('x') : null;
|
|
18
|
+
if (match && match.length === 2) {
|
|
19
|
+
return {
|
|
20
|
+
width: Number(match[0]),
|
|
21
|
+
height: Number(match[1])
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Chromium/V8 launch args shared by measurement and profiling, kept intentionally minimal.
|
|
28
|
+
// `--expose-gc` is required: the harness forces GC between iterations for clean, comparable
|
|
29
|
+
// timings. The backgrounding flags stop Chrome from throttling the (headless or occluded)
|
|
30
|
+
// benchmark tab, which would otherwise add large variance. Heavier "determinism" flags
|
|
31
|
+
// (`--no-opt`, `--predictable`, `--hash-seed`/`--random-seed`, `--disable-gpu`,
|
|
32
|
+
// `--enable-benchmarking`) were measured to slow renders ~40% and distort paint timing without
|
|
33
|
+
// reducing variance, so they are omitted — add them per project via `launchArgs` if a specific
|
|
34
|
+
// workload needs them.
|
|
35
|
+
const LAUNCH_ARGS = ['--js-flags=--expose-gc', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding'];
|
|
3
36
|
export function createBenchmarkVitestConfig(options) {
|
|
4
37
|
const {
|
|
5
38
|
outputPath,
|
|
6
39
|
baselinePath,
|
|
7
40
|
launchArgs = []
|
|
8
41
|
} = options ?? {};
|
|
42
|
+
const profile = options?.profile ?? process.env.BENCHMARK_PROFILE === 'true';
|
|
43
|
+
const viewport = resolveViewport(options?.viewport) ?? DEFAULT_VIEWPORT;
|
|
44
|
+
|
|
45
|
+
// Profiling adds DevTools on top of the shared args, plus a window sized to match the viewport —
|
|
46
|
+
// Vitest's `viewport` only sizes the iframe, so otherwise it's cropped/scrolled in the headed
|
|
47
|
+
// window instead of filling it. (Measurement is headless, so it has no window to size.)
|
|
48
|
+
const profileArgs = [...LAUNCH_ARGS, '--auto-open-devtools-for-tabs', `--window-size=${viewport.width},${viewport.height}`];
|
|
9
49
|
return {
|
|
10
50
|
plugins: [react()],
|
|
11
51
|
define: {
|
|
12
|
-
'process.env.NODE_ENV': '"production"'
|
|
52
|
+
'process.env.NODE_ENV': '"production"',
|
|
53
|
+
'process.env.BENCHMARK_PROFILE': JSON.stringify(profile ? 'true' : '')
|
|
13
54
|
},
|
|
14
55
|
resolve: {
|
|
15
56
|
dedupe: ['react', 'react-dom'],
|
|
@@ -21,29 +62,28 @@ export function createBenchmarkVitestConfig(options) {
|
|
|
21
62
|
test: {
|
|
22
63
|
browser: {
|
|
23
64
|
enabled: true,
|
|
24
|
-
headless:
|
|
65
|
+
headless: !profile,
|
|
66
|
+
// Profiling renders into a clean page: hide Vitest's browser runner UI
|
|
67
|
+
// so the orchestrator chrome doesn't clutter what you're profiling.
|
|
68
|
+
ui: profile ? false : undefined,
|
|
69
|
+
// Same viewport for both modes (DEFAULT_VIEWPORT unless overridden).
|
|
70
|
+
viewport,
|
|
25
71
|
screenshotFailures: false,
|
|
26
72
|
instances: [{
|
|
27
73
|
browser: 'chromium',
|
|
28
|
-
|
|
74
|
+
// Profiling sessions are driven by hand, so give them effectively
|
|
75
|
+
// unlimited time instead of the measurement timeout.
|
|
76
|
+
testTimeout: profile ? 0 : 120_000
|
|
29
77
|
}],
|
|
30
78
|
provider: playwright({
|
|
31
79
|
launchOptions: {
|
|
32
|
-
args: [
|
|
33
|
-
// V8 flags for deterministic JS execution
|
|
34
|
-
'--js-flags=--expose-gc,--predictable,--no-opt,--predictable-gc-schedule,--no-concurrent-sweeping,--hash-seed=1,--random-seed=1,--max-old-space-size=4096',
|
|
35
|
-
// Chromium flags to reduce renderer/compositor noise
|
|
36
|
-
'--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-networking',
|
|
37
|
-
// Reduces environmental noise by disabling field trials,
|
|
38
|
-
// for more consistent profiling results.
|
|
39
|
-
'--enable-benchmarking',
|
|
40
|
-
// Forces software rendering instead of GPU, which is more deterministic.
|
|
41
|
-
'--disable-gpu', ...launchArgs]
|
|
80
|
+
args: [...(profile ? profileArgs : LAUNCH_ARGS), ...launchArgs]
|
|
42
81
|
}
|
|
43
82
|
})
|
|
44
83
|
},
|
|
45
84
|
fileParallelism: false,
|
|
46
|
-
|
|
85
|
+
// Profiling sessions don't measure anything, so skip the results reporter.
|
|
86
|
+
reporters: profile ? ['default'] : ['default', ['@mui/internal-benchmark/reporter', {
|
|
47
87
|
outputPath,
|
|
48
88
|
baselinePath
|
|
49
89
|
}]],
|