@mui/internal-benchmark 0.0.1

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 ADDED
@@ -0,0 +1,13 @@
1
+ # Versions
2
+
3
+ ## 2.0.8
4
+
5
+ Test release
6
+
7
+ ## 2.0.7
8
+
9
+ Test release
10
+
11
+ ## 2.0.6
12
+
13
+ Changelog start
@@ -0,0 +1,5 @@
1
+ export declare function ElementTiming({
2
+ name
3
+ }: {
4
+ name: string;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import * as React from 'react';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ export function ElementTiming({
4
+ name
5
+ }) {
6
+ return /*#__PURE__*/_jsx("span", {
7
+ elementtiming: name,
8
+ style: {
9
+ position: 'fixed',
10
+ top: 0,
11
+ left: 0,
12
+ opacity: 0.01,
13
+ pointerEvents: 'none',
14
+ fontSize: 1
15
+ },
16
+ children: "\xA0"
17
+ });
18
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 Material-UI SAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Benchmark
2
+
3
+ A React component render benchmarking tool built on Vitest and Playwright. Runs benchmarks in a real browser using React's profiling build to capture accurate render durations.
4
+
5
+ ## Features
6
+
7
+ - Measures React component render durations using `React.Profiler`
8
+ - Captures paint metrics via the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming)
9
+ - Runs in a real Chromium browser via Playwright
10
+ - Uses React's profiling build for accurate production-like measurements
11
+ - IQR-based outlier removal for stable results
12
+ - Configurable warmup and measurement runs
13
+ - JSON results output
14
+
15
+ ## Usage
16
+
17
+ ### Setup
18
+
19
+ Create a `vitest.config.ts`:
20
+
21
+ ```ts
22
+ import { createBenchmarkVitestConfig } from '@mui/internal-benchmark/vitest';
23
+
24
+ export default createBenchmarkVitestConfig();
25
+ ```
26
+
27
+ ### Writing benchmarks
28
+
29
+ Create `*.bench.tsx` files:
30
+
31
+ ```tsx
32
+ import * as React from 'react';
33
+ import { benchmark } from '@mui/internal-benchmark';
34
+
35
+ function MyComponent() {
36
+ return (
37
+ <div>
38
+ {Array.from({ length: 100 }, (_, i) => (
39
+ <span key={i}>{i}</span>
40
+ ))}
41
+ </div>
42
+ );
43
+ }
44
+
45
+ benchmark('MyComponent mount', () => <MyComponent />);
46
+ ```
47
+
48
+ The second argument is a render function (not an element) — it's called on each iteration to produce a fresh React element.
49
+
50
+ ### Interactions
51
+
52
+ To benchmark re-renders, pass an interaction callback:
53
+
54
+ ```tsx
55
+ benchmark(
56
+ 'Counter click',
57
+ () => <Counter />,
58
+ async () => {
59
+ document.querySelector('button')?.click();
60
+ },
61
+ );
62
+ ```
63
+
64
+ ### Paint metrics
65
+
66
+ By default, every benchmark captures a `paint:default` metric — the time from iteration start until the browser actually paints the rendered output. This uses the [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) via an invisible sentinel element that the benchmark harness renders automatically.
67
+
68
+ 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
+
70
+ ```tsx
71
+ import { benchmark, ElementTiming } from '@mui/internal-benchmark';
72
+
73
+ function MyComponent() {
74
+ return (
75
+ <div>
76
+ <ElementTiming name="my-component" />
77
+ {/* ... */}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ benchmark(
83
+ 'MyComponent mount',
84
+ () => <MyComponent />,
85
+ async ({ waitForElementTiming }) => {
86
+ await waitForElementTiming('my-component');
87
+ },
88
+ );
89
+ ```
90
+
91
+ This produces a `paint:my-component` metric alongside the automatic `paint:default`.
92
+
93
+ `waitForElementTiming` accepts an optional `timeout` in milliseconds (default: 5000). Pass `0` or `Infinity` to rely on the test timeout instead.
94
+
95
+ ### Options
96
+
97
+ ```tsx
98
+ benchmark('name', renderFn, interaction, {
99
+ runs: 20, // measurement iterations (default: 20)
100
+ warmupRuns: 10, // warmup iterations before measuring (default: 10)
101
+ afterEach: () => {
102
+ /* cleanup between iterations */
103
+ },
104
+ });
105
+ ```
106
+
107
+ ### Running
108
+
109
+ ```bash
110
+ vitest run
111
+ ```
112
+
113
+ ### Configuration
114
+
115
+ `createBenchmarkVitestConfig` accepts:
116
+
117
+ - `outputPath` — path for JSON results (default: `benchmarks/results.json`)
118
+ - `launchArgs` — additional browser launch arguments
119
+
120
+ To override standard Vitest options (e.g. `include`, `testTimeout`, `headless`), use `mergeConfig`:
121
+
122
+ ```ts
123
+ import { mergeConfig } from 'vitest/config';
124
+ import { createBenchmarkVitestConfig } from '@mui/internal-benchmark/vitest';
125
+
126
+ export default mergeConfig(createBenchmarkVitestConfig(), {
127
+ test: {
128
+ include: ['**/*.perf.tsx'],
129
+ },
130
+ });
131
+ ```
132
+
133
+ ## API
134
+
135
+ - `benchmark` — define a benchmark test case
136
+ - `ElementTiming` — invisible marker component for paint timing (renders a `<span>` tracked by the Element Timing API)
137
+ - `createBenchmarkVitestConfig` — create a Vitest config with browser benchmarking defaults
138
+ - `BenchmarkReporter` — Vitest reporter that collects and outputs benchmark results
package/ciReport.d.mts ADDED
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod/v4';
2
+ declare const renderStatsSchema: z.ZodObject<{
3
+ id: z.ZodString;
4
+ phase: z.ZodEnum<{
5
+ mount: "mount";
6
+ "nested-update": "nested-update";
7
+ update: "update";
8
+ }>;
9
+ startTime: z.ZodNumber;
10
+ actualDuration: z.ZodNumber;
11
+ stdDev: z.ZodNumber;
12
+ outliers: z.ZodNumber;
13
+ }, z.core.$strip>;
14
+ declare const metricStatsSchema: z.ZodObject<{
15
+ mean: z.ZodNumber;
16
+ stdDev: z.ZodNumber;
17
+ outliers: z.ZodNumber;
18
+ }, z.core.$strip>;
19
+ declare const benchmarkReportEntrySchema: z.ZodObject<{
20
+ iterations: z.ZodNumber;
21
+ totalDuration: z.ZodNumber;
22
+ renders: z.ZodArray<z.ZodObject<{
23
+ id: z.ZodString;
24
+ phase: z.ZodEnum<{
25
+ mount: "mount";
26
+ "nested-update": "nested-update";
27
+ update: "update";
28
+ }>;
29
+ startTime: z.ZodNumber;
30
+ actualDuration: z.ZodNumber;
31
+ stdDev: z.ZodNumber;
32
+ outliers: z.ZodNumber;
33
+ }, z.core.$strip>>;
34
+ metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
35
+ mean: z.ZodNumber;
36
+ stdDev: z.ZodNumber;
37
+ outliers: z.ZodNumber;
38
+ }, z.core.$strip>>;
39
+ }, z.core.$strip>;
40
+ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
41
+ iterations: z.ZodNumber;
42
+ totalDuration: z.ZodNumber;
43
+ renders: z.ZodArray<z.ZodObject<{
44
+ id: z.ZodString;
45
+ phase: z.ZodEnum<{
46
+ mount: "mount";
47
+ "nested-update": "nested-update";
48
+ update: "update";
49
+ }>;
50
+ startTime: z.ZodNumber;
51
+ actualDuration: z.ZodNumber;
52
+ stdDev: z.ZodNumber;
53
+ outliers: z.ZodNumber;
54
+ }, z.core.$strip>>;
55
+ metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
56
+ mean: z.ZodNumber;
57
+ stdDev: z.ZodNumber;
58
+ outliers: z.ZodNumber;
59
+ }, z.core.$strip>>;
60
+ }, z.core.$strip>>;
61
+ export declare const benchmarkUploadSchema: z.ZodObject<{
62
+ version: z.ZodLiteral<number>;
63
+ timestamp: z.ZodNumber;
64
+ commitSha: z.ZodString;
65
+ repo: z.ZodString;
66
+ reportType: z.ZodLiteral<string>;
67
+ prNumber: z.ZodOptional<z.ZodNumber>;
68
+ branch: z.ZodString;
69
+ report: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
70
+ }, z.core.$strip>;
71
+ export type RenderStats = z.infer<typeof renderStatsSchema>;
72
+ export type MetricStats = z.infer<typeof metricStatsSchema>;
73
+ export type BenchmarkReportEntry = z.infer<typeof benchmarkReportEntrySchema>;
74
+ export type BenchmarkReport = z.infer<typeof benchmarkReportSchema>;
75
+ export type BenchmarkUpload = z.infer<typeof benchmarkUploadSchema>;
76
+ export declare function getCiMetadata(): Promise<{
77
+ timestamp: number;
78
+ repo: string;
79
+ branch: string;
80
+ prNumber: number | undefined;
81
+ commitSha: string;
82
+ }>;
83
+ export {};
package/ciReport.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import { execa } from 'execa';
2
+ import { z } from 'zod/v4';
3
+ import envCi from 'env-ci';
4
+ async function getCommitSha() {
5
+ try {
6
+ const {
7
+ stdout
8
+ } = await execa('git', ['rev-parse', 'HEAD']);
9
+ return stdout.trim();
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Creates a CI report upload schema for a specific report type.
17
+ * Common fields (commitSha, repo, branch, prNumber) are shared across all report types.
18
+ */
19
+ function ciReportUploadSchema(type, version, reportSchema) {
20
+ return z.object({
21
+ version: z.literal(version),
22
+ timestamp: z.number(),
23
+ commitSha: z.string().regex(/^[0-9a-f]{40}$/, 'Must be a 40-character hex string'),
24
+ repo: z.string().includes('/', 'Must be in owner/repo format'),
25
+ reportType: z.literal(type),
26
+ prNumber: z.number().int().positive().optional(),
27
+ branch: z.string(),
28
+ report: reportSchema
29
+ });
30
+ }
31
+ const renderStatsSchema = z.object({
32
+ id: z.string(),
33
+ phase: z.enum(['mount', 'update', 'nested-update']),
34
+ startTime: z.number(),
35
+ actualDuration: z.number(),
36
+ stdDev: z.number(),
37
+ outliers: z.number()
38
+ });
39
+ const metricStatsSchema = z.object({
40
+ mean: z.number(),
41
+ stdDev: z.number(),
42
+ outliers: z.number()
43
+ });
44
+ const benchmarkReportEntrySchema = z.object({
45
+ iterations: z.number(),
46
+ totalDuration: z.number(),
47
+ renders: z.array(renderStatsSchema),
48
+ metrics: z.record(z.string(), metricStatsSchema)
49
+ });
50
+ const benchmarkReportSchema = z.record(z.string(), benchmarkReportEntrySchema);
51
+ export const benchmarkUploadSchema = ciReportUploadSchema('benchmark', 1, benchmarkReportSchema);
52
+ export async function getCiMetadata() {
53
+ const ciInfo = envCi();
54
+ return {
55
+ timestamp: Date.now(),
56
+ repo: ciInfo.slug ?? '',
57
+ branch: ciInfo.isPr ? ciInfo.prBranch ?? '' : ciInfo.branch ?? '',
58
+ prNumber: ciInfo.pr ? Number(ciInfo.pr) : undefined,
59
+ commitSha: ciInfo.commit ?? (await getCommitSha()) ?? ''
60
+ };
61
+ }
package/format.d.mts ADDED
@@ -0,0 +1,12 @@
1
+ export declare const dim: (s: string) => string;
2
+ export declare const red: (s: string) => string;
3
+ export declare const green: (s: string) => string;
4
+ export declare const yellow: (s: string) => string;
5
+ export declare const cyan: (s: string) => string;
6
+ export declare function fileUrl(filePath: string): string;
7
+ interface Column {
8
+ header: string;
9
+ width: number;
10
+ }
11
+ export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
12
+ export {};
package/format.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { styleText } from 'node:util';
3
+ export const dim = s => styleText('dim', s);
4
+ export const red = s => styleText('red', s);
5
+ export const green = s => styleText('green', s);
6
+ export const yellow = s => styleText('yellow', s);
7
+ export const cyan = s => styleText('cyan', s);
8
+ export function fileUrl(filePath) {
9
+ return pathToFileURL(filePath).href;
10
+ }
11
+ function truncate(str, maxLength) {
12
+ if (str.length <= maxLength) {
13
+ return str;
14
+ }
15
+ return `${str.slice(0, maxLength - 1)}…`;
16
+ }
17
+ export function printTable(columns, rows, footer, title) {
18
+ const colWidths = columns.map(col => col.width);
19
+ const totalInner = colWidths.reduce((sum, w) => sum + w + 2, 0) + colWidths.length - 1;
20
+ if (title) {
21
+ const titleTop = dim(`┌${'─'.repeat(totalInner)}┐`);
22
+ const titleContent = ` ${truncate(title, totalInner - 2)}`;
23
+ const titlePadding = totalInner - titleContent.length;
24
+ const titleLine = dim('│') + titleContent + ' '.repeat(Math.max(0, titlePadding)) + dim('│');
25
+ const titleSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┬')}┤`);
26
+
27
+ // eslint-disable-next-line no-console
28
+ console.log(titleTop);
29
+ // eslint-disable-next-line no-console
30
+ console.log(titleLine);
31
+ // eslint-disable-next-line no-console
32
+ console.log(titleSep);
33
+ } else {
34
+ const topBorder = dim(`┌${colWidths.map(w => '─'.repeat(w + 2)).join('┬')}┐`);
35
+ // eslint-disable-next-line no-console
36
+ console.log(topBorder);
37
+ }
38
+ const headerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┼')}┤`);
39
+ const headerCells = columns.map(col => ` ${col.header.padStart(col.width)} `);
40
+ const headerLine = dim('│') + headerCells.join(dim('│')) + dim('│');
41
+
42
+ // eslint-disable-next-line no-console
43
+ console.log(headerLine);
44
+ // eslint-disable-next-line no-console
45
+ console.log(headerSep);
46
+ for (const row of rows) {
47
+ const cells = row.map((cell, i) => ` ${cell.padStart(colWidths[i])} `);
48
+ // eslint-disable-next-line no-console
49
+ console.log(dim('│') + cells.join(dim('│')) + dim('│'));
50
+ }
51
+ if (footer) {
52
+ const footerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┴')}┤`);
53
+ const footerContent = ` ${footer}`;
54
+ const padding = totalInner - stripAnsi(footerContent).length;
55
+ const footerLine = dim('│') + footerContent + ' '.repeat(Math.max(0, padding)) + dim('│');
56
+ const bottomBorder = dim(`└${'─'.repeat(totalInner)}┘`);
57
+
58
+ // eslint-disable-next-line no-console
59
+ console.log(footerSep);
60
+ // eslint-disable-next-line no-console
61
+ console.log(footerLine);
62
+ // eslint-disable-next-line no-console
63
+ console.log(bottomBorder);
64
+ } else {
65
+ const bottomBorder = dim(`└${colWidths.map(w => '─'.repeat(w + 2)).join('┴')}┘`);
66
+ // eslint-disable-next-line no-console
67
+ console.log(bottomBorder);
68
+ }
69
+ }
70
+
71
+ // Strip ANSI escape codes to measure visible string length
72
+ function stripAnsi(str) {
73
+ // eslint-disable-next-line no-control-regex
74
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
75
+ }
package/index.d.mts ADDED
@@ -0,0 +1,16 @@
1
+ import * as React from 'react';
2
+ import type { InteractionContext } from "./types.mjs";
3
+ import "./taskMetaAugmentation.mjs";
4
+ export type { RenderEvent, BenchmarkMetric, IterationData, InteractionContext } from "./types.mjs";
5
+ export { ElementTiming } from "./ElementTiming.mjs";
6
+ declare global {
7
+ interface Window {
8
+ gc?: () => void;
9
+ }
10
+ }
11
+ interface BenchmarkOptions {
12
+ runs?: number;
13
+ warmupRuns?: number;
14
+ afterEach?: () => Promise<void> | void;
15
+ }
16
+ export declare function benchmark(name: string, renderFn: () => React.ReactElement, interactionOrOptions?: ((ctx: InteractionContext) => Promise<void> | void) | BenchmarkOptions, maybeOptions?: BenchmarkOptions): void;
package/index.mjs ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @mui/internal-benchmark v0.0.1
3
+ *
4
+ * @license undefined
5
+ * This source code is licensed under the undefined license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ var _ElementTiming;
9
+ import * as React from 'react';
10
+ import { expect, it } from 'vitest';
11
+ import * as ReactDOMClient from 'react-dom/client'; // aliased to react-dom/profiling by Vite
12
+ import * as ReactDOM from 'react-dom';
13
+ import { ElementTiming } from "./ElementTiming.mjs";
14
+ // Import for TaskMeta augmentation side effect
15
+ import "./taskMetaAugmentation.mjs";
16
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
+ export { ElementTiming } from "./ElementTiming.mjs";
18
+ function BenchProfiler({
19
+ captures,
20
+ children
21
+ }) {
22
+ const onRender = React.useCallback((id, phase, actualDuration, _baseDuration, startTime) => {
23
+ captures.push({
24
+ id,
25
+ phase,
26
+ actualDuration,
27
+ startTime
28
+ });
29
+ }, [captures]);
30
+ return /*#__PURE__*/_jsxs(React.Profiler, {
31
+ id: "bench",
32
+ onRender: onRender,
33
+ children: [children, _ElementTiming || (_ElementTiming = /*#__PURE__*/_jsx(ElementTiming, {
34
+ name: "default"
35
+ }))]
36
+ });
37
+ }
38
+
39
+ // Double GC: the first pass collects garbage, the second catches weak refs
40
+ // and prevent leaking into the next iteration.
41
+ function forceGC() {
42
+ if (typeof window.gc === 'function') {
43
+ window.gc();
44
+ window.gc();
45
+ }
46
+ }
47
+ // Flush pending microtasks and React cleanup effects (e.g. from a previous unmount)
48
+ // so they don't interfere with the next iteration's timing.
49
+ function settle() {
50
+ return new Promise(resolve => {
51
+ setTimeout(resolve, 0);
52
+ });
53
+ }
54
+ function supportsElementTiming() {
55
+ return PerformanceObserver.supportedEntryTypes.includes('element');
56
+ }
57
+ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
58
+ const interaction = typeof interactionOrOptions === 'function' ? interactionOrOptions : undefined;
59
+ const options = typeof interactionOrOptions === 'object' ? interactionOrOptions : maybeOptions;
60
+ it(name, async ({
61
+ task
62
+ }) => {
63
+ const runs = options?.runs ?? 20;
64
+ const warmupRuns = options?.warmupRuns ?? 10;
65
+ const totalRuns = warmupRuns + runs;
66
+ const iterations = [];
67
+ const hasElementTiming = supportsElementTiming();
68
+ if (typeof window.gc !== 'function') {
69
+ console.warn('window.gc is not available. Run with --js-flags=--expose-gc for consistent GC between iterations.');
70
+ }
71
+ let renderError = null;
72
+ for (let i = 0; i < totalRuns; i += 1) {
73
+ const isWarmup = i < warmupRuns;
74
+
75
+ // Drain event loop from previous unmount, then double GC for thorough cleanup
76
+ // eslint-disable-next-line no-await-in-loop
77
+ await settle();
78
+ forceGC();
79
+ const captures = [];
80
+ const elementEntries = [];
81
+ const elementResolvers = new Map();
82
+
83
+ // Set up Element Timing observer
84
+ let elementObserver = null;
85
+ if (hasElementTiming) {
86
+ elementObserver = new PerformanceObserver(list => {
87
+ for (const entry of list.getEntries()) {
88
+ elementEntries.push(entry);
89
+ const resolver = elementResolvers.get(entry.identifier);
90
+ if (resolver) {
91
+ elementResolvers.delete(entry.identifier);
92
+ resolver();
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, {
131
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
132
+ onUncaughtError: error => {
133
+ renderError = error;
134
+ }
135
+ });
136
+ ReactDOM.flushSync(() => {
137
+ root.render(/*#__PURE__*/_jsx(BenchProfiler, {
138
+ captures: captures,
139
+ children: renderFn()
140
+ }));
141
+ });
142
+ if (renderError) {
143
+ elementObserver?.disconnect();
144
+ root.unmount();
145
+ container.remove();
146
+ break;
147
+ }
148
+ if (interaction) {
149
+ // eslint-disable-next-line no-await-in-loop
150
+ await interaction({
151
+ waitForElementTiming
152
+ });
153
+ }
154
+
155
+ // Wait for the bench sentinel paint entry (relies on test timeout)
156
+ // eslint-disable-next-line no-await-in-loop
157
+ await waitForElementTiming('default', 0);
158
+ elementObserver?.disconnect();
159
+ root.unmount();
160
+ container.remove();
161
+ if (!isWarmup) {
162
+ const metrics = elementEntries.map(entry => ({
163
+ name: `paint:${entry.identifier}`,
164
+ value: entry.renderTime - iterationStart
165
+ }));
166
+ iterations.push({
167
+ renders: captures,
168
+ metrics
169
+ });
170
+ }
171
+ if (options?.afterEach) {
172
+ // eslint-disable-next-line no-await-in-loop
173
+ await options.afterEach();
174
+ }
175
+ }
176
+ task.meta.benchmarkIterations = iterations;
177
+ task.meta.benchmarkName = name;
178
+ if (renderError) {
179
+ throw renderError;
180
+ }
181
+
182
+ // Validate that at least one render was recorded
183
+ expect(iterations[0].renders.length, 'No renders were recorded during benchmark').toBeGreaterThan(0);
184
+
185
+ // Validate all iterations produced the same render events (count + order).
186
+ // This runs after meta is set so the reporter can still display results on failure.
187
+ if (iterations.length > 1) {
188
+ const getEventKey = event => `${event.id}:${event.phase}`;
189
+ const expectedKeys = iterations[0].renders.map(getEventKey);
190
+ for (let i = 1; i < iterations.length; i += 1) {
191
+ const iterationKeys = iterations[i].renders.map(getEventKey);
192
+ expect(iterationKeys, `Iteration ${i} render events differ from iteration 0`).toEqual(expectedKeys);
193
+ }
194
+ }
195
+ });
196
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@mui/internal-benchmark",
3
+ "version": "0.0.1",
4
+ "author": "MUI Team",
5
+ "description": "Benchmark utilities for MUI projects. Internal package.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mui/mui-public.git",
9
+ "directory": "packages/benchmark"
10
+ },
11
+ "dependencies": {
12
+ "@babel/runtime": "^7.29.2",
13
+ "@vitejs/plugin-react": "^6.0.1",
14
+ "env-ci": "^11.2.0",
15
+ "execa": "^9.6.1",
16
+ "zod": "^4.3.6"
17
+ },
18
+ "peerDependencies": {
19
+ "@vitest/browser-playwright": ">=4.1",
20
+ "react": "^18.0.0 || ^19.0.0",
21
+ "react-dom": "^18.0.0 || ^19.0.0",
22
+ "vitest": ">=4.1"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "commonjs",
28
+ "exports": {
29
+ "./package.json": "./package.json",
30
+ ".": {
31
+ "import": {
32
+ "types": "./index.d.mts",
33
+ "default": "./index.mjs"
34
+ },
35
+ "default": {
36
+ "types": "./index.d.mts",
37
+ "default": "./index.mjs"
38
+ }
39
+ },
40
+ "./ciReport": {
41
+ "import": {
42
+ "types": "./ciReport.d.mts",
43
+ "default": "./ciReport.mjs"
44
+ },
45
+ "default": {
46
+ "types": "./ciReport.d.mts",
47
+ "default": "./ciReport.mjs"
48
+ }
49
+ },
50
+ "./reporter": {
51
+ "import": {
52
+ "types": "./reporter.d.mts",
53
+ "default": "./reporter.mjs"
54
+ },
55
+ "default": {
56
+ "types": "./reporter.d.mts",
57
+ "default": "./reporter.mjs"
58
+ }
59
+ },
60
+ "./vitest": {
61
+ "import": {
62
+ "types": "./vitest.d.mts",
63
+ "default": "./vitest.mjs"
64
+ },
65
+ "default": {
66
+ "types": "./vitest.d.mts",
67
+ "default": "./vitest.mjs"
68
+ }
69
+ }
70
+ }
71
+ }
package/reporter.d.mts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Reporter, TestCase } from 'vitest/node';
2
+ import type { IterationData } from "./types.mjs";
3
+ import type { BenchmarkReportEntry } from "./ciReport.mjs";
4
+ import "./taskMetaAugmentation.mjs";
5
+ declare function generateReportFromIterations(iterations: IterationData[]): BenchmarkReportEntry;
6
+ export interface BenchmarkReporterOptions {
7
+ outputPath?: string;
8
+ upload?: boolean;
9
+ }
10
+ declare class BenchmarkReporter implements Reporter {
11
+ private benchmarks;
12
+ private outputPath;
13
+ private upload;
14
+ private hasFailures;
15
+ constructor(options?: BenchmarkReporterOptions);
16
+ onTestCaseResult(testCase: TestCase): void;
17
+ onTestRunEnd(): Promise<void>;
18
+ }
19
+ export { BenchmarkReporter, generateReportFromIterations };
20
+ export default BenchmarkReporter;
package/reporter.mjs ADDED
@@ -0,0 +1,251 @@
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs/promises';
3
+ import { getCiMetadata } from "./ciReport.mjs";
4
+ import { calculateMean, calculateStdDev, quantile, isOutlier } from "./stats.mjs";
5
+ import { dim, red, green, yellow, cyan, printTable, fileUrl } from "./format.mjs";
6
+ import { uploadCiReport } from "./upload.mjs";
7
+ // Import for TaskMeta augmentation side effect
8
+ import "./taskMetaAugmentation.mjs";
9
+ const byNumeric = (a, b) => a - b;
10
+ function getEventKey(event) {
11
+ return `${event.id}:${event.phase}`;
12
+ }
13
+ function aggregateMetrics(iterations) {
14
+ // Collect all metric names across iterations
15
+ const metricValues = new Map();
16
+ for (const iteration of iterations) {
17
+ for (const metric of iteration.metrics) {
18
+ let values = metricValues.get(metric.name);
19
+ if (!values) {
20
+ values = [];
21
+ metricValues.set(metric.name, values);
22
+ }
23
+ values.push(metric.value);
24
+ }
25
+ }
26
+ const result = {};
27
+ for (const [name, values] of metricValues) {
28
+ // Apply IQR filtering
29
+ const sorted = [...values].sort(byNumeric);
30
+ const q1 = quantile(sorted, 0.25);
31
+ const q3 = quantile(sorted, 0.75);
32
+ const filtered = values.filter(d => !isOutlier(d, q1, q3));
33
+ const used = filtered.length > 0 ? filtered : values;
34
+ const mean = calculateMean(used);
35
+ const stdDev = calculateStdDev(used, mean);
36
+ result[name] = {
37
+ mean,
38
+ stdDev,
39
+ outliers: values.length - used.length
40
+ };
41
+ }
42
+ return result;
43
+ }
44
+ function generateReportFromIterations(iterations) {
45
+ if (iterations.length === 0) {
46
+ return {
47
+ iterations: 0,
48
+ totalDuration: 0,
49
+ renders: [],
50
+ metrics: {}
51
+ };
52
+ }
53
+ const iterationCount = iterations.length;
54
+ const firstIteration = iterations[0];
55
+ const expectedLength = firstIteration.renders.length;
56
+
57
+ // Skip report if iterations have inconsistent event counts (the test already failed)
58
+ if (iterations.some(iter => iter.renders.length !== expectedLength)) {
59
+ return {
60
+ iterations: iterationCount,
61
+ totalDuration: 0,
62
+ renders: [],
63
+ metrics: {}
64
+ };
65
+ }
66
+
67
+ // Per-render stats (IQR-filtered)
68
+ const renderStats = [];
69
+ for (let index = 0; index < expectedLength; index += 1) {
70
+ const durations = iterations.map(iteration => iteration.renders[index].actualDuration);
71
+ const sorted = [...durations].sort(byNumeric);
72
+ const q1 = quantile(sorted, 0.25);
73
+ const q3 = quantile(sorted, 0.75);
74
+ const filtered = durations.filter(d => !isOutlier(d, q1, q3));
75
+ const used = filtered.length > 0 ? filtered : durations;
76
+ const iqrMean = calculateMean(used);
77
+ const iqrStdDev = calculateStdDev(used, iqrMean);
78
+ const coefficientOfVariation = iqrMean > 0 ? iqrStdDev / iqrMean : 0;
79
+ if (iqrMean > 1 && coefficientOfVariation > 0.1) {
80
+ const event = firstIteration.renders[index];
81
+ console.warn(`High coefficient of variation (${(coefficientOfVariation * 100).toFixed(1)}%) for render #${index} event "${getEventKey(event)}". ` + `Mean: ${iqrMean.toFixed(2)}ms, StdDev: ${iqrStdDev.toFixed(2)}ms. Results may be unreliable.`);
82
+ }
83
+ renderStats.push({
84
+ event: firstIteration.renders[index],
85
+ iqrMean,
86
+ iqrStdDev,
87
+ outliers: durations.length - used.length
88
+ });
89
+ }
90
+
91
+ // Calculate mean gaps between consecutive renders, then derive start times
92
+ const meanGaps = [0]; // no gap before first render
93
+ for (let index = 1; index < expectedLength; index += 1) {
94
+ const gaps = iterations.map(iteration => {
95
+ const prevEnd = iteration.renders[index - 1].startTime + iteration.renders[index - 1].actualDuration;
96
+ return iteration.renders[index].startTime - prevEnd;
97
+ });
98
+ meanGaps.push(calculateMean(gaps));
99
+ }
100
+ const renders = [];
101
+ let totalDuration = 0;
102
+ for (let index = 0; index < expectedLength; index += 1) {
103
+ const {
104
+ event,
105
+ iqrMean,
106
+ iqrStdDev,
107
+ outliers
108
+ } = renderStats[index];
109
+ const startTime = index === 0 ? 0 : renders[index - 1].startTime + renders[index - 1].actualDuration + meanGaps[index];
110
+ renders.push({
111
+ id: event.id,
112
+ phase: event.phase,
113
+ startTime,
114
+ actualDuration: iqrMean,
115
+ stdDev: iqrStdDev,
116
+ outliers
117
+ });
118
+ totalDuration += iqrMean;
119
+ }
120
+
121
+ // Aggregate metrics
122
+ const metrics = aggregateMetrics(iterations);
123
+ return {
124
+ iterations: iterationCount,
125
+ totalDuration,
126
+ renders,
127
+ metrics
128
+ };
129
+ }
130
+ const LABEL_WIDTH = 28;
131
+ const STAT_WIDTH = 16;
132
+ const CV_WIDTH = 8;
133
+ function colorCV(cv) {
134
+ const str = `${cv.toFixed(1)}%`.padStart(CV_WIDTH);
135
+ if (cv > 10) {
136
+ return red(str);
137
+ }
138
+ if (cv > 5) {
139
+ return yellow(str);
140
+ }
141
+ return dim(str);
142
+ }
143
+ function printDurationMatrix(name, report, footer) {
144
+ if (report.renders.length === 0) {
145
+ return;
146
+ }
147
+ const rows = [];
148
+ for (let r = 0; r < report.renders.length; r += 1) {
149
+ const render = report.renders[r];
150
+ const label = `#${r} ${render.id}:${render.phase}`;
151
+ const iqrStr = `${render.actualDuration.toFixed(2)}±${render.stdDev.toFixed(2)}`;
152
+ const cv = render.actualDuration > 0 ? render.stdDev / render.actualDuration * 100 : 0;
153
+ rows.push([label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), render.outliers > 0 ? yellow(String(render.outliers).padStart(4)) : dim('0'.padStart(4))]);
154
+ }
155
+ printTable([{
156
+ header: 'Render',
157
+ width: LABEL_WIDTH
158
+ }, {
159
+ header: 'Mean±σ (ms)',
160
+ width: STAT_WIDTH
161
+ }, {
162
+ header: 'Var%',
163
+ width: CV_WIDTH
164
+ }, {
165
+ header: 'Out',
166
+ width: 4
167
+ }], rows, footer, name);
168
+ }
169
+ function printMetricsTable(name, metrics, iterationCount) {
170
+ const entries = Object.entries(metrics);
171
+ if (entries.length === 0) {
172
+ return;
173
+ }
174
+ const rows = entries.map(([metricName, stats]) => {
175
+ const iqrStr = `${stats.mean.toFixed(2)}±${stats.stdDev.toFixed(2)}`;
176
+ const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
177
+ return [metricName.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), stats.outliers > 0 ? yellow(String(stats.outliers).padStart(4)) : dim('0'.padStart(4))];
178
+ });
179
+ printTable([{
180
+ header: 'Metric',
181
+ width: LABEL_WIDTH
182
+ }, {
183
+ header: 'Mean±σ (ms)',
184
+ width: STAT_WIDTH
185
+ }, {
186
+ header: 'Var%',
187
+ width: CV_WIDTH
188
+ }, {
189
+ header: 'Out',
190
+ width: 4
191
+ }], rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
192
+ }
193
+ class BenchmarkReporter {
194
+ benchmarks = {};
195
+ hasFailures = false;
196
+ constructor(options) {
197
+ this.outputPath = options?.outputPath ?? path.resolve(process.cwd(), 'benchmarks', 'results.json');
198
+ this.upload = options?.upload ?? process.env.BENCHMARK_UPLOAD === 'true';
199
+ }
200
+ onTestCaseResult(testCase) {
201
+ if (testCase.result().state === 'failed') {
202
+ this.hasFailures = true;
203
+ }
204
+ const meta = testCase.meta();
205
+ const iterations = meta.benchmarkIterations;
206
+ if (!iterations) {
207
+ console.warn(yellow(` No iterations recorded for: ${testCase.fullName}`));
208
+ return;
209
+ }
210
+ const name = meta.benchmarkName ?? testCase.fullName;
211
+ const report = generateReportFromIterations(iterations);
212
+ this.benchmarks[name] = report;
213
+ const summary = dim('Total: ') + green(`${report.totalDuration.toFixed(2)}ms`) + dim(` (${report.renders.length} renders, ${report.iterations} iterations)`);
214
+ printDurationMatrix(`${name} — React`, report, summary);
215
+ printMetricsTable(name, report.metrics, report.iterations);
216
+ }
217
+ async onTestRunEnd() {
218
+ const count = Object.keys(this.benchmarks).length;
219
+
220
+ // eslint-disable-next-line no-console
221
+ console.log(`\n${cyan('Benchmark Results')} ${dim(`(${count} benchmark${count === 1 ? '' : 's'})`)}`);
222
+ for (const [name, result] of Object.entries(this.benchmarks)) {
223
+ // eslint-disable-next-line no-console
224
+ console.log(` ${name}: ${result.totalDuration.toFixed(2)}ms ${dim(`(${result.renders.length} renders, ${result.iterations} iterations)`)}`);
225
+ }
226
+ const results = {
227
+ version: 1,
228
+ reportType: 'benchmark',
229
+ ...(await getCiMetadata()),
230
+ report: this.benchmarks
231
+ };
232
+ const outputDir = path.dirname(this.outputPath);
233
+ await fs.mkdir(outputDir, {
234
+ recursive: true
235
+ });
236
+ await fs.writeFile(this.outputPath, JSON.stringify(results, null, 2));
237
+
238
+ // eslint-disable-next-line no-console
239
+ console.log(dim(`\nResults saved to ${fileUrl(this.outputPath)}`));
240
+ if (this.upload) {
241
+ if (this.hasFailures) {
242
+ // eslint-disable-next-line no-console
243
+ console.log(yellow('\nSkipping upload: some test cases failed'));
244
+ } else {
245
+ await uploadCiReport(results);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ export { BenchmarkReporter, generateReportFromIterations };
251
+ export default BenchmarkReporter;
package/stats.d.mts ADDED
@@ -0,0 +1,10 @@
1
+ export declare function calculateMean(values: number[]): number;
2
+ export declare function calculateStdDev(values: number[], mean: number): number;
3
+ export declare function quantile(sorted: number[], q: number): number;
4
+ /**
5
+ * Determines whether a value is a statistical outlier using the IQR method.
6
+ * Values below Q1 - 1.5*IQR or above Q3 + 1.5*IQR are considered outliers.
7
+ *
8
+ * See https://en.wikipedia.org/wiki/Interquartile_range#Outliers
9
+ */
10
+ export declare function isOutlier(value: number, q1: number, q3: number): boolean;
package/stats.mjs ADDED
@@ -0,0 +1,27 @@
1
+ export function calculateMean(values) {
2
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
3
+ }
4
+ export function calculateStdDev(values, mean) {
5
+ const squaredDiffs = values.map(v => (v - mean) ** 2);
6
+ return Math.sqrt(squaredDiffs.reduce((sum, v) => sum + v, 0) / values.length);
7
+ }
8
+ export function quantile(sorted, q) {
9
+ const pos = (sorted.length - 1) * q;
10
+ const base = Math.floor(pos);
11
+ const rest = pos - base;
12
+ if (sorted[base + 1] !== undefined) {
13
+ return sorted[base] + rest * (sorted[base + 1] - sorted[base]);
14
+ }
15
+ return sorted[base];
16
+ }
17
+
18
+ /**
19
+ * Determines whether a value is a statistical outlier using the IQR method.
20
+ * Values below Q1 - 1.5*IQR or above Q3 + 1.5*IQR are considered outliers.
21
+ *
22
+ * See https://en.wikipedia.org/wiki/Interquartile_range#Outliers
23
+ */
24
+ export function isOutlier(value, q1, q3) {
25
+ const iqr = q3 - q1;
26
+ return value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr;
27
+ }
@@ -0,0 +1,7 @@
1
+ import type { IterationData } from "./types.mjs";
2
+ declare module 'vitest' {
3
+ interface TaskMeta {
4
+ benchmarkName?: string;
5
+ benchmarkIterations?: IterationData[];
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ export {};
package/types.d.mts ADDED
@@ -0,0 +1,33 @@
1
+ export interface RenderEvent {
2
+ id: string;
3
+ /**
4
+ * The React Profiler phase that triggered the render.
5
+ * - `mount` — first render of the component
6
+ * - `update` — re-render caused by state, props, or context change
7
+ * - `nested-update` — re-render caused by a state update inside useLayoutEffect or flushSync
8
+ *
9
+ * See https://react.dev/reference/react/Profiler#onrender-callback
10
+ */
11
+ phase: 'mount' | 'update' | 'nested-update';
12
+ actualDuration: number;
13
+ /** Start time in milliseconds (from performance.now()) */
14
+ startTime: number;
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
+ export interface IterationData {
23
+ renders: RenderEvent[];
24
+ metrics: BenchmarkMetric[];
25
+ }
26
+ export interface InteractionContext {
27
+ /**
28
+ * Wait for an element with the given `elementtiming` identifier to be painted.
29
+ * @param identifier - The `elementtiming` attribute value to wait for.
30
+ * @param timeout - Timeout in ms. Default: 5000. Pass 0 or Infinity to rely on the test timeout.
31
+ */
32
+ waitForElementTiming: (identifier: string, timeout?: number) => Promise<void>;
33
+ }
package/types.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};
package/upload.d.mts ADDED
@@ -0,0 +1,5 @@
1
+ import type { BenchmarkUpload } from "./ciReport.mjs";
2
+ declare function uploadCiReport(report: BenchmarkUpload, options?: {
3
+ apiUrl?: string;
4
+ }): Promise<void>;
5
+ export { uploadCiReport };
package/upload.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { benchmarkUploadSchema } from "./ciReport.mjs";
2
+ async function uploadCiReport(report, options) {
3
+ benchmarkUploadSchema.parse(report);
4
+ const apiUrl = options?.apiUrl ?? process.env.CI_REPORT_API_URL ?? 'https://code-infra-dashboard.onrender.com';
5
+ const url = new URL('/api/ci-reports/upload', apiUrl);
6
+ const oidcToken = process.env.CIRCLE_OIDC_TOKEN_V2;
7
+ if (!oidcToken) {
8
+ throw new Error('CIRCLE_OIDC_TOKEN_V2 environment variable is required for uploads');
9
+ }
10
+
11
+ // eslint-disable-next-line no-console
12
+ console.log('Upload request body:', JSON.stringify({
13
+ ...report,
14
+ report: '...'
15
+ }, null, 2));
16
+ // eslint-disable-next-line no-console
17
+ console.log(`Uploading benchmark to ${url.href}`);
18
+ const response = await fetch(url, {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ Authorization: `Bearer ${oidcToken}`
23
+ },
24
+ body: JSON.stringify(report)
25
+ });
26
+ const responseText = await response.text();
27
+ if (!response.ok) {
28
+ throw new Error(`Upload failed (${response.status}): ${responseText}`);
29
+ }
30
+ const result = JSON.parse(responseText);
31
+ // eslint-disable-next-line no-console
32
+ console.log(`Benchmark results uploaded successfully. S3 key: ${result.key}`);
33
+ }
34
+ export { uploadCiReport };
package/vitest.d.mts ADDED
@@ -0,0 +1,12 @@
1
+ import { ViteUserConfig } from 'vitest/config';
2
+ export interface CreateBenchmarkVitestConfigOptions {
3
+ /**
4
+ * Path to save benchmark results JSON file. If not provided, results will not be saved to disk.
5
+ */
6
+ outputPath?: string;
7
+ /**
8
+ * Additional Chromium launch arguments.
9
+ */
10
+ launchArgs?: string[];
11
+ }
12
+ export declare function createBenchmarkVitestConfig(options?: CreateBenchmarkVitestConfigOptions): ViteUserConfig;
package/vitest.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import react from '@vitejs/plugin-react';
2
+ import { playwright } from '@vitest/browser-playwright';
3
+ export function createBenchmarkVitestConfig(options) {
4
+ const {
5
+ outputPath,
6
+ launchArgs = []
7
+ } = options ?? {};
8
+ return {
9
+ plugins: [react()],
10
+ define: {
11
+ 'process.env.NODE_ENV': '"production"'
12
+ },
13
+ resolve: {
14
+ dedupe: ['react', 'react-dom'],
15
+ alias: [{
16
+ find: 'react-dom/client',
17
+ replacement: 'react-dom/profiling'
18
+ }]
19
+ },
20
+ test: {
21
+ browser: {
22
+ enabled: true,
23
+ headless: true,
24
+ screenshotFailures: false,
25
+ instances: [{
26
+ browser: 'chromium',
27
+ testTimeout: 120_000
28
+ }],
29
+ provider: playwright({
30
+ launchOptions: {
31
+ args: [
32
+ // V8 flags for deterministic JS execution
33
+ '--js-flags=--expose-gc,--predictable,--no-opt,--predictable-gc-schedule,--no-concurrent-sweeping,--hash-seed=1,--random-seed=1,--max-old-space-size=4096',
34
+ // Chromium flags to reduce renderer/compositor noise
35
+ '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-networking',
36
+ // Reduces environmental noise by disabling field trials,
37
+ // for more consistent profiling results.
38
+ '--enable-benchmarking',
39
+ // Forces software rendering instead of GPU, which is more deterministic.
40
+ '--disable-gpu', ...launchArgs]
41
+ }
42
+ })
43
+ },
44
+ fileParallelism: false,
45
+ reporters: ['default', ['@mui/internal-benchmark/reporter', {
46
+ outputPath
47
+ }]],
48
+ include: ['**/*.bench.tsx']
49
+ }
50
+ };
51
+ }