@mui/internal-benchmark 0.0.3-canary.6 → 0.0.3-canary.8
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/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 +223 -90
- package/metricsGate.d.mts +7 -0
- package/metricsGate.mjs +21 -0
- package/package.json +2 -2
- 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 +17 -0
- package/vitest.mjs +54 -14
|
@@ -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
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
|
|
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
|
|
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,6 +208,38 @@ 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:
|
|
@@ -117,6 +247,8 @@ vitest run
|
|
|
117
247
|
- `outputPath` — path for JSON results (default: `benchmarks/results.json`). Also settable via `BENCHMARK_OUTPUT_PATH`.
|
|
118
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`.
|
|
119
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`).
|
|
120
252
|
|
|
121
253
|
To override standard Vitest options (e.g. `include`, `testTimeout`, `headless`), use `mergeConfig`:
|
|
122
254
|
|
|
@@ -148,5 +280,7 @@ The feature is opt-in — without `BENCHMARK_BASELINE_PATH` (or the `baselinePat
|
|
|
148
280
|
|
|
149
281
|
- `benchmark` — define a benchmark test case
|
|
150
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
|
|
151
285
|
- `createBenchmarkVitestConfig` — create a Vitest config with browser benchmarking defaults
|
|
152
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
|
+
}
|
package/ScalarMetric.mjs
ADDED
|
@@ -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
|
|
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,
|
|
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;
|