@mui/internal-benchmark 0.0.3-canary.0 → 0.0.3-canary.10

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.
@@ -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
  },
@@ -110,12 +208,47 @@ benchmark('name', renderFn, interaction, {
110
208
  vitest run
111
209
  ```
112
210
 
211
+ ### Profiling in DevTools
212
+
213
+ To profile a benchmark case by hand with the browser DevTools instead of running the automated measurement loop, enable profile mode. It opens a **headed** Chromium window with DevTools already open, and replaces the measurement loop with an interactive control panel:
214
+
215
+ ```bash
216
+ BENCHMARK_PROFILE=true vitest run -t "MyComponent mount"
217
+ ```
218
+
219
+ Each `benchmark()` case renders a toolbar pinned to the top of the page with **Render**, **Finish**, and (when the case has an interaction) **Run interaction** buttons. The **Render** button toggles between mounting and unmounting (it reads **Unmount** while the component is mounted). The component under test stays unmounted until you click **Render**, so the flow is:
220
+
221
+ 1. Switch to the DevTools **Performance** tab and start recording.
222
+ 2. Click **Render** — this mounts the component (the thing you're profiling).
223
+ 3. Stop the recording and inspect. Toggle **Unmount** / **Render** to capture more frames, or **Run interaction** to profile a re-render.
224
+ 4. Click **Finish** to end the case and move to the next one.
225
+
226
+ Filter to a single case with Vitest's `-t "<name>"` (or by file) so the window isn't shared across many cases. Profiling shares the same minimal launch args as measurement (V8 optimization and the GPU stay on for both), so the profiler reflects realistic performance; it differs only by running headed — DevTools open, in a full desktop viewport (below) — so its absolute numbers aren't directly comparable to a measurement run.
227
+
228
+ Both modes render at a 1920x1080 viewport by default (instead of Vitest's phone-sized 414x896). Set `viewport` (or the `BENCHMARK_VIEWPORT` env var) to change it; in profile mode the headed browser window is also sized to match so the full render is visible:
229
+
230
+ ```bash
231
+ BENCHMARK_PROFILE=true BENCHMARK_VIEWPORT=2560x1440 vitest run -t "MyComponent mount"
232
+ ```
233
+
234
+ Profile mode is also settable via the `profile` config option:
235
+
236
+ ```ts
237
+ export default createBenchmarkVitestConfig({
238
+ profile: true,
239
+ viewport: { width: 2560, height: 1440 },
240
+ });
241
+ ```
242
+
113
243
  ### Configuration
114
244
 
115
245
  `createBenchmarkVitestConfig` accepts:
116
246
 
117
- - `outputPath` — path for JSON results (default: `benchmarks/results.json`)
247
+ - `outputPath` — path for JSON results (default: `benchmarks/results.json`). Also settable via `BENCHMARK_OUTPUT_PATH`.
248
+ - `baselinePath` — path to a prior results JSON file to inline as the comparison base (see [Baseline comparisons](#baseline-comparisons)). Also settable via `BENCHMARK_BASELINE_PATH`.
118
249
  - `launchArgs` — additional browser launch arguments
250
+ - `profile` — run an interactive profiling session in a headed browser with DevTools instead of measuring (see [Profiling in DevTools](#profiling-in-devtools)). Also settable via `BENCHMARK_PROFILE=true`.
251
+ - `viewport` — `{ width, height }` browser viewport (and window size in profile mode), applied to both modes. Defaults to `1920x1080`. Also settable via `BENCHMARK_VIEWPORT` (e.g. `2560x1440`).
119
252
 
120
253
  To override standard Vitest options (e.g. `include`, `testTimeout`, `headless`), use `mergeConfig`:
121
254
 
@@ -130,9 +263,24 @@ export default mergeConfig(createBenchmarkVitestConfig(), {
130
263
  });
131
264
  ```
132
265
 
266
+ ### Baseline comparisons
267
+
268
+ Benchmark runs are noisy across machines. To get a clean comparison in a PR, run the baseline benchmark in the _same_ CI job as the head: the results are inlined into the head upload as a `base` field, and the dashboard / PR comment render the comparison without fetching a separate base artifact from S3.
269
+
270
+ ```bash
271
+ # in a PR CI job
272
+ git worktree add /tmp/base $BASE_SHA
273
+ (cd /tmp/base && pnpm install && BENCHMARK_OUTPUT_PATH=/tmp/base-bench.json pnpm test:bench)
274
+ BENCHMARK_BASELINE_PATH=/tmp/base-bench.json pnpm test:bench # head run, inlines base
275
+ ```
276
+
277
+ The feature is opt-in — without `BENCHMARK_BASELINE_PATH` (or the `baselinePath` config option), the dashboard falls back to fetching the base from S3 by merge-base SHA as before.
278
+
133
279
  ## API
134
280
 
135
281
  - `benchmark` — define a benchmark test case
136
282
  - `ElementTiming` — invisible marker component for paint timing (renders a `<span>` tracked by the Element Timing API)
283
+ - `ScalarMetric` — record a continuous custom measurement (with a `console.time`-style timing helper)
284
+ - `DiscreteMetric` — record a discrete custom count
137
285
  - `createBenchmarkVitestConfig` — create a Vitest config with browser benchmarking defaults
138
286
  - `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,46 @@ 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>;
76
+ declare const benchmarkBaseUploadSchema: z.ZodObject<{
77
+ version: z.ZodLiteral<number>;
78
+ timestamp: z.ZodNumber;
79
+ commitSha: z.ZodString;
80
+ repo: z.ZodString;
81
+ reportType: z.ZodLiteral<string>;
82
+ prNumber: z.ZodOptional<z.ZodNumber>;
83
+ branch: z.ZodString;
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>>>;
100
+ }, z.core.$strip>;
61
101
  export declare const benchmarkUploadSchema: z.ZodObject<{
62
102
  version: z.ZodLiteral<number>;
63
103
  timestamp: z.ZodNumber;
@@ -67,17 +107,59 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
67
107
  prNumber: z.ZodOptional<z.ZodNumber>;
68
108
  branch: z.ZodString;
69
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>>>;
125
+ base: z.ZodOptional<z.ZodObject<{
126
+ version: z.ZodLiteral<number>;
127
+ timestamp: z.ZodNumber;
128
+ commitSha: z.ZodString;
129
+ repo: z.ZodString;
130
+ reportType: z.ZodLiteral<string>;
131
+ prNumber: z.ZodOptional<z.ZodNumber>;
132
+ branch: z.ZodString;
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>>>;
149
+ }, z.core.$strip>>;
70
150
  }, z.core.$strip>;
71
151
  export type RenderStats = z.infer<typeof renderStatsSchema>;
72
152
  export type MetricStats = z.infer<typeof metricStatsSchema>;
153
+ export type BenchmarkMetricDefinition = z.infer<typeof metricDefinitionSchema>;
73
154
  export type BenchmarkReportEntry = z.infer<typeof benchmarkReportEntrySchema>;
74
155
  export type BenchmarkReport = z.infer<typeof benchmarkReportSchema>;
156
+ export type BenchmarkBaseUpload = z.infer<typeof benchmarkBaseUploadSchema>;
75
157
  export type BenchmarkUpload = z.infer<typeof benchmarkUploadSchema>;
76
158
  export declare function getCiMetadata(): Promise<{
77
159
  timestamp: number;
78
160
  repo: string;
79
- branch: string;
80
161
  prNumber: number | undefined;
162
+ branch: string;
81
163
  commitSha: string;
82
164
  }>;
83
165
  export {};
package/ciReport.mjs CHANGED
@@ -1,15 +1,12 @@
1
1
  import { execa } from 'execa';
2
2
  import { z } from 'zod/v4';
3
3
  import envCi from 'env-ci';
4
- async function getCommitSha() {
5
- try {
6
- const {
7
- stdout
8
- } = await execa('git', ['rev-parse', 'HEAD']);
9
- return stdout.trim();
10
- } catch {
11
- return null;
12
- }
4
+ async function getGitInfo() {
5
+ const [commit, branch] = await Promise.all([execa('git', ['rev-parse', 'HEAD']), execa('git', ['branch', '--show-current'])]);
6
+ return {
7
+ commitSha: commit.stdout,
8
+ branch: branch.stdout
9
+ };
13
10
  }
14
11
 
15
12
  /**
@@ -48,14 +45,31 @@ const benchmarkReportEntrySchema = z.object({
48
45
  metrics: z.record(z.string(), metricStatsSchema)
49
46
  });
50
47
  const benchmarkReportSchema = z.record(z.string(), benchmarkReportEntrySchema);
51
- export const benchmarkUploadSchema = 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
+ });
62
+ export const benchmarkUploadSchema = benchmarkBaseUploadSchema.extend({
63
+ base: benchmarkBaseUploadSchema.optional()
64
+ });
52
65
  export async function getCiMetadata() {
53
66
  const ciInfo = envCi();
67
+ const gitInfo = await getGitInfo();
54
68
  return {
55
69
  timestamp: Date.now(),
56
70
  repo: ciInfo.slug ?? '',
57
- branch: ciInfo.isPr ? ciInfo.prBranch ?? '' : ciInfo.branch ?? '',
58
71
  prNumber: ciInfo.pr ? Number(ciInfo.pr) : undefined,
59
- commitSha: ciInfo.commit ?? (await getCommitSha()) ?? ''
72
+ branch: gitInfo.branch || process.env.BENCHMARK_BRANCH || (ciInfo.isPr ? ciInfo.prBranch : ciInfo.branch) || '',
73
+ commitSha: gitInfo.commitSha
60
74
  };
61
75
  }
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;