@mui/internal-benchmark 0.0.3-canary.5 → 0.0.3-canary.7

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  ## 2.0.8
4
4
 
5
5
  Test release
6
+ dummy PR
6
7
 
7
8
  ## 2.0.7
8
9
 
@@ -0,0 +1,15 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ import type { MetricConfig, MetricKind } from "./types.mjs";
3
+ /**
4
+ * A discrete count of events or occurrences. Compared against a baseline as an exact integer
5
+ * (any change is significant — there is no noise band), and formatted as a whole number by default.
6
+ *
7
+ * ```ts
8
+ * const clicks = new DiscreteMetric({ name: 'button_clicks' });
9
+ * clicks.record(countClicks());
10
+ * ```
11
+ */
12
+ export declare class DiscreteMetric extends Metric {
13
+ readonly kind: MetricKind;
14
+ constructor(config: MetricConfig | string);
15
+ }
@@ -0,0 +1,24 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ /**
3
+ * A discrete count of events or occurrences. Compared against a baseline as an exact integer
4
+ * (any change is significant — there is no noise band), and formatted as a whole number by default.
5
+ *
6
+ * ```ts
7
+ * const clicks = new DiscreteMetric({ name: 'button_clicks' });
8
+ * clicks.record(countClicks());
9
+ * ```
10
+ */
11
+ export class DiscreteMetric extends Metric {
12
+ kind = 'discrete';
13
+ constructor(config) {
14
+ const resolved = typeof config === 'string' ? {
15
+ name: config
16
+ } : config;
17
+ super({
18
+ ...resolved,
19
+ format: resolved.format ?? {
20
+ maximumFractionDigits: 0
21
+ }
22
+ });
23
+ }
24
+ }
package/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- The MIT License (MIT)
1
+ MIT License
2
2
 
3
3
  Copyright (c) 2019 Material-UI SAS
4
4
 
package/Metric.d.mts ADDED
@@ -0,0 +1,24 @@
1
+ import type { MetricConfig, MetricKind } from "./types.mjs";
2
+ import "./taskMetaAugmentation.mjs";
3
+ export interface MetricRecordOptions {
4
+ /** Sub-series label. Recorded under `${name}#${id}` in the report; omit for the base series. */
5
+ id?: string;
6
+ }
7
+ /**
8
+ * Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
9
+ *
10
+ * Records are tied to the test that is running when `record()` is called (resolved via
11
+ * `getCurrentTest()`), so a single instance can be declared at module scope and reused across
12
+ * tests and loop iterations, inside or outside React.
13
+ */
14
+ export declare abstract class Metric {
15
+ abstract readonly kind: MetricKind;
16
+ readonly name: string;
17
+ protected readonly config: MetricConfig;
18
+ constructor(config: MetricConfig | string);
19
+ /**
20
+ * Records a single measured value. Samples accumulate in browser memory and are aggregated
21
+ * once when the test finishes. Pass `options.id` to split into a labeled sub-series.
22
+ */
23
+ record(value: number, options?: MetricRecordOptions): void;
24
+ }
package/Metric.mjs ADDED
@@ -0,0 +1,89 @@
1
+ import { onTestFinished, TestRunner } from 'vitest';
2
+ import { aggregateSamples } from "./stats.mjs";
3
+ import { metricsGate } from "./metricsGate.mjs";
4
+ // Import for TaskMeta augmentation side effect
5
+ import "./taskMetaAugmentation.mjs";
6
+ // Raw samples never cross the browser→runner boundary. They accumulate here per test (keyed by
7
+ // the running task so a module-scoped metric shared across tests never mixes data) and are
8
+ // aggregated into compact stats by an `onTestFinished` hook before being written to `task.meta`.
9
+ const accumulators = new WeakMap();
10
+ function flush(test, accumulator) {
11
+ const store = {};
12
+ for (const [name, entry] of accumulator) {
13
+ const series = {};
14
+ for (const [seriesId, samples] of entry.series) {
15
+ series[seriesId] = {
16
+ ...aggregateSamples(samples),
17
+ count: samples.length
18
+ };
19
+ }
20
+ store[name] = {
21
+ kind: entry.kind,
22
+ config: entry.config,
23
+ series
24
+ };
25
+ }
26
+ test.meta.benchmarkMetrics = store;
27
+ }
28
+ /**
29
+ * Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
30
+ *
31
+ * Records are tied to the test that is running when `record()` is called (resolved via
32
+ * `getCurrentTest()`), so a single instance can be declared at module scope and reused across
33
+ * tests and loop iterations, inside or outside React.
34
+ */
35
+ export class Metric {
36
+ constructor(config) {
37
+ this.config = typeof config === 'string' ? {
38
+ name: config
39
+ } : config;
40
+ this.name = this.config.name;
41
+ if (this.name.includes('#')) {
42
+ throw new Error(`Metric name "${this.name}" must not contain "#" — it is reserved as the sub-series separator.`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Records a single measured value. Samples accumulate in browser memory and are aggregated
48
+ * once when the test finishes. Pass `options.id` to split into a labeled sub-series.
49
+ */
50
+ record(value, options) {
51
+ const test = TestRunner.getCurrentTest();
52
+ if (!test) {
53
+ throw new Error(`${this.constructor.name}.record() must be called inside a running Vitest test.`);
54
+ }
55
+
56
+ // The harness disables the gate during warmup iterations so custom metrics recorded inside a
57
+ // benchmark honor the same warmup exclusion as renders and `bench:paint`. Standalone `it()`
58
+ // loops never touch the gate, so they keep recording every value.
59
+ if (!metricsGate.isRecordingEnabled(test)) {
60
+ return;
61
+ }
62
+ let accumulator = accumulators.get(test);
63
+ if (!accumulator) {
64
+ const created = new Map();
65
+ accumulator = created;
66
+ accumulators.set(test, created);
67
+ onTestFinished(() => flush(test, created));
68
+ }
69
+ let entry = accumulator.get(this.name);
70
+ if (!entry) {
71
+ entry = {
72
+ owner: this,
73
+ kind: this.kind,
74
+ config: this.config,
75
+ series: new Map()
76
+ };
77
+ accumulator.set(this.name, entry);
78
+ } else if (entry.owner !== this) {
79
+ throw new Error(`Two metrics share the name "${this.name}". Metric names must be unique; reuse a single instance instead.`);
80
+ }
81
+ const seriesId = options?.id ?? '';
82
+ let samples = entry.series.get(seriesId);
83
+ if (!samples) {
84
+ samples = [];
85
+ entry.series.set(seriesId, samples);
86
+ }
87
+ samples.push(value);
88
+ }
89
+ }
package/README.md CHANGED
@@ -61,9 +61,44 @@ benchmark(
61
61
  );
62
62
  ```
63
63
 
64
+ ### Scoping which renders are measured
65
+
66
+ By default a benchmark records every React render and paint, from the mount through the whole interaction. To measure only part of an interaction — or to exclude the mount — pause and resume recording from the interaction callback:
67
+
68
+ ```tsx
69
+ benchmark(
70
+ 'Combobox type',
71
+ () => <Combobox />,
72
+ async ({ pauseReactRecording, resumeReactRecording, waitForElementTiming }) => {
73
+ pauseReactRecording(); // stop recording the settling re-renders
74
+ await openMenu();
75
+ resumeReactRecording(); // measure only what follows
76
+ await type('hello');
77
+ await waitForElementTiming('results');
78
+ },
79
+ );
80
+ ```
81
+
82
+ `pauseReactRecording()` / `resumeReactRecording()` toggle only the harness's React render and `bench:paint` recording — your own custom metrics keep recording. They are a strict pair: pausing while already paused, or resuming while already active, throws (this catches unbalanced calls early).
83
+
84
+ To exclude the mount itself, start paused with the `reactRecordingPaused` option and resume at the point you care about — the mount is captured before the interaction callback runs, so pausing inside the callback can't drop it:
85
+
86
+ ```tsx
87
+ benchmark(
88
+ 'Combobox type',
89
+ () => <Combobox />,
90
+ async ({ resumeReactRecording }) => {
91
+ await openMenu(); // mount + open: not recorded
92
+ resumeReactRecording();
93
+ await type('hello'); // only these renders/paint recorded
94
+ },
95
+ { reactRecordingPaused: true },
96
+ );
97
+ ```
98
+
64
99
  ### Paint metrics
65
100
 
66
- By default, every benchmark captures a `paint:default` metric — the time from iteration start until the browser actually paints the rendered output. This uses the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) via an invisible sentinel element that the benchmark harness renders automatically.
101
+ By default, every benchmark captures a `bench:paint` metric — the time from iteration start until the browser actually paints the rendered output. This uses the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) via an invisible sentinel element that the benchmark harness renders automatically. The harness owns the `bench:` namespace, so avoid it for your own metric names.
67
102
 
68
103
  You can track additional paint metrics by placing `<ElementTiming>` markers and awaiting them in an interaction callback. The component renders an invisible `<span>` that fires in the same paint frame as its surrounding content.
69
104
 
@@ -88,16 +123,79 @@ benchmark(
88
123
  );
89
124
  ```
90
125
 
91
- This produces a `paint:my-component` metric alongside the automatic `paint:default`.
126
+ This produces a `bench:paint#my-component` sub-series alongside the automatic `bench:paint`.
92
127
 
93
128
  `waitForElementTiming` accepts an optional `timeout` in milliseconds (default: 5000). Pass `0` or `Infinity` to rely on the test timeout instead.
94
129
 
130
+ ### Custom metrics
131
+
132
+ Record your own measurements — a timing, a count, anything measured inside or outside React — from a plain `it()` loop or from inside a `benchmark()`. There are two primitives:
133
+
134
+ - `ScalarMetric` — a continuous value (timings, sizes). Aggregated as mean ± standard deviation with IQR outlier removal, and compared against a baseline with a relative noise band.
135
+ - `DiscreteMetric` — a count of events. Compared as an exact integer (any change is significant) and formatted as a whole number.
136
+
137
+ Both record values with `record(value)`. `ScalarMetric` additionally offers `time()`/`timeEnd()` — a `console.time`-style shortcut that records the elapsed milliseconds for you.
138
+
139
+ ```tsx
140
+ import { it } from 'vitest';
141
+ import { ScalarMetric, DiscreteMetric } from '@mui/internal-benchmark';
142
+
143
+ const duration = new ScalarMetric({
144
+ name: 'work_duration',
145
+ format: { style: 'unit', unit: 'millisecond' }, // Intl.NumberFormatOptions
146
+ alarm: { direction: 'lowerIsBetter', warn: 0.1, error: 0.25 }, // warn >10%, error >25%
147
+ });
148
+
149
+ const clicks = new DiscreteMetric({ name: 'button_clicks' });
150
+
151
+ it('measures work', () => {
152
+ for (let i = 0; i < 100; i += 1) {
153
+ duration.time();
154
+ runWork();
155
+ duration.timeEnd(); // records the elapsed milliseconds
156
+
157
+ clicks.record(countClicks()); // a discrete count per run
158
+ }
159
+ });
160
+ ```
161
+
162
+ A metric is declared once (typically at module scope) and reused across tests and iterations. `record()` attaches the value to whichever test is running, so the same instance works in any `it()`.
163
+
164
+ You can also `record()` or `time()` from inside a `benchmark()` render function or interaction callback. Values recorded during warmup iterations are excluded automatically, just like renders and `bench:paint`, so a metric recorded once per iteration yields exactly `runs` samples.
165
+
166
+ #### Metric configuration
167
+
168
+ - `name` — the metric's report key (**required**).
169
+ - `format` — an [`Intl.NumberFormatOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Intl/NumberFormat/NumberFormat) object used to display the value.
170
+ - `alarm` — opts the metric into regression flagging. Omit it and the metric is informational (its diff is shown but never flagged). Holds:
171
+ - `direction` — `'lowerIsBetter'` (default) or `'higherIsBetter'`.
172
+ - `warn` — softer band; a regression past it is flagged as a warning.
173
+ - `error` — harder band; a regression past it is flagged as an error. Defaults to the dashboard's global noise band only when both `warn` and `error` are omitted; with only `warn` set there is no error band (warning-only).
174
+ - Bands are relative fractions for scalar metrics (`0.1` = 10%) and absolute count deltas for discrete metrics (`1`, `2`). Either band is optional.
175
+
176
+ Alarms are evaluated against the baseline when the PR comment is generated, not during the local `vitest run` — a regression never fails the test suite locally. In the PR comment, `error`-band regressions surface as failures and `warn`-band regressions as warnings.
177
+
178
+ #### Sub-series
179
+
180
+ Pass `record(value, { id })` to split one metric into labeled sub-series, reported as `name#id`. For `ScalarMetric.time()`/`timeEnd()`, pass a label that maps to the same `id`:
181
+
182
+ ```tsx
183
+ const phase = new ScalarMetric({ name: 'render_phase' });
184
+
185
+ phase.time('header');
186
+ renderHeader();
187
+ phase.timeEnd('header'); // -> render_phase#header
188
+ ```
189
+
190
+ Custom metrics are aggregated in the browser and only the resulting stats cross to the runner, so the amount of data is independent of how many values you record.
191
+
95
192
  ### Options
96
193
 
97
194
  ```tsx
98
195
  benchmark('name', renderFn, interaction, {
99
196
  runs: 20, // measurement iterations (default: 20)
100
197
  warmupRuns: 10, // warmup iterations before measuring (default: 10)
198
+ reactRecordingPaused: false, // start with React render/paint recording paused (default: false)
101
199
  afterEach: () => {
102
200
  /* cleanup between iterations */
103
201
  },
@@ -148,5 +246,7 @@ The feature is opt-in — without `BENCHMARK_BASELINE_PATH` (or the `baselinePat
148
246
 
149
247
  - `benchmark` — define a benchmark test case
150
248
  - `ElementTiming` — invisible marker component for paint timing (renders a `<span>` tracked by the Element Timing API)
249
+ - `ScalarMetric` — record a continuous custom measurement (with a `console.time`-style timing helper)
250
+ - `DiscreteMetric` — record a discrete custom count
151
251
  - `createBenchmarkVitestConfig` — create a Vitest config with browser benchmarking defaults
152
252
  - `BenchmarkReporter` — Vitest reporter that collects and outputs benchmark results
@@ -0,0 +1,23 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ import type { MetricKind } from "./types.mjs";
3
+ /**
4
+ * A continuous measurement (timings, sizes, …). Samples are aggregated with mean ± standard
5
+ * deviation and IQR outlier removal, and compared against a baseline with a relative noise band.
6
+ *
7
+ * It offers a `console.time`-style timing helper:
8
+ *
9
+ * ```ts
10
+ * const metric = new ScalarMetric({ name: 'render', format: { style: 'unit', unit: 'millisecond' } });
11
+ * metric.time();
12
+ * doWork();
13
+ * metric.timeEnd(); // records the elapsed milliseconds
14
+ * ```
15
+ */
16
+ export declare class ScalarMetric extends Metric {
17
+ readonly kind: MetricKind;
18
+ private readonly pending;
19
+ /** Starts a timer. Pass a `label` to time a sub-series; it maps to `record`'s `id`. */
20
+ time(label?: string): void;
21
+ /** Stops the timer started by `time(label)` and records the elapsed milliseconds. */
22
+ timeEnd(label?: string): void;
23
+ }
@@ -0,0 +1,42 @@
1
+ import { Metric } from "./Metric.mjs";
2
+ /**
3
+ * A continuous measurement (timings, sizes, …). Samples are aggregated with mean ± standard
4
+ * deviation and IQR outlier removal, and compared against a baseline with a relative noise band.
5
+ *
6
+ * It offers a `console.time`-style timing helper:
7
+ *
8
+ * ```ts
9
+ * const metric = new ScalarMetric({ name: 'render', format: { style: 'unit', unit: 'millisecond' } });
10
+ * metric.time();
11
+ * doWork();
12
+ * metric.timeEnd(); // records the elapsed milliseconds
13
+ * ```
14
+ */
15
+ export class ScalarMetric extends Metric {
16
+ kind = 'scalar';
17
+ pending = new Map();
18
+
19
+ /** Starts a timer. Pass a `label` to time a sub-series; it maps to `record`'s `id`. */
20
+ time(label) {
21
+ const key = label ?? '';
22
+ if (this.pending.has(key)) {
23
+ throw new Error(`${this.name}.time(${label ? `"${label}"` : ''}) was called while a timer is already running for that label.`);
24
+ }
25
+ this.pending.set(key, performance.now());
26
+ }
27
+
28
+ /** Stops the timer started by `time(label)` and records the elapsed milliseconds. */
29
+ timeEnd(label) {
30
+ // Capture the end time first so the map lookup/delete below isn't part of the measurement.
31
+ const end = performance.now();
32
+ const key = label ?? '';
33
+ const start = this.pending.get(key);
34
+ if (start === undefined) {
35
+ throw new Error(`${this.name}.timeEnd(${label ? `"${label}"` : ''}) was called without a matching time().`);
36
+ }
37
+ this.pending.delete(key);
38
+ this.record(end - start, label !== undefined ? {
39
+ id: label
40
+ } : undefined);
41
+ }
42
+ }
package/ciReport.d.mts CHANGED
@@ -58,6 +58,21 @@ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
58
58
  outliers: z.ZodNumber;
59
59
  }, z.core.$strip>>;
60
60
  }, z.core.$strip>>;
61
+ declare const metricDefinitionSchema: z.ZodObject<{
62
+ kind: z.ZodEnum<{
63
+ discrete: "discrete";
64
+ scalar: "scalar";
65
+ }>;
66
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
67
+ alarm: z.ZodOptional<z.ZodObject<{
68
+ direction: z.ZodOptional<z.ZodEnum<{
69
+ higherIsBetter: "higherIsBetter";
70
+ lowerIsBetter: "lowerIsBetter";
71
+ }>>;
72
+ warn: z.ZodOptional<z.ZodNumber>;
73
+ error: z.ZodOptional<z.ZodNumber>;
74
+ }, z.core.$strip>>;
75
+ }, z.core.$strip>;
61
76
  declare const benchmarkBaseUploadSchema: z.ZodObject<{
62
77
  version: z.ZodLiteral<number>;
63
78
  timestamp: z.ZodNumber;
@@ -67,6 +82,21 @@ declare const benchmarkBaseUploadSchema: z.ZodObject<{
67
82
  prNumber: z.ZodOptional<z.ZodNumber>;
68
83
  branch: z.ZodString;
69
84
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
85
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
86
+ kind: z.ZodEnum<{
87
+ discrete: "discrete";
88
+ scalar: "scalar";
89
+ }>;
90
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
91
+ alarm: z.ZodOptional<z.ZodObject<{
92
+ direction: z.ZodOptional<z.ZodEnum<{
93
+ higherIsBetter: "higherIsBetter";
94
+ lowerIsBetter: "lowerIsBetter";
95
+ }>>;
96
+ warn: z.ZodOptional<z.ZodNumber>;
97
+ error: z.ZodOptional<z.ZodNumber>;
98
+ }, z.core.$strip>>;
99
+ }, z.core.$strip>>>;
70
100
  }, z.core.$strip>;
71
101
  export declare const benchmarkUploadSchema: z.ZodObject<{
72
102
  version: z.ZodLiteral<number>;
@@ -77,6 +107,21 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
77
107
  prNumber: z.ZodOptional<z.ZodNumber>;
78
108
  branch: z.ZodString;
79
109
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
110
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
111
+ kind: z.ZodEnum<{
112
+ discrete: "discrete";
113
+ scalar: "scalar";
114
+ }>;
115
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
116
+ alarm: z.ZodOptional<z.ZodObject<{
117
+ direction: z.ZodOptional<z.ZodEnum<{
118
+ higherIsBetter: "higherIsBetter";
119
+ lowerIsBetter: "lowerIsBetter";
120
+ }>>;
121
+ warn: z.ZodOptional<z.ZodNumber>;
122
+ error: z.ZodOptional<z.ZodNumber>;
123
+ }, z.core.$strip>>;
124
+ }, z.core.$strip>>>;
80
125
  base: z.ZodOptional<z.ZodObject<{
81
126
  version: z.ZodLiteral<number>;
82
127
  timestamp: z.ZodNumber;
@@ -86,10 +131,26 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
86
131
  prNumber: z.ZodOptional<z.ZodNumber>;
87
132
  branch: z.ZodString;
88
133
  report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
134
+ metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
135
+ kind: z.ZodEnum<{
136
+ discrete: "discrete";
137
+ scalar: "scalar";
138
+ }>;
139
+ format: z.ZodOptional<z.ZodCustom<Intl.NumberFormatOptions, Intl.NumberFormatOptions>>;
140
+ alarm: z.ZodOptional<z.ZodObject<{
141
+ direction: z.ZodOptional<z.ZodEnum<{
142
+ higherIsBetter: "higherIsBetter";
143
+ lowerIsBetter: "lowerIsBetter";
144
+ }>>;
145
+ warn: z.ZodOptional<z.ZodNumber>;
146
+ error: z.ZodOptional<z.ZodNumber>;
147
+ }, z.core.$strip>>;
148
+ }, z.core.$strip>>>;
89
149
  }, z.core.$strip>>;
90
150
  }, z.core.$strip>;
91
151
  export type RenderStats = z.infer<typeof renderStatsSchema>;
92
152
  export type MetricStats = z.infer<typeof metricStatsSchema>;
153
+ export type BenchmarkMetricDefinition = z.infer<typeof metricDefinitionSchema>;
93
154
  export type BenchmarkReportEntry = z.infer<typeof benchmarkReportEntrySchema>;
94
155
  export type BenchmarkReport = z.infer<typeof benchmarkReportSchema>;
95
156
  export type BenchmarkBaseUpload = z.infer<typeof benchmarkBaseUploadSchema>;
package/ciReport.mjs CHANGED
@@ -45,7 +45,20 @@ const benchmarkReportEntrySchema = z.object({
45
45
  metrics: z.record(z.string(), metricStatsSchema)
46
46
  });
47
47
  const benchmarkReportSchema = z.record(z.string(), benchmarkReportEntrySchema);
48
- const benchmarkBaseUploadSchema = ciReportUploadSchema('benchmark', 1, benchmarkReportSchema);
48
+ const metricDefinitionSchema = z.object({
49
+ kind: z.enum(['scalar', 'discrete']),
50
+ format: z.custom().optional(),
51
+ alarm: z.object({
52
+ direction: z.enum(['lowerIsBetter', 'higherIsBetter']).optional(),
53
+ warn: z.number().min(0).optional(),
54
+ error: z.number().min(0).optional()
55
+ }).optional()
56
+ });
57
+ const benchmarkBaseUploadSchema = ciReportUploadSchema('benchmark', 1, benchmarkReportSchema).extend({
58
+ // Per-metric config for custom metrics, hoisted to the top level (keyed by metric name) so
59
+ // it is stored once rather than duplicated in every report entry. Optional and additive.
60
+ metricDefinitions: z.record(z.string(), metricDefinitionSchema).optional()
61
+ });
49
62
  export const benchmarkUploadSchema = benchmarkBaseUploadSchema.extend({
50
63
  base: benchmarkBaseUploadSchema.optional()
51
64
  });
package/index.d.mts CHANGED
@@ -1,8 +1,13 @@
1
1
  import * as React from 'react';
2
2
  import type { InteractionContext } from "./types.mjs";
3
+ import { ScalarMetric } from "./ScalarMetric.mjs";
3
4
  import "./taskMetaAugmentation.mjs";
4
- export type { RenderEvent, BenchmarkMetric, IterationData, InteractionContext } from "./types.mjs";
5
+ export type { RenderEvent, IterationData, InteractionContext } from "./types.mjs";
6
+ export type { MetricKind, MetricDirection, MetricAlarm, MetricConfig, MetricDefinition } from "./types.mjs";
5
7
  export { ElementTiming } from "./ElementTiming.mjs";
8
+ export { Metric, type MetricRecordOptions } from "./Metric.mjs";
9
+ export { ScalarMetric };
10
+ export { DiscreteMetric } from "./DiscreteMetric.mjs";
6
11
  declare global {
7
12
  interface Window {
8
13
  gc?: () => void;
@@ -12,5 +17,11 @@ interface BenchmarkOptions {
12
17
  runs?: number;
13
18
  warmupRuns?: number;
14
19
  afterEach?: () => Promise<void> | void;
20
+ /**
21
+ * Start each iteration with React render/paint recording paused. The interaction callback then
22
+ * calls `resumeReactRecording()` at the point it cares about — useful to exclude the mount and
23
+ * measure only the renders/paint of a later interaction. Defaults to `false` (mount recorded).
24
+ */
25
+ reactRecordingPaused?: boolean;
15
26
  }
16
27
  export declare function benchmark(name: string, renderFn: () => React.ReactElement, interactionOrOptions?: ((ctx: InteractionContext) => Promise<void> | void) | BenchmarkOptions, maybeOptions?: BenchmarkOptions): void;
package/index.mjs CHANGED
@@ -11,22 +11,34 @@ import { expect, it } from 'vitest';
11
11
  import * as ReactDOMClient from 'react-dom/client'; // aliased to react-dom/profiling by Vite
12
12
  import * as ReactDOM from 'react-dom';
13
13
  import { ElementTiming } from "./ElementTiming.mjs";
14
+ import { ScalarMetric } from "./ScalarMetric.mjs";
15
+ import { metricsGate } from "./metricsGate.mjs";
16
+ import { createReactRecordingControls } from "./reactRecording.mjs";
14
17
  // Import for TaskMeta augmentation side effect
15
18
  import "./taskMetaAugmentation.mjs";
16
19
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
20
  export { ElementTiming } from "./ElementTiming.mjs";
21
+ export { Metric } from "./Metric.mjs";
22
+ export { ScalarMetric };
23
+ export { DiscreteMetric } from "./DiscreteMetric.mjs";
18
24
  function BenchProfiler({
19
25
  captures,
26
+ recording,
20
27
  children
21
28
  }) {
22
29
  const onRender = React.useCallback((id, phase, actualDuration, _baseDuration, startTime) => {
23
- captures.push({
24
- id,
25
- phase,
26
- actualDuration,
27
- startTime
28
- });
29
- }, [captures]);
30
+ // Skip renders captured while React recording is paused (e.g. the mount when the benchmark
31
+ // starts paused, or a span the interaction explicitly excludes).
32
+ if (recording.active) {
33
+ captures.push({
34
+ id,
35
+ phase,
36
+ actualDuration,
37
+ startTime
38
+ });
39
+ recording.markRendered();
40
+ }
41
+ }, [captures, recording]);
30
42
  return /*#__PURE__*/_jsxs(React.Profiler, {
31
43
  id: "bench",
32
44
  onRender: onRender,
@@ -64,14 +76,40 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
64
76
  const warmupRuns = options?.warmupRuns ?? 10;
65
77
  const totalRuns = warmupRuns + runs;
66
78
  const iterations = [];
79
+
80
+ // Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
81
+ // is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
82
+ // (`bench:paint#grid-header`, …), all sharing a single definition. A default alarm keeps a
83
+ // >20% paint regression flagged, matching the previous behavior.
84
+ const paint = new ScalarMetric({
85
+ name: 'bench:paint',
86
+ format: {
87
+ style: 'unit',
88
+ unit: 'millisecond',
89
+ maximumFractionDigits: 2
90
+ },
91
+ alarm: {
92
+ error: 0.2
93
+ }
94
+ });
67
95
  const hasElementTiming = supportsElementTiming();
68
96
  if (typeof window.gc !== 'function') {
69
97
  console.warn('window.gc is not available. Run with --js-flags=--expose-gc for consistent GC between iterations.');
70
98
  }
71
99
  let renderError = null;
100
+ // Set if any iteration had a recording window that was active yet captured no renders.
101
+ let sawEmptyActiveWindow = false;
72
102
  for (let i = 0; i < totalRuns; i += 1) {
73
103
  const isWarmup = i < warmupRuns;
74
104
 
105
+ // Custom metrics recorded inside the benchmark honor warmup exclusion through the gate, the
106
+ // same way renders and `bench:paint` are excluded during warmup.
107
+ metricsGate.setRecordingEnabled(task, !isWarmup);
108
+
109
+ // Per-iteration switch for the harness's React render/paint recording. Starts paused when
110
+ // `reactRecordingPaused` is set; the interaction callback drives it from there.
111
+ const recording = createReactRecordingControls(!(options?.reactRecordingPaused ?? false));
112
+
75
113
  // Drain event loop from previous unmount, then double GC for thorough cleanup
76
114
  // eslint-disable-next-line no-await-in-loop
77
115
  await settle();
@@ -136,6 +174,7 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
136
174
  ReactDOM.flushSync(() => {
137
175
  root.render(/*#__PURE__*/_jsx(BenchProfiler, {
138
176
  captures: captures,
177
+ recording: recording,
139
178
  children: renderFn()
140
179
  }));
141
180
  });
@@ -148,24 +187,39 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
148
187
  if (interaction) {
149
188
  // eslint-disable-next-line no-await-in-loop
150
189
  await interaction({
151
- waitForElementTiming
190
+ waitForElementTiming,
191
+ pauseReactRecording: recording.pauseReactRecording,
192
+ resumeReactRecording: recording.resumeReactRecording
152
193
  });
153
194
  }
154
195
 
155
196
  // Wait for the bench sentinel paint entry (relies on test timeout)
156
197
  // eslint-disable-next-line no-await-in-loop
157
198
  await waitForElementTiming('default', 0);
199
+
200
+ // Close the final window and remember if any active window measured no renders.
201
+ recording.finalizeWindow();
202
+ if (recording.hadEmptyActiveWindow) {
203
+ sawEmptyActiveWindow = true;
204
+ }
158
205
  elementObserver?.disconnect();
159
206
  root.unmount();
160
207
  container.remove();
161
208
  if (!isWarmup) {
162
- const metrics = elementEntries.map(entry => ({
163
- name: `paint:${entry.identifier}`,
164
- value: entry.renderTime - iterationStart
165
- }));
209
+ for (const entry of elementEntries) {
210
+ // Skip paints that happened while recording was paused. Attribute by the paint's
211
+ // `renderTime`, not by when the observer callback fired (which can lag the paint).
212
+ if (!recording.activeAt(entry.renderTime)) {
213
+ continue;
214
+ }
215
+ // The default sentinel is the base series; named markers become sub-series.
216
+ const id = entry.identifier === 'default' ? undefined : entry.identifier;
217
+ paint.record(entry.renderTime - iterationStart, id !== undefined ? {
218
+ id
219
+ } : undefined);
220
+ }
166
221
  iterations.push({
167
- renders: captures,
168
- metrics
222
+ renders: captures
169
223
  });
170
224
  }
171
225
  if (options?.afterEach) {
@@ -179,8 +233,9 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
179
233
  throw renderError;
180
234
  }
181
235
 
182
- // Validate that at least one render was recorded
183
- expect(iterations[0].renders.length, 'No renders were recorded during benchmark').toBeGreaterThan(0);
236
+ // Every active recording window must capture at least one render. Windows where recording was
237
+ // never running (e.g. a fully-paused, metric-only benchmark) are not checked.
238
+ expect(sawEmptyActiveWindow, 'React recording was active but captured no renders. If you only measure imperative DOM ' + 'updates or custom metrics, keep recording paused (reactRecordingPaused) instead of resuming.').toBe(false);
184
239
 
185
240
  // Validate all iterations produced the same render events (count + order).
186
241
  // This runs after meta is set so the reporter can still display results on failure.
@@ -0,0 +1,7 @@
1
+ import type { RunnerTestCase } from 'vitest';
2
+ export declare const metricsGate: {
3
+ /** Whether custom-metric recording is currently enabled for `test`. Defaults to `true`. */
4
+ isRecordingEnabled(test: RunnerTestCase): boolean;
5
+ /** Enable or disable custom-metric recording for `test`. */
6
+ setRecordingEnabled(test: RunnerTestCase, enabled: boolean): void;
7
+ };
@@ -0,0 +1,21 @@
1
+ // Internal — not exported from the package, not user-facing. The `benchmark()` harness toggles
2
+ // recording per test (off during warmup iterations) and `Metric.record()` consults it, so custom
3
+ // metrics recorded inside a benchmark honor the same warmup exclusion as renders and `bench:paint`.
4
+ //
5
+ // Storage tracks the *disabled* tests so absence means enabled: a test with no entry — e.g. a
6
+ // standalone `it()` loop that never goes through the harness — records normally by default.
7
+ const disabled = new WeakSet();
8
+ export const metricsGate = {
9
+ /** Whether custom-metric recording is currently enabled for `test`. Defaults to `true`. */
10
+ isRecordingEnabled(test) {
11
+ return !disabled.has(test);
12
+ },
13
+ /** Enable or disable custom-metric recording for `test`. */
14
+ setRecordingEnabled(test, enabled) {
15
+ if (enabled) {
16
+ disabled.delete(test);
17
+ } else {
18
+ disabled.add(test);
19
+ }
20
+ }
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-benchmark",
3
- "version": "0.0.3-canary.5",
3
+ "version": "0.0.3-canary.7",
4
4
  "author": "MUI Team",
5
5
  "description": "Benchmark utilities for MUI projects. Internal package.",
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@babel/runtime": "^7.29.2",
13
- "@vitejs/plugin-react": "^6.0.1",
13
+ "@vitejs/plugin-react": "^6.0.2",
14
14
  "env-ci": "^11.2.0",
15
15
  "execa": "^9.6.1",
16
16
  "zod": "^4.4.3"
@@ -68,5 +68,5 @@
68
68
  }
69
69
  }
70
70
  },
71
- "gitSha": "8b0badde3f4948db33af81129bf69b51a619aa3a"
71
+ "gitSha": "0763444eee05140fa86809f0a4330c1550c9c3c6"
72
72
  }
@@ -0,0 +1,30 @@
1
+ export interface ReactRecordingControls {
2
+ /** Whether React render/paint recording is active right now — the synchronous gate for renders. */
3
+ readonly active: boolean;
4
+ /** Whether any active recording window closed without capturing a render. */
5
+ readonly hadEmptyActiveWindow: boolean;
6
+ /**
7
+ * Whether recording was active at `time` (a `performance.now()` timestamp). Paint entries are
8
+ * observed asynchronously, so they are attributed by their `renderTime` rather than by the
9
+ * recording state at the moment the observer callback happens to fire.
10
+ */
11
+ activeAt(time: number): boolean;
12
+ /** Note that a render was captured in the current window. Called by the harness from `onRender`. */
13
+ markRendered(): void;
14
+ /** Close the final window at the end of the iteration (validates it if recording is still active). */
15
+ finalizeWindow(): void;
16
+ /** Pause React render/paint recording. Throws if recording is already paused. */
17
+ pauseReactRecording(): void;
18
+ /** Resume React render/paint recording. Throws if recording is already active. */
19
+ resumeReactRecording(): void;
20
+ }
21
+ /**
22
+ * Creates the per-iteration switch that turns the harness's React render/paint recording on and
23
+ * off. The interaction callback drives it via `pauseReactRecording`/`resumeReactRecording`; the
24
+ * strict state machine (each throws when called in the wrong state) catches unbalanced pairs early.
25
+ *
26
+ * It also tracks whether each *active* window captured at least one render, so the harness can flag
27
+ * a window that was recording but measured nothing — while leaving fully-paused (metric-only)
28
+ * benchmarks alone.
29
+ */
30
+ export declare function createReactRecordingControls(initiallyActive: boolean): ReactRecordingControls;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Creates the per-iteration switch that turns the harness's React render/paint recording on and
3
+ * off. The interaction callback drives it via `pauseReactRecording`/`resumeReactRecording`; the
4
+ * strict state machine (each throws when called in the wrong state) catches unbalanced pairs early.
5
+ *
6
+ * It also tracks whether each *active* window captured at least one render, so the harness can flag
7
+ * a window that was recording but measured nothing — while leaving fully-paused (metric-only)
8
+ * benchmarks alone.
9
+ */
10
+ export function createReactRecordingControls(initiallyActive) {
11
+ let active = initiallyActive;
12
+ let currentWindowHasRender = false;
13
+ let emptyActiveWindow = false;
14
+ // Transitions in chronological order. The implicit state before the first toggle is
15
+ // `initiallyActive`; `activeAt` replays this to attribute a paint to its render time.
16
+ const transitions = [];
17
+
18
+ // Flag the window being closed if it was recording yet captured nothing.
19
+ function closeWindowIfActive() {
20
+ if (active && !currentWindowHasRender) {
21
+ emptyActiveWindow = true;
22
+ }
23
+ }
24
+ return {
25
+ get active() {
26
+ return active;
27
+ },
28
+ get hadEmptyActiveWindow() {
29
+ return emptyActiveWindow;
30
+ },
31
+ activeAt(time) {
32
+ let result = initiallyActive;
33
+ for (const transition of transitions) {
34
+ if (transition.time > time) {
35
+ break;
36
+ }
37
+ result = transition.active;
38
+ }
39
+ return result;
40
+ },
41
+ markRendered() {
42
+ currentWindowHasRender = true;
43
+ },
44
+ finalizeWindow() {
45
+ closeWindowIfActive();
46
+ },
47
+ pauseReactRecording() {
48
+ // Stamp first — before the guard and bookkeeping — so the closing window ends as early as
49
+ // possible and excludes pause's own overhead. In the throw path it is simply discarded.
50
+ const now = performance.now();
51
+ if (!active) {
52
+ throw new Error('pauseReactRecording() called but React recording is already paused.');
53
+ }
54
+ closeWindowIfActive();
55
+ active = false;
56
+ transitions.push({
57
+ time: now,
58
+ active: false
59
+ });
60
+ },
61
+ resumeReactRecording() {
62
+ if (active) {
63
+ throw new Error('resumeReactRecording() called but React recording is already active.');
64
+ }
65
+ active = true;
66
+ currentWindowHasRender = false;
67
+ // Stamp last — after the bookkeeping — so the new window starts as late as possible and
68
+ // excludes resume's own overhead.
69
+ const now = performance.now();
70
+ transitions.push({
71
+ time: now,
72
+ active: true
73
+ });
74
+ }
75
+ };
76
+ }
package/reporter.d.mts CHANGED
@@ -10,11 +10,13 @@ export interface BenchmarkReporterOptions {
10
10
  }
11
11
  declare class BenchmarkReporter implements Reporter {
12
12
  private benchmarks;
13
+ private metricDefinitions;
13
14
  private outputPath;
14
15
  private upload;
15
16
  private baselinePath;
16
17
  private hasFailures;
17
18
  constructor(options?: BenchmarkReporterOptions);
19
+ onTestRunStart(): void;
18
20
  onTestCaseResult(testCase: TestCase): void;
19
21
  onTestRunEnd(): Promise<void>;
20
22
  }
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, calculateStdDev, quantile, isOutlier } from "./stats.mjs";
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
- function aggregateMetrics(iterations) {
15
- // Collect all metric names across iterations
16
- const metricValues = new Map();
17
- for (const iteration of iterations) {
18
- for (const metric of iteration.metrics) {
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
- const result = {};
28
- for (const [name, values] of metricValues) {
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
- return result;
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 sorted = [...durations].sort(byNumeric);
73
- const q1 = quantile(sorted, 0.25);
74
- const q3 = quantile(sorted, 0.75);
75
- const filtered = durations.filter(d => !isOutlier(d, q1, q3));
76
- const used = filtered.length > 0 ? filtered : durations;
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: durations.length - used.length
73
+ outliers
89
74
  });
90
75
  }
91
76
 
@@ -119,8 +104,8 @@ function generateReportFromIterations(iterations) {
119
104
  totalDuration += iqrMean;
120
105
  }
121
106
 
122
- // Aggregate metrics
123
- const metrics = aggregateMetrics(iterations);
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
- function printMetricsTable(name, metrics, iterationCount) {
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 iqrStr = `${stats.mean.toFixed(2)}±${stats.stdDev.toFixed(2)}`;
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
- return [metricName.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))];
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±σ (ms)',
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
- if (!iterations) {
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,69 @@ 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
+ /**
38
+ * Whether a custom metric measures a continuous value or a discrete count.
39
+ * - `scalar` — continuous measurements (timings, sizes); compared with a relative noise band.
40
+ * - `discrete` — counts/events; compared as exact integers.
41
+ */
42
+ export type MetricKind = 'scalar' | 'discrete';
43
+ /** Which direction of change counts as a regression for a metric in alarm mode. */
44
+ export type MetricDirection = 'lowerIsBetter' | 'higherIsBetter';
45
+ export interface MetricAlarm {
46
+ /** Defaults to `lowerIsBetter`. */
47
+ direction?: MetricDirection;
48
+ /**
49
+ * Softer band: a regression past `warn` (but within `error`) is flagged as a warning.
50
+ * Scalar metrics: a relative fraction (`0.1` = 10%). Discrete metrics: an absolute count delta.
51
+ */
52
+ warn?: number;
53
+ /**
54
+ * Harder band: a regression past `error` is flagged as an error (the alarm). When **both**
55
+ * `warn` and `error` are omitted, `error` defaults to the dashboard's global noise band; with
56
+ * only `warn` set there is no error band (warning-only).
57
+ * Scalar metrics: a relative fraction (`0.25` = 25%). Discrete metrics: an absolute count delta.
58
+ */
59
+ error?: number;
60
+ }
61
+ export interface MetricConfig {
62
+ name: string;
63
+ /** Display formatting applied by the reporter and dashboard via `Intl.NumberFormat`. */
64
+ format?: Intl.NumberFormatOptions;
65
+ /**
66
+ * Regression judgment. Its presence opts the metric into alarming; when omitted the metric
67
+ * is informational (the diff is shown but never flagged).
68
+ */
69
+ alarm?: MetricAlarm;
70
+ }
71
+ /** Aggregated stats for a single metric sub-series, as they cross the browser→runner boundary. */
72
+ export interface MetricSampleStats {
73
+ mean: number;
74
+ stdDev: number;
75
+ outliers: number;
76
+ /** Number of recorded samples (used to derive iteration counts; stripped from the report). */
77
+ count: number;
78
+ }
79
+ /** A custom metric's aggregated data attached to `task.meta`, keyed by metric name. */
80
+ export interface MetricReport {
81
+ kind: MetricKind;
82
+ config: MetricConfig;
83
+ /** Aggregated stats keyed by sub-series id (`''` is the base series). */
84
+ series: Record<string, MetricSampleStats>;
85
+ }
86
+ /** Per-metric configuration hoisted to the top level of the report (keyed by metric name). */
87
+ export interface MetricDefinition {
88
+ kind: MetricKind;
89
+ format?: Intl.NumberFormatOptions;
90
+ alarm?: MetricAlarm;
33
91
  }