@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 +1 -0
- 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 +102 -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 +71 -16
- package/metricsGate.d.mts +7 -0
- package/metricsGate.mjs +21 -0
- package/package.json +3 -3
- 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 +65 -7
package/CHANGELOG.md
CHANGED
|
@@ -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
|
},
|
|
@@ -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
|
+
}
|
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;
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
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
|
+
};
|
package/metricsGate.mjs
ADDED
|
@@ -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.
|
|
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.
|
|
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": "
|
|
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,
|
|
4
|
+
import { calculateMean, aggregateSamples } from "./stats.mjs";
|
|
5
5
|
import { dim, red, green, yellow, cyan, printTable, fileUrl } from "./format.mjs";
|
|
6
6
|
import { uploadCiReport } from "./upload.mjs";
|
|
7
7
|
import { syncPrComment } from "./syncPrComment.mjs";
|
|
8
8
|
// Import for TaskMeta augmentation side effect
|
|
9
9
|
import "./taskMetaAugmentation.mjs";
|
|
10
|
-
const byNumeric = (a, b) => a - b;
|
|
11
10
|
function getEventKey(event) {
|
|
12
11
|
return `${event.id}:${event.phase}`;
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let values = metricValues.get(metric.name);
|
|
20
|
-
if (!values) {
|
|
21
|
-
values = [];
|
|
22
|
-
metricValues.set(metric.name, values);
|
|
23
|
-
}
|
|
24
|
-
values.push(metric.value);
|
|
25
|
-
}
|
|
13
|
+
|
|
14
|
+
/** Order-insensitive deep equality, treating a missing key and an `undefined` value as equal. */
|
|
15
|
+
function deepEqual(first, second) {
|
|
16
|
+
if (first === second) {
|
|
17
|
+
return true;
|
|
26
18
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// Apply IQR filtering
|
|
30
|
-
const sorted = [...values].sort(byNumeric);
|
|
31
|
-
const q1 = quantile(sorted, 0.25);
|
|
32
|
-
const q3 = quantile(sorted, 0.75);
|
|
33
|
-
const filtered = values.filter(d => !isOutlier(d, q1, q3));
|
|
34
|
-
const used = filtered.length > 0 ? filtered : values;
|
|
35
|
-
const mean = calculateMean(used);
|
|
36
|
-
const stdDev = calculateStdDev(used, mean);
|
|
37
|
-
result[name] = {
|
|
38
|
-
mean,
|
|
39
|
-
stdDev,
|
|
40
|
-
outliers: values.length - used.length
|
|
41
|
-
};
|
|
19
|
+
if (typeof first !== 'object' || first === null || typeof second !== 'object' || second === null) {
|
|
20
|
+
return false;
|
|
42
21
|
}
|
|
43
|
-
|
|
22
|
+
const firstRecord = first;
|
|
23
|
+
const secondRecord = second;
|
|
24
|
+
const keys = new Set([...Object.keys(firstRecord), ...Object.keys(secondRecord)]);
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
if (!deepEqual(firstRecord[key], secondRecord[key])) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
44
31
|
}
|
|
45
32
|
function generateReportFromIterations(iterations) {
|
|
46
33
|
if (iterations.length === 0) {
|
|
@@ -69,13 +56,11 @@ function generateReportFromIterations(iterations) {
|
|
|
69
56
|
const renderStats = [];
|
|
70
57
|
for (let index = 0; index < expectedLength; index += 1) {
|
|
71
58
|
const durations = iterations.map(iteration => iteration.renders[index].actualDuration);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const iqrMean = calculateMean(used);
|
|
78
|
-
const iqrStdDev = calculateStdDev(used, iqrMean);
|
|
59
|
+
const {
|
|
60
|
+
mean: iqrMean,
|
|
61
|
+
stdDev: iqrStdDev,
|
|
62
|
+
outliers
|
|
63
|
+
} = aggregateSamples(durations);
|
|
79
64
|
const coefficientOfVariation = iqrMean > 0 ? iqrStdDev / iqrMean : 0;
|
|
80
65
|
if (iqrMean > 1 && coefficientOfVariation > 0.1) {
|
|
81
66
|
const event = firstIteration.renders[index];
|
|
@@ -85,7 +70,7 @@ function generateReportFromIterations(iterations) {
|
|
|
85
70
|
event: firstIteration.renders[index],
|
|
86
71
|
iqrMean,
|
|
87
72
|
iqrStdDev,
|
|
88
|
-
outliers
|
|
73
|
+
outliers
|
|
89
74
|
});
|
|
90
75
|
}
|
|
91
76
|
|
|
@@ -119,8 +104,8 @@ function generateReportFromIterations(iterations) {
|
|
|
119
104
|
totalDuration += iqrMean;
|
|
120
105
|
}
|
|
121
106
|
|
|
122
|
-
//
|
|
123
|
-
const metrics =
|
|
107
|
+
// Custom + paint metrics are merged separately from `task.meta.benchmarkMetrics`.
|
|
108
|
+
const metrics = {};
|
|
124
109
|
return {
|
|
125
110
|
iterations: iterationCount,
|
|
126
111
|
totalDuration,
|
|
@@ -167,21 +152,71 @@ function printDurationMatrix(name, report, footer) {
|
|
|
167
152
|
width: 4
|
|
168
153
|
}], rows, footer, name);
|
|
169
154
|
}
|
|
170
|
-
|
|
155
|
+
|
|
156
|
+
/** Strips a `#sub-series` suffix to recover the metric name used to look up its definition. */
|
|
157
|
+
function baseMetricName(key) {
|
|
158
|
+
const hashIndex = key.indexOf('#');
|
|
159
|
+
return hashIndex === -1 ? key : key.slice(0, hashIndex);
|
|
160
|
+
}
|
|
161
|
+
function formatMetricValue(value, definition) {
|
|
162
|
+
if (definition?.format) {
|
|
163
|
+
return new Intl.NumberFormat(undefined, definition.format).format(value);
|
|
164
|
+
}
|
|
165
|
+
return value.toFixed(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merges aggregated custom metrics (already stats, not raw samples) into a report entry, keyed
|
|
170
|
+
* `name` or `name#id` for sub-series, and collects each metric's config into the shared
|
|
171
|
+
* top-level definitions. For a metric-only test, derives the iteration count from the samples.
|
|
172
|
+
*/
|
|
173
|
+
function mergeCustomMetrics(report, customMetrics, definitions) {
|
|
174
|
+
let maxCount = 0;
|
|
175
|
+
for (const [metricName, metric] of Object.entries(customMetrics)) {
|
|
176
|
+
for (const [seriesId, stats] of Object.entries(metric.series)) {
|
|
177
|
+
const key = seriesId === '' ? metricName : `${metricName}#${seriesId}`;
|
|
178
|
+
report.metrics[key] = {
|
|
179
|
+
mean: stats.mean,
|
|
180
|
+
stdDev: stats.stdDev,
|
|
181
|
+
outliers: stats.outliers
|
|
182
|
+
};
|
|
183
|
+
maxCount = Math.max(maxCount, stats.count);
|
|
184
|
+
}
|
|
185
|
+
const definition = {
|
|
186
|
+
kind: metric.kind,
|
|
187
|
+
format: metric.config.format,
|
|
188
|
+
alarm: metric.config.alarm
|
|
189
|
+
};
|
|
190
|
+
// A metric name maps to one definition. Reusing it across benchmarks is fine when the config
|
|
191
|
+
// matches (e.g. the harness `bench:paint`), but conflicting config would silently apply
|
|
192
|
+
// last-write-wins to every entry — reject it instead.
|
|
193
|
+
const existing = definitions[metricName];
|
|
194
|
+
if (existing && !deepEqual(existing, definition)) {
|
|
195
|
+
throw new Error(`Benchmark metric "${metricName}" is defined with conflicting configuration across ` + `benchmarks. A metric name must map to a single kind, format, and alarm.`);
|
|
196
|
+
}
|
|
197
|
+
definitions[metricName] = definition;
|
|
198
|
+
}
|
|
199
|
+
if (report.iterations === 0) {
|
|
200
|
+
report.iterations = maxCount;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function printMetricsTable(name, metrics, iterationCount, definitions) {
|
|
171
204
|
const entries = Object.entries(metrics);
|
|
172
205
|
if (entries.length === 0) {
|
|
173
206
|
return;
|
|
174
207
|
}
|
|
175
208
|
const rows = entries.map(([metricName, stats]) => {
|
|
176
|
-
const
|
|
209
|
+
const definition = definitions[baseMetricName(metricName)];
|
|
210
|
+
const iqrStr = `${formatMetricValue(stats.mean, definition)}±${formatMetricValue(stats.stdDev, definition)}`;
|
|
177
211
|
const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
|
|
178
|
-
|
|
212
|
+
const label = definition?.alarm ? `${metricName} ⚠` : metricName;
|
|
213
|
+
return [label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), stats.outliers > 0 ? yellow(String(stats.outliers).padStart(4)) : dim('0'.padStart(4))];
|
|
179
214
|
});
|
|
180
215
|
printTable([{
|
|
181
216
|
header: 'Metric',
|
|
182
217
|
width: LABEL_WIDTH
|
|
183
218
|
}, {
|
|
184
|
-
header: 'Mean±σ
|
|
219
|
+
header: 'Mean±σ',
|
|
185
220
|
width: STAT_WIDTH
|
|
186
221
|
}, {
|
|
187
222
|
header: 'Var%',
|
|
@@ -204,28 +239,47 @@ async function loadBaselineReport(baselinePath) {
|
|
|
204
239
|
}
|
|
205
240
|
class BenchmarkReporter {
|
|
206
241
|
benchmarks = {};
|
|
242
|
+
metricDefinitions = {};
|
|
207
243
|
hasFailures = false;
|
|
208
244
|
constructor(options) {
|
|
209
245
|
this.outputPath = options?.outputPath ?? process.env.BENCHMARK_OUTPUT_PATH ?? path.resolve(process.cwd(), 'benchmarks', 'results.json');
|
|
210
246
|
this.upload = options?.upload ?? process.env.BENCHMARK_UPLOAD === 'true';
|
|
211
247
|
this.baselinePath = options?.baselinePath ?? process.env.BENCHMARK_BASELINE_PATH;
|
|
212
248
|
}
|
|
249
|
+
|
|
250
|
+
// Reset accumulated state at the start of every run so watch-mode re-runs start clean (the
|
|
251
|
+
// reporter instance is reused across runs). Otherwise stale benchmarks/definitions linger — and
|
|
252
|
+
// an edited metric config would conflict with its own previous-run definition.
|
|
253
|
+
onTestRunStart() {
|
|
254
|
+
this.benchmarks = {};
|
|
255
|
+
this.metricDefinitions = {};
|
|
256
|
+
this.hasFailures = false;
|
|
257
|
+
}
|
|
213
258
|
onTestCaseResult(testCase) {
|
|
214
259
|
if (testCase.result().state === 'failed') {
|
|
215
260
|
this.hasFailures = true;
|
|
216
261
|
}
|
|
217
262
|
const meta = testCase.meta();
|
|
218
263
|
const iterations = meta.benchmarkIterations;
|
|
219
|
-
|
|
264
|
+
const customMetrics = meta.benchmarkMetrics;
|
|
265
|
+
if (!iterations && !customMetrics) {
|
|
220
266
|
console.warn(yellow(` No iterations recorded for: ${testCase.fullName}`));
|
|
221
267
|
return;
|
|
222
268
|
}
|
|
223
269
|
const name = meta.benchmarkName ?? testCase.fullName;
|
|
224
|
-
const report = generateReportFromIterations(iterations)
|
|
270
|
+
const report = iterations ? generateReportFromIterations(iterations) : {
|
|
271
|
+
iterations: 0,
|
|
272
|
+
totalDuration: 0,
|
|
273
|
+
renders: [],
|
|
274
|
+
metrics: {}
|
|
275
|
+
};
|
|
276
|
+
if (customMetrics) {
|
|
277
|
+
mergeCustomMetrics(report, customMetrics, this.metricDefinitions);
|
|
278
|
+
}
|
|
225
279
|
this.benchmarks[name] = report;
|
|
226
280
|
const summary = dim('Total: ') + green(`${report.totalDuration.toFixed(2)}ms`) + dim(` (${report.renders.length} renders, ${report.iterations} iterations)`);
|
|
227
281
|
printDurationMatrix(`${name} — React`, report, summary);
|
|
228
|
-
printMetricsTable(name, report.metrics, report.iterations);
|
|
282
|
+
printMetricsTable(name, report.metrics, report.iterations, this.metricDefinitions);
|
|
229
283
|
}
|
|
230
284
|
async onTestRunEnd() {
|
|
231
285
|
const count = Object.keys(this.benchmarks).length;
|
|
@@ -237,11 +291,15 @@ class BenchmarkReporter {
|
|
|
237
291
|
console.log(` ${name}: ${result.totalDuration.toFixed(2)}ms ${dim(`(${result.renders.length} renders, ${result.iterations} iterations)`)}`);
|
|
238
292
|
}
|
|
239
293
|
const baseline = this.baselinePath ? await loadBaselineReport(this.baselinePath) : undefined;
|
|
294
|
+
const hasMetricDefinitions = Object.keys(this.metricDefinitions).length > 0;
|
|
240
295
|
const results = {
|
|
241
296
|
version: 1,
|
|
242
297
|
reportType: 'benchmark',
|
|
243
298
|
...(await getCiMetadata()),
|
|
244
299
|
report: this.benchmarks,
|
|
300
|
+
...(hasMetricDefinitions ? {
|
|
301
|
+
metricDefinitions: this.metricDefinitions
|
|
302
|
+
} : {}),
|
|
245
303
|
...(baseline ? {
|
|
246
304
|
base: baseline
|
|
247
305
|
} : {})
|
package/stats.d.mts
CHANGED
|
@@ -7,4 +7,14 @@ export declare function quantile(sorted: number[], q: number): number;
|
|
|
7
7
|
*
|
|
8
8
|
* See https://en.wikipedia.org/wiki/Interquartile_range#Outliers
|
|
9
9
|
*/
|
|
10
|
-
export declare function isOutlier(value: number, q1: number, q3: number): boolean;
|
|
10
|
+
export declare function isOutlier(value: number, q1: number, q3: number): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
13
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
14
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
15
|
+
*/
|
|
16
|
+
export declare function aggregateSamples(values: number[]): {
|
|
17
|
+
mean: number;
|
|
18
|
+
stdDev: number;
|
|
19
|
+
outliers: number;
|
|
20
|
+
};
|
package/stats.mjs
CHANGED
|
@@ -24,4 +24,24 @@ export function quantile(sorted, q) {
|
|
|
24
24
|
export function isOutlier(value, q1, q3) {
|
|
25
25
|
const iqr = q3 - q1;
|
|
26
26
|
return value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Aggregates a series of samples into a mean, standard deviation, and outlier count using
|
|
31
|
+
* IQR-based outlier removal. Falls back to the raw values when filtering would remove
|
|
32
|
+
* everything. This is the shared aggregation core for custom metrics.
|
|
33
|
+
*/
|
|
34
|
+
export function aggregateSamples(values) {
|
|
35
|
+
const sorted = values.toSorted((first, second) => first - second);
|
|
36
|
+
const q1 = quantile(sorted, 0.25);
|
|
37
|
+
const q3 = quantile(sorted, 0.75);
|
|
38
|
+
const filtered = values.filter(value => !isOutlier(value, q1, q3));
|
|
39
|
+
const used = filtered.length > 0 ? filtered : values;
|
|
40
|
+
const mean = calculateMean(used);
|
|
41
|
+
const stdDev = calculateStdDev(used, mean);
|
|
42
|
+
return {
|
|
43
|
+
mean,
|
|
44
|
+
stdDev,
|
|
45
|
+
outliers: values.length - used.length
|
|
46
|
+
};
|
|
27
47
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { IterationData } from "./types.mjs";
|
|
1
|
+
import type { IterationData, MetricReport } from "./types.mjs";
|
|
2
2
|
declare module 'vitest' {
|
|
3
3
|
interface TaskMeta {
|
|
4
4
|
benchmarkName?: string;
|
|
5
5
|
benchmarkIterations?: IterationData[];
|
|
6
|
+
/** Custom metrics recorded via `ScalarMetric`/`DiscreteMetric`, keyed by metric name. */
|
|
7
|
+
benchmarkMetrics?: Record<string, MetricReport>;
|
|
6
8
|
}
|
|
7
9
|
}
|
package/types.d.mts
CHANGED
|
@@ -13,15 +13,8 @@ export interface RenderEvent {
|
|
|
13
13
|
/** Start time in milliseconds (from performance.now()) */
|
|
14
14
|
startTime: number;
|
|
15
15
|
}
|
|
16
|
-
export interface BenchmarkMetric {
|
|
17
|
-
/** Metric name, e.g. "paint:bench", "paint:grid-header" */
|
|
18
|
-
name: string;
|
|
19
|
-
/** Measured value in ms */
|
|
20
|
-
value: number;
|
|
21
|
-
}
|
|
22
16
|
export interface IterationData {
|
|
23
17
|
renders: RenderEvent[];
|
|
24
|
-
metrics: BenchmarkMetric[];
|
|
25
18
|
}
|
|
26
19
|
export interface InteractionContext {
|
|
27
20
|
/**
|
|
@@ -30,4 +23,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
|
}
|