@mui/internal-benchmark 0.0.3-canary.2 → 0.0.3-canary.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DiscreteMetric.d.mts +15 -0
- package/DiscreteMetric.mjs +24 -0
- package/ElementTiming.d.mts +2 -1
- package/LICENSE +1 -1
- package/Metric.d.mts +24 -0
- package/Metric.mjs +89 -0
- package/README.md +136 -2
- package/ScalarMetric.d.mts +23 -0
- package/ScalarMetric.mjs +42 -0
- package/ciReport.d.mts +61 -0
- package/ciReport.mjs +14 -1
- package/index.d.mts +12 -1
- package/index.mjs +221 -90
- package/metricsGate.d.mts +7 -0
- package/metricsGate.mjs +21 -0
- package/package.json +6 -6
- package/profileSession.d.mts +2 -0
- package/profileSession.mjs +95 -0
- package/reactRecording.d.mts +30 -0
- package/reactRecording.mjs +76 -0
- package/reporter.d.mts +2 -0
- package/reporter.mjs +105 -47
- package/stats.d.mts +11 -1
- package/stats.mjs +20 -0
- package/taskMetaAugmentation.d.mts +3 -1
- package/types.d.mts +75 -7
- package/vitest.d.mts +18 -1
- package/vitest.mjs +54 -14
package/index.mjs
CHANGED
|
@@ -11,28 +11,39 @@ 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";
|
|
17
|
+
import { runProfileSession } from "./profileSession.mjs";
|
|
14
18
|
// Import for TaskMeta augmentation side effect
|
|
15
19
|
import "./taskMetaAugmentation.mjs";
|
|
16
20
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
17
21
|
export { ElementTiming } from "./ElementTiming.mjs";
|
|
22
|
+
export { Metric } from "./Metric.mjs";
|
|
23
|
+
export { ScalarMetric };
|
|
24
|
+
export { DiscreteMetric } from "./DiscreteMetric.mjs";
|
|
18
25
|
function BenchProfiler({
|
|
19
26
|
captures,
|
|
27
|
+
recording,
|
|
20
28
|
children
|
|
21
29
|
}) {
|
|
22
30
|
const onRender = React.useCallback((id, phase, actualDuration, _baseDuration, startTime) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
// Skip renders captured while React recording is paused (e.g. the mount when the benchmark
|
|
32
|
+
// starts paused, or a span the interaction explicitly excludes).
|
|
33
|
+
if (recording.active) {
|
|
34
|
+
captures.push({
|
|
35
|
+
id,
|
|
36
|
+
phase,
|
|
37
|
+
actualDuration,
|
|
38
|
+
startTime
|
|
39
|
+
});
|
|
40
|
+
recording.markRendered();
|
|
41
|
+
}
|
|
42
|
+
}, [captures, recording]);
|
|
43
|
+
return /*#__PURE__*/_jsx(React.Profiler, {
|
|
31
44
|
id: "bench",
|
|
32
45
|
onRender: onRender,
|
|
33
|
-
children:
|
|
34
|
-
name: "default"
|
|
35
|
-
}))]
|
|
46
|
+
children: children
|
|
36
47
|
});
|
|
37
48
|
}
|
|
38
49
|
|
|
@@ -54,9 +65,137 @@ function settle() {
|
|
|
54
65
|
function supportsElementTiming() {
|
|
55
66
|
return PerformanceObserver.supportedEntryTypes.includes('element');
|
|
56
67
|
}
|
|
68
|
+
// Sets up a PerformanceObserver for the Element Timing API and exposes a promise-based
|
|
69
|
+
// `waitForElementTiming` helper. Used by the measurement loop (which also reads `elementEntries`
|
|
70
|
+
// to record paint metrics) and the interactive profiling session.
|
|
71
|
+
function createElementTimingWaiter() {
|
|
72
|
+
const hasElementTiming = supportsElementTiming();
|
|
73
|
+
const elementEntries = [];
|
|
74
|
+
const elementResolvers = new Map();
|
|
75
|
+
let observer = null;
|
|
76
|
+
if (hasElementTiming) {
|
|
77
|
+
observer = new PerformanceObserver(list => {
|
|
78
|
+
for (const entry of list.getEntries()) {
|
|
79
|
+
elementEntries.push(entry);
|
|
80
|
+
const resolver = elementResolvers.get(entry.identifier);
|
|
81
|
+
if (resolver) {
|
|
82
|
+
elementResolvers.delete(entry.identifier);
|
|
83
|
+
resolver();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
observer.observe({
|
|
88
|
+
type: 'element',
|
|
89
|
+
buffered: false
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const waitForElementTiming = (identifier, timeout) => {
|
|
93
|
+
if (!hasElementTiming) {
|
|
94
|
+
console.warn(`waitForElementTiming("${identifier}"): Element Timing API is not supported. ` + 'Paint metrics will not be collected.');
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
if (elementEntries.some(entry => entry.identifier === identifier)) {
|
|
98
|
+
return Promise.resolve();
|
|
99
|
+
}
|
|
100
|
+
const {
|
|
101
|
+
promise,
|
|
102
|
+
resolve,
|
|
103
|
+
reject
|
|
104
|
+
} = Promise.withResolvers();
|
|
105
|
+
const timeoutMs = timeout ?? 5000;
|
|
106
|
+
const timer = timeoutMs > 0 && timeoutMs < Infinity ? setTimeout(() => {
|
|
107
|
+
elementResolvers.delete(identifier);
|
|
108
|
+
reject(new Error(`waitForElementTiming("${identifier}"): timed out after ${timeoutMs}ms. ` + 'Ensure the element has an `elementtiming` attribute and is visible in the viewport.'));
|
|
109
|
+
}, timeoutMs) : undefined;
|
|
110
|
+
elementResolvers.set(identifier, () => {
|
|
111
|
+
if (timer) {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
return promise;
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
elementEntries,
|
|
120
|
+
waitForElementTiming,
|
|
121
|
+
disconnect: () => observer?.disconnect()
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// When true, `benchmark()` opens an interactive profiling session in a headed
|
|
126
|
+
// browser instead of running the automated measurement loop. Enabled by
|
|
127
|
+
// `createBenchmarkVitestConfig({ profile: true })` or `BENCHMARK_PROFILE=true`,
|
|
128
|
+
// both of which replace this expression at build time via Vite `define`.
|
|
129
|
+
const PROFILE_MODE = process.env.BENCHMARK_PROFILE === 'true';
|
|
130
|
+
function createCaseRuntime({
|
|
131
|
+
renderFn,
|
|
132
|
+
interaction,
|
|
133
|
+
context,
|
|
134
|
+
onUncaughtError
|
|
135
|
+
}) {
|
|
136
|
+
let root = null;
|
|
137
|
+
let container = null;
|
|
138
|
+
return {
|
|
139
|
+
mount() {
|
|
140
|
+
if (root) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
container = document.createElement('div');
|
|
144
|
+
document.body.appendChild(container);
|
|
145
|
+
const newRoot = ReactDOMClient.createRoot(container, {
|
|
146
|
+
onUncaughtError
|
|
147
|
+
});
|
|
148
|
+
root = newRoot;
|
|
149
|
+
ReactDOM.flushSync(() => {
|
|
150
|
+
newRoot.render(/*#__PURE__*/_jsxs(React.Fragment, {
|
|
151
|
+
children: [renderFn(), _ElementTiming || (_ElementTiming = /*#__PURE__*/_jsx(ElementTiming, {
|
|
152
|
+
name: "default"
|
|
153
|
+
}))]
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
interact: interaction ? async () => {
|
|
158
|
+
await interaction(context);
|
|
159
|
+
} : undefined,
|
|
160
|
+
unmount() {
|
|
161
|
+
if (!root) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
root.unmount();
|
|
165
|
+
container?.remove();
|
|
166
|
+
root = null;
|
|
167
|
+
container = null;
|
|
168
|
+
},
|
|
169
|
+
isMounted: () => root !== null
|
|
170
|
+
};
|
|
171
|
+
}
|
|
57
172
|
export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
58
173
|
const interaction = typeof interactionOrOptions === 'function' ? interactionOrOptions : undefined;
|
|
59
174
|
const options = typeof interactionOrOptions === 'object' ? interactionOrOptions : maybeOptions;
|
|
175
|
+
|
|
176
|
+
// In profile mode, skip the automated measurement loop entirely: build a bare case runtime (no
|
|
177
|
+
// BenchProfiler wrapper, no-op recording since the user drives DevTools by hand) and hand it to
|
|
178
|
+
// the interactive panel.
|
|
179
|
+
if (PROFILE_MODE) {
|
|
180
|
+
it(name, async () => {
|
|
181
|
+
const timing = createElementTimingWaiter();
|
|
182
|
+
// No `wrap`: profile mode renders the component bare (no BenchProfiler / React Profiler
|
|
183
|
+
// overhead in the hand-captured trace). The runtime still plants the `default` sentinel, so
|
|
184
|
+
// the first paint shows up as a labeled marker in the DevTools Performance timeline.
|
|
185
|
+
const runtime = createCaseRuntime({
|
|
186
|
+
renderFn,
|
|
187
|
+
interaction,
|
|
188
|
+
context: {
|
|
189
|
+
waitForElementTiming: timing.waitForElementTiming,
|
|
190
|
+
pauseReactRecording: () => {},
|
|
191
|
+
resumeReactRecording: () => {}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
await runProfileSession(name, runtime);
|
|
195
|
+
timing.disconnect();
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
60
199
|
it(name, async ({
|
|
61
200
|
task
|
|
62
201
|
}) => {
|
|
@@ -64,108 +203,99 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
64
203
|
const warmupRuns = options?.warmupRuns ?? 10;
|
|
65
204
|
const totalRuns = warmupRuns + runs;
|
|
66
205
|
const iterations = [];
|
|
67
|
-
|
|
206
|
+
|
|
207
|
+
// Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
|
|
208
|
+
// is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
|
|
209
|
+
// (`bench:paint#grid-header`, …), all sharing a single definition. Paint is informational (no
|
|
210
|
+
// alarm): it dominates each test's total duration, so a per-test paint alarm just duplicates the
|
|
211
|
+
// Duration regression signal and floods the report on any broadly-regressed run.
|
|
212
|
+
const paint = new ScalarMetric({
|
|
213
|
+
name: 'bench:paint',
|
|
214
|
+
format: {
|
|
215
|
+
style: 'unit',
|
|
216
|
+
unit: 'millisecond',
|
|
217
|
+
maximumFractionDigits: 2
|
|
218
|
+
}
|
|
219
|
+
});
|
|
68
220
|
if (typeof window.gc !== 'function') {
|
|
69
221
|
console.warn('window.gc is not available. Run with --js-flags=--expose-gc for consistent GC between iterations.');
|
|
70
222
|
}
|
|
71
223
|
let renderError = null;
|
|
224
|
+
// Set if any iteration had a recording window that was active yet captured no renders.
|
|
225
|
+
let sawEmptyActiveWindow = false;
|
|
72
226
|
for (let i = 0; i < totalRuns; i += 1) {
|
|
73
227
|
const isWarmup = i < warmupRuns;
|
|
74
228
|
|
|
229
|
+
// Custom metrics recorded inside the benchmark honor warmup exclusion through the gate, the
|
|
230
|
+
// same way renders and `bench:paint` are excluded during warmup.
|
|
231
|
+
metricsGate.setRecordingEnabled(task, !isWarmup);
|
|
232
|
+
|
|
233
|
+
// Per-iteration switch for the harness's React render/paint recording. Starts paused when
|
|
234
|
+
// `reactRecordingPaused` is set; the interaction callback drives it from there.
|
|
235
|
+
const recording = createReactRecordingControls(!(options?.reactRecordingPaused ?? false));
|
|
236
|
+
|
|
75
237
|
// Drain event loop from previous unmount, then double GC for thorough cleanup
|
|
76
238
|
// eslint-disable-next-line no-await-in-loop
|
|
77
239
|
await settle();
|
|
78
240
|
forceGC();
|
|
79
241
|
const captures = [];
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
96
|
-
elementObserver.observe({
|
|
97
|
-
type: 'element',
|
|
98
|
-
buffered: false
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
const waitForElementTiming = (identifier, timeout) => {
|
|
102
|
-
if (!hasElementTiming) {
|
|
103
|
-
console.warn(`waitForElementTiming("${identifier}"): Element Timing API is not supported. ` + 'Paint metrics will not be collected.');
|
|
104
|
-
return Promise.resolve();
|
|
105
|
-
}
|
|
106
|
-
if (elementEntries.some(entry => entry.identifier === identifier)) {
|
|
107
|
-
return Promise.resolve();
|
|
108
|
-
}
|
|
109
|
-
const {
|
|
110
|
-
promise,
|
|
111
|
-
resolve,
|
|
112
|
-
reject
|
|
113
|
-
} = Promise.withResolvers();
|
|
114
|
-
const timeoutMs = timeout ?? 5000;
|
|
115
|
-
const timer = timeoutMs > 0 && timeoutMs < Infinity ? setTimeout(() => {
|
|
116
|
-
elementResolvers.delete(identifier);
|
|
117
|
-
reject(new Error(`waitForElementTiming("${identifier}"): timed out after ${timeoutMs}ms. ` + 'Ensure the element has an `elementtiming` attribute and is visible in the viewport.'));
|
|
118
|
-
}, timeoutMs) : undefined;
|
|
119
|
-
elementResolvers.set(identifier, () => {
|
|
120
|
-
if (timer) {
|
|
121
|
-
clearTimeout(timer);
|
|
122
|
-
}
|
|
123
|
-
resolve();
|
|
124
|
-
});
|
|
125
|
-
return promise;
|
|
126
|
-
};
|
|
127
|
-
const iterationStart = performance.now();
|
|
128
|
-
const container = document.createElement('div');
|
|
129
|
-
document.body.appendChild(container);
|
|
130
|
-
const root = ReactDOMClient.createRoot(container, {
|
|
242
|
+
const timing = createElementTimingWaiter();
|
|
243
|
+
const runtime = createCaseRuntime({
|
|
244
|
+
// Wrap the case in BenchProfiler so its renders are captured; the runtime mounts whatever
|
|
245
|
+
// renderFn returns (profiling passes the case bare).
|
|
246
|
+
renderFn: () => /*#__PURE__*/_jsx(BenchProfiler, {
|
|
247
|
+
captures: captures,
|
|
248
|
+
recording: recording,
|
|
249
|
+
children: renderFn()
|
|
250
|
+
}),
|
|
251
|
+
interaction,
|
|
252
|
+
context: {
|
|
253
|
+
waitForElementTiming: timing.waitForElementTiming,
|
|
254
|
+
pauseReactRecording: recording.pauseReactRecording,
|
|
255
|
+
resumeReactRecording: recording.resumeReactRecording
|
|
256
|
+
},
|
|
131
257
|
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
|
132
258
|
onUncaughtError: error => {
|
|
133
259
|
renderError = error;
|
|
134
260
|
}
|
|
135
261
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
captures: captures,
|
|
139
|
-
children: renderFn()
|
|
140
|
-
}));
|
|
141
|
-
});
|
|
262
|
+
const iterationStart = performance.now();
|
|
263
|
+
runtime.mount();
|
|
142
264
|
if (renderError) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
container.remove();
|
|
265
|
+
timing.disconnect();
|
|
266
|
+
runtime.unmount();
|
|
146
267
|
break;
|
|
147
268
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
waitForElementTiming
|
|
152
|
-
});
|
|
153
|
-
}
|
|
269
|
+
|
|
270
|
+
// eslint-disable-next-line no-await-in-loop
|
|
271
|
+
await runtime.interact?.();
|
|
154
272
|
|
|
155
273
|
// Wait for the bench sentinel paint entry (relies on test timeout)
|
|
156
274
|
// eslint-disable-next-line no-await-in-loop
|
|
157
|
-
await waitForElementTiming('default', 0);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
275
|
+
await timing.waitForElementTiming('default', 0);
|
|
276
|
+
|
|
277
|
+
// Close the final window and remember if any active window measured no renders.
|
|
278
|
+
recording.finalizeWindow();
|
|
279
|
+
if (recording.hadEmptyActiveWindow) {
|
|
280
|
+
sawEmptyActiveWindow = true;
|
|
281
|
+
}
|
|
282
|
+
timing.disconnect();
|
|
283
|
+
runtime.unmount();
|
|
161
284
|
if (!isWarmup) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
285
|
+
for (const entry of timing.elementEntries) {
|
|
286
|
+
// Skip paints that happened while recording was paused. Attribute by the paint's
|
|
287
|
+
// `paintTime`, not by when the observer callback fired (which can lag the paint).
|
|
288
|
+
if (!recording.activeAt(entry.paintTime)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// The default sentinel is the base series; named markers become sub-series.
|
|
292
|
+
const id = entry.identifier === 'default' ? undefined : entry.identifier;
|
|
293
|
+
paint.record(entry.paintTime - iterationStart, id !== undefined ? {
|
|
294
|
+
id
|
|
295
|
+
} : undefined);
|
|
296
|
+
}
|
|
166
297
|
iterations.push({
|
|
167
|
-
renders: captures
|
|
168
|
-
metrics
|
|
298
|
+
renders: captures
|
|
169
299
|
});
|
|
170
300
|
}
|
|
171
301
|
if (options?.afterEach) {
|
|
@@ -179,8 +309,9 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
179
309
|
throw renderError;
|
|
180
310
|
}
|
|
181
311
|
|
|
182
|
-
//
|
|
183
|
-
|
|
312
|
+
// Every active recording window must capture at least one render. Windows where recording was
|
|
313
|
+
// never running (e.g. a fully-paused, metric-only benchmark) are not checked.
|
|
314
|
+
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
315
|
|
|
185
316
|
// Validate all iterations produced the same render events (count + order).
|
|
186
317
|
// 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.21",
|
|
4
4
|
"author": "MUI Team",
|
|
5
5
|
"description": "Benchmark utilities for MUI projects. Internal package.",
|
|
6
6
|
"repository": {
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"directory": "packages/benchmark"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@babel/runtime": "^7.29.
|
|
13
|
-
"@vitejs/plugin-react": "^6.0
|
|
12
|
+
"@babel/runtime": "^7.29.7",
|
|
13
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
14
14
|
"env-ci": "^11.2.0",
|
|
15
|
-
"execa": "^
|
|
16
|
-
"zod": "^4.3
|
|
15
|
+
"execa": "^10.0.1",
|
|
16
|
+
"zod": "^4.4.3"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@vitest/browser-playwright": ">=4.1",
|
|
@@ -68,5 +68,5 @@
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
|
-
"gitSha": "
|
|
71
|
+
"gitSha": "9e0cb003bd909d9f1b5f9f33b1706f3ce1627ea8"
|
|
72
72
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const PROFILE_PANEL_STYLE = ['position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:2147483647', 'display:flex', 'gap:8px', 'align-items:center', 'box-sizing:border-box', 'padding:8px 12px', 'background:#1e1e1e', 'color:#fff', 'font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', 'box-shadow:0 1px 6px rgba(0,0,0,0.5)'].join(';');
|
|
2
|
+
const PROFILE_BUTTON_STYLE = ['padding:4px 10px', 'border:1px solid #555', 'border-radius:4px', 'background:#333', 'color:#fff', 'cursor:pointer', 'font:inherit'].join(';');
|
|
3
|
+
function createProfileButton(label) {
|
|
4
|
+
const button = document.createElement('button');
|
|
5
|
+
button.type = 'button';
|
|
6
|
+
button.textContent = label;
|
|
7
|
+
button.style.cssText = PROFILE_BUTTON_STYLE;
|
|
8
|
+
return button;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Interactive profiling session: instead of measuring, render a control panel with
|
|
12
|
+
// Render / Unmount / Run interaction / Finish buttons that drive the given case runtime. The
|
|
13
|
+
// component under test stays unmounted until the user clicks "Render", giving them time to start
|
|
14
|
+
// the DevTools profiler first. The returned promise resolves on "Finish", which is what keeps the
|
|
15
|
+
// Vitest test (and the headed browser window) alive in between; the caller cleans up afterwards.
|
|
16
|
+
export function runProfileSession(name, runtime) {
|
|
17
|
+
const panel = document.createElement('div');
|
|
18
|
+
panel.setAttribute('data-benchmark-profile-panel', '');
|
|
19
|
+
panel.style.cssText = PROFILE_PANEL_STYLE;
|
|
20
|
+
const title = document.createElement('span');
|
|
21
|
+
title.textContent = `⏱ ${name}`;
|
|
22
|
+
title.style.cssText = 'font-weight:600;white-space:nowrap';
|
|
23
|
+
const status = document.createElement('span');
|
|
24
|
+
status.style.cssText = 'margin-left:auto;opacity:0.85;white-space:nowrap';
|
|
25
|
+
const renderButton = createProfileButton('▶ Render');
|
|
26
|
+
const interactButton = runtime.interact ? createProfileButton('⚡ Run interaction') : null;
|
|
27
|
+
const finishButton = createProfileButton('✓ Finish');
|
|
28
|
+
if (interactButton) {
|
|
29
|
+
interactButton.disabled = true;
|
|
30
|
+
}
|
|
31
|
+
panel.appendChild(title);
|
|
32
|
+
panel.appendChild(renderButton);
|
|
33
|
+
if (interactButton) {
|
|
34
|
+
panel.appendChild(interactButton);
|
|
35
|
+
}
|
|
36
|
+
panel.appendChild(finishButton);
|
|
37
|
+
panel.appendChild(status);
|
|
38
|
+
document.body.appendChild(panel);
|
|
39
|
+
|
|
40
|
+
// Push page content below the fixed panel so it doesn't cover the component.
|
|
41
|
+
const spacer = document.createElement('div');
|
|
42
|
+
spacer.style.height = `${panel.offsetHeight}px`;
|
|
43
|
+
document.body.insertBefore(spacer, panel);
|
|
44
|
+
const setStatus = text => {
|
|
45
|
+
status.textContent = text;
|
|
46
|
+
};
|
|
47
|
+
const show = () => {
|
|
48
|
+
if (runtime.isMounted()) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
runtime.mount();
|
|
52
|
+
renderButton.textContent = '■ Unmount';
|
|
53
|
+
if (interactButton) {
|
|
54
|
+
interactButton.disabled = false;
|
|
55
|
+
}
|
|
56
|
+
setStatus('rendered — capture your profile, then Unmount or Finish');
|
|
57
|
+
};
|
|
58
|
+
const hide = () => {
|
|
59
|
+
if (!runtime.isMounted()) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
runtime.unmount();
|
|
63
|
+
renderButton.textContent = '▶ Render';
|
|
64
|
+
if (interactButton) {
|
|
65
|
+
interactButton.disabled = true;
|
|
66
|
+
}
|
|
67
|
+
setStatus('unmounted — Render again or Finish');
|
|
68
|
+
};
|
|
69
|
+
setStatus('idle — start the DevTools profiler, then click Render');
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
renderButton.addEventListener('click', () => runtime.isMounted() ? hide() : show());
|
|
72
|
+
if (interactButton) {
|
|
73
|
+
interactButton.addEventListener('click', async () => {
|
|
74
|
+
interactButton.disabled = true;
|
|
75
|
+
setStatus('running interaction…');
|
|
76
|
+
try {
|
|
77
|
+
await runtime.interact?.();
|
|
78
|
+
setStatus('interaction done');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
setStatus(`interaction error: ${String(error)}`);
|
|
81
|
+
} finally {
|
|
82
|
+
if (runtime.isMounted()) {
|
|
83
|
+
interactButton.disabled = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
finishButton.addEventListener('click', () => {
|
|
89
|
+
hide();
|
|
90
|
+
spacer.remove();
|
|
91
|
+
panel.remove();
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -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 `paintTime` 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
|
}
|