@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 +13 -0
- package/ElementTiming.d.mts +5 -0
- package/ElementTiming.mjs +18 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/ciReport.d.mts +83 -0
- package/ciReport.mjs +61 -0
- package/format.d.mts +12 -0
- package/format.mjs +75 -0
- package/index.d.mts +16 -0
- package/index.mjs +196 -0
- package/package.json +71 -0
- package/reporter.d.mts +20 -0
- package/reporter.mjs +251 -0
- package/stats.d.mts +10 -0
- package/stats.mjs +27 -0
- package/taskMetaAugmentation.d.mts +7 -0
- package/taskMetaAugmentation.mjs +1 -0
- package/types.d.mts +33 -0
- package/types.mjs +1 -0
- package/upload.d.mts +5 -0
- package/upload.mjs +34 -0
- package/vitest.d.mts +12 -0
- package/vitest.mjs +51 -0
package/CHANGELOG.md
ADDED
|
@@ -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 @@
|
|
|
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
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
|
+
}
|