@mui/internal-benchmark 0.0.3-canary.9 → 0.1.1-canary.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/ElementTiming.d.mts +2 -1
- package/Metric.mjs +2 -0
- package/ciApi.d.mts +5 -0
- package/ciApi.mjs +34 -0
- package/ciReport.d.mts +77 -3
- package/ciReport.mjs +1 -1
- package/index.mjs +7 -9
- package/package.json +8 -6
- package/reactRecording.d.mts +1 -1
- package/reporter.mjs +3 -0
- package/syncPrComment.d.mts +1 -3
- package/syncPrComment.mjs +6 -25
- package/upload.d.mts +7 -3
- package/upload.mjs +11 -28
- package/utils/git.d.mts +2 -0
- package/utils/git.mjs +13 -0
- package/utils/packWorkspace.d.mts +46 -0
- package/utils/packWorkspace.mjs +217 -0
- package/utils/pnpm.d.mts +12 -0
- package/utils/pnpm.mjs +19 -0
- package/utils/testUtils.d.mts +2 -0
- package/utils/testUtils.mjs +16 -0
- package/vitest.mjs +12 -0
package/ElementTiming.d.mts
CHANGED
package/Metric.mjs
CHANGED
package/ciApi.d.mts
ADDED
package/ciApi.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* eslint-disable no-console -- progress belongs in the CI log. */
|
|
2
|
+
|
|
3
|
+
// The dashboard's CI-report API: a bearer-token POST of one JSON body, with the OIDC token
|
|
4
|
+
// CircleCI mints for the job.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_URL = 'https://frontend-public.mui.com';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Posts `body` to the dashboard, authenticated as this CI job, and returns the response text. `what`
|
|
10
|
+
* names the request in the errors it raises.
|
|
11
|
+
*/
|
|
12
|
+
export async function postToDashboard(pathname, body, what) {
|
|
13
|
+
const oidcToken = process.env.CIRCLE_OIDC_TOKEN_V2;
|
|
14
|
+
if (!oidcToken) {
|
|
15
|
+
throw new Error(`CIRCLE_OIDC_TOKEN_V2 environment variable is required for ${what}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The override exists so a pull request can point at its own dashboard preview deployment.
|
|
19
|
+
const url = new URL(pathname, process.env.CI_REPORT_API_URL || DEFAULT_API_URL);
|
|
20
|
+
console.log(`${what} → ${url.href}`);
|
|
21
|
+
const response = await fetch(url, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
Authorization: `Bearer ${oidcToken}`
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify(body)
|
|
28
|
+
});
|
|
29
|
+
const responseText = await response.text();
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`${what} failed (${response.status}): ${responseText}`);
|
|
32
|
+
}
|
|
33
|
+
return responseText;
|
|
34
|
+
}
|
package/ciReport.d.mts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a CI report upload schema for a specific report type.
|
|
4
|
+
* Common fields (commitSha, repo, branch, prNumber) are shared across all report types.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ciReportUploadSchema<ReportSchema extends z.ZodType>(type: string, version: number, reportSchema: ReportSchema): z.ZodObject<{
|
|
7
|
+
version: z.ZodLiteral<number>;
|
|
8
|
+
timestamp: z.ZodNumber;
|
|
9
|
+
commitSha: z.ZodString;
|
|
10
|
+
repo: z.ZodString;
|
|
11
|
+
reportType: z.ZodLiteral<string>;
|
|
12
|
+
prNumber: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
branch: z.ZodString;
|
|
14
|
+
report: ReportSchema;
|
|
15
|
+
}, z.core.$strip>;
|
|
2
16
|
declare const renderStatsSchema: z.ZodObject<{
|
|
3
17
|
id: z.ZodString;
|
|
4
18
|
phase: z.ZodEnum<{
|
|
@@ -81,7 +95,27 @@ declare const benchmarkBaseUploadSchema: z.ZodObject<{
|
|
|
81
95
|
reportType: z.ZodLiteral<string>;
|
|
82
96
|
prNumber: z.ZodOptional<z.ZodNumber>;
|
|
83
97
|
branch: z.ZodString;
|
|
84
|
-
report: z.
|
|
98
|
+
report: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
99
|
+
iterations: z.ZodNumber;
|
|
100
|
+
totalDuration: z.ZodNumber;
|
|
101
|
+
renders: z.ZodArray<z.ZodObject<{
|
|
102
|
+
id: z.ZodString;
|
|
103
|
+
phase: z.ZodEnum<{
|
|
104
|
+
mount: "mount";
|
|
105
|
+
"nested-update": "nested-update";
|
|
106
|
+
update: "update";
|
|
107
|
+
}>;
|
|
108
|
+
startTime: z.ZodNumber;
|
|
109
|
+
actualDuration: z.ZodNumber;
|
|
110
|
+
stdDev: z.ZodNumber;
|
|
111
|
+
outliers: z.ZodNumber;
|
|
112
|
+
}, z.core.$strip>>;
|
|
113
|
+
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
114
|
+
mean: z.ZodNumber;
|
|
115
|
+
stdDev: z.ZodNumber;
|
|
116
|
+
outliers: z.ZodNumber;
|
|
117
|
+
}, z.core.$strip>>;
|
|
118
|
+
}, z.core.$strip>>;
|
|
85
119
|
metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
86
120
|
kind: z.ZodEnum<{
|
|
87
121
|
discrete: "discrete";
|
|
@@ -106,7 +140,27 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
|
|
|
106
140
|
reportType: z.ZodLiteral<string>;
|
|
107
141
|
prNumber: z.ZodOptional<z.ZodNumber>;
|
|
108
142
|
branch: z.ZodString;
|
|
109
|
-
report: z.
|
|
143
|
+
report: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
144
|
+
iterations: z.ZodNumber;
|
|
145
|
+
totalDuration: z.ZodNumber;
|
|
146
|
+
renders: z.ZodArray<z.ZodObject<{
|
|
147
|
+
id: z.ZodString;
|
|
148
|
+
phase: z.ZodEnum<{
|
|
149
|
+
mount: "mount";
|
|
150
|
+
"nested-update": "nested-update";
|
|
151
|
+
update: "update";
|
|
152
|
+
}>;
|
|
153
|
+
startTime: z.ZodNumber;
|
|
154
|
+
actualDuration: z.ZodNumber;
|
|
155
|
+
stdDev: z.ZodNumber;
|
|
156
|
+
outliers: z.ZodNumber;
|
|
157
|
+
}, z.core.$strip>>;
|
|
158
|
+
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
159
|
+
mean: z.ZodNumber;
|
|
160
|
+
stdDev: z.ZodNumber;
|
|
161
|
+
outliers: z.ZodNumber;
|
|
162
|
+
}, z.core.$strip>>;
|
|
163
|
+
}, z.core.$strip>>;
|
|
110
164
|
metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
111
165
|
kind: z.ZodEnum<{
|
|
112
166
|
discrete: "discrete";
|
|
@@ -130,7 +184,27 @@ export declare const benchmarkUploadSchema: z.ZodObject<{
|
|
|
130
184
|
reportType: z.ZodLiteral<string>;
|
|
131
185
|
prNumber: z.ZodOptional<z.ZodNumber>;
|
|
132
186
|
branch: z.ZodString;
|
|
133
|
-
report: z.
|
|
187
|
+
report: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
188
|
+
iterations: z.ZodNumber;
|
|
189
|
+
totalDuration: z.ZodNumber;
|
|
190
|
+
renders: z.ZodArray<z.ZodObject<{
|
|
191
|
+
id: z.ZodString;
|
|
192
|
+
phase: z.ZodEnum<{
|
|
193
|
+
mount: "mount";
|
|
194
|
+
"nested-update": "nested-update";
|
|
195
|
+
update: "update";
|
|
196
|
+
}>;
|
|
197
|
+
startTime: z.ZodNumber;
|
|
198
|
+
actualDuration: z.ZodNumber;
|
|
199
|
+
stdDev: z.ZodNumber;
|
|
200
|
+
outliers: z.ZodNumber;
|
|
201
|
+
}, z.core.$strip>>;
|
|
202
|
+
metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
203
|
+
mean: z.ZodNumber;
|
|
204
|
+
stdDev: z.ZodNumber;
|
|
205
|
+
outliers: z.ZodNumber;
|
|
206
|
+
}, z.core.$strip>>;
|
|
207
|
+
}, z.core.$strip>>;
|
|
134
208
|
metricDefinitions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
135
209
|
kind: z.ZodEnum<{
|
|
136
210
|
discrete: "discrete";
|
package/ciReport.mjs
CHANGED
|
@@ -13,7 +13,7 @@ async function getGitInfo() {
|
|
|
13
13
|
* Creates a CI report upload schema for a specific report type.
|
|
14
14
|
* Common fields (commitSha, repo, branch, prNumber) are shared across all report types.
|
|
15
15
|
*/
|
|
16
|
-
function ciReportUploadSchema(type, version, reportSchema) {
|
|
16
|
+
export function ciReportUploadSchema(type, version, reportSchema) {
|
|
17
17
|
return z.object({
|
|
18
18
|
version: z.literal(version),
|
|
19
19
|
timestamp: z.number(),
|
package/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @mui/internal-benchmark v0.0
|
|
2
|
+
* @mui/internal-benchmark v0.1.0
|
|
3
3
|
*
|
|
4
4
|
* @license undefined
|
|
5
5
|
* This source code is licensed under the undefined license found in the
|
|
@@ -206,17 +206,15 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
206
206
|
|
|
207
207
|
// Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
|
|
208
208
|
// is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
|
|
209
|
-
// (`bench:paint#grid-header`, …), all sharing a single definition.
|
|
210
|
-
//
|
|
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.
|
|
211
212
|
const paint = new ScalarMetric({
|
|
212
213
|
name: 'bench:paint',
|
|
213
214
|
format: {
|
|
214
215
|
style: 'unit',
|
|
215
216
|
unit: 'millisecond',
|
|
216
217
|
maximumFractionDigits: 2
|
|
217
|
-
},
|
|
218
|
-
alarm: {
|
|
219
|
-
error: 0.2
|
|
220
218
|
}
|
|
221
219
|
});
|
|
222
220
|
if (typeof window.gc !== 'function') {
|
|
@@ -286,13 +284,13 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
|
|
|
286
284
|
if (!isWarmup) {
|
|
287
285
|
for (const entry of timing.elementEntries) {
|
|
288
286
|
// Skip paints that happened while recording was paused. Attribute by the paint's
|
|
289
|
-
// `
|
|
290
|
-
if (!recording.activeAt(entry.
|
|
287
|
+
// `paintTime`, not by when the observer callback fired (which can lag the paint).
|
|
288
|
+
if (!recording.activeAt(entry.paintTime)) {
|
|
291
289
|
continue;
|
|
292
290
|
}
|
|
293
291
|
// The default sentinel is the base series; named markers become sub-series.
|
|
294
292
|
const id = entry.identifier === 'default' ? undefined : entry.identifier;
|
|
295
|
-
paint.record(entry.
|
|
293
|
+
paint.record(entry.paintTime - iterationStart, id !== undefined ? {
|
|
296
294
|
id
|
|
297
295
|
} : undefined);
|
|
298
296
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mui/internal-benchmark",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1-canary.1",
|
|
4
4
|
"author": "MUI Team",
|
|
5
5
|
"description": "Benchmark utilities for MUI projects. Internal package.",
|
|
6
6
|
"repository": {
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
"directory": "packages/benchmark"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@babel/runtime": "^
|
|
13
|
-
"@vitejs/plugin-react": "^6.
|
|
12
|
+
"@babel/runtime": "^8.0.5",
|
|
13
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
14
|
+
"chalk": "^5.6.2",
|
|
14
15
|
"env-ci": "^11.2.0",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
16
|
+
"es-toolkit": "^1.52.0",
|
|
17
|
+
"execa": "^10.0.1",
|
|
18
|
+
"zod": "^4.6.5"
|
|
17
19
|
},
|
|
18
20
|
"peerDependencies": {
|
|
19
21
|
"@vitest/browser-playwright": ">=4.1",
|
|
@@ -68,5 +70,5 @@
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
},
|
|
71
|
-
"gitSha": "
|
|
73
|
+
"gitSha": "c2a0ca83b8dd00ae2fa2b5714dbc6684d50926d9"
|
|
72
74
|
}
|
package/reactRecording.d.mts
CHANGED
|
@@ -5,7 +5,7 @@ export interface ReactRecordingControls {
|
|
|
5
5
|
readonly hadEmptyActiveWindow: boolean;
|
|
6
6
|
/**
|
|
7
7
|
* Whether recording was active at `time` (a `performance.now()` timestamp). Paint entries are
|
|
8
|
-
* observed asynchronously, so they are attributed by their `
|
|
8
|
+
* observed asynchronously, so they are attributed by their `paintTime` rather than by the
|
|
9
9
|
* recording state at the moment the observer callback happens to fire.
|
|
10
10
|
*/
|
|
11
11
|
activeAt(time: number): boolean;
|
package/reporter.mjs
CHANGED
|
@@ -240,6 +240,9 @@ async function loadBaselineReport(baselinePath) {
|
|
|
240
240
|
class BenchmarkReporter {
|
|
241
241
|
benchmarks = {};
|
|
242
242
|
metricDefinitions = {};
|
|
243
|
+
outputPath;
|
|
244
|
+
upload;
|
|
245
|
+
baselinePath;
|
|
243
246
|
hasFailures = false;
|
|
244
247
|
constructor(options) {
|
|
245
248
|
this.outputPath = options?.outputPath ?? process.env.BENCHMARK_OUTPUT_PATH ?? path.resolve(process.cwd(), 'benchmarks', 'results.json');
|
package/syncPrComment.d.mts
CHANGED
|
@@ -2,8 +2,6 @@ interface SyncPrCommentResult {
|
|
|
2
2
|
success: boolean;
|
|
3
3
|
skipped?: boolean;
|
|
4
4
|
}
|
|
5
|
-
/**
|
|
6
|
-
* Syncs a PR comment via the dashboard API.
|
|
7
|
-
*/
|
|
5
|
+
/** Asks the dashboard to regenerate the pull request comment for `repo`. */
|
|
8
6
|
export declare function syncPrComment(repo: string): Promise<SyncPrCommentResult>;
|
|
9
7
|
export {};
|
package/syncPrComment.mjs
CHANGED
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
* Syncs a PR comment via the dashboard API.
|
|
4
|
-
*/
|
|
1
|
+
import { postToDashboard } from "./ciApi.mjs";
|
|
2
|
+
/** Asks the dashboard to regenerate the pull request comment for `repo`. */
|
|
5
3
|
export async function syncPrComment(repo) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const apiUrl = process.env.CI_REPORT_API_URL || DEFAULT_API_URL;
|
|
11
|
-
const url = new URL('/api/ci-reports/sync-pr-comment', apiUrl);
|
|
12
|
-
const response = await fetch(url, {
|
|
13
|
-
method: 'POST',
|
|
14
|
-
headers: {
|
|
15
|
-
'Content-Type': 'application/json',
|
|
16
|
-
Authorization: `Bearer ${oidcToken}`
|
|
17
|
-
},
|
|
18
|
-
body: JSON.stringify({
|
|
19
|
-
repo
|
|
20
|
-
})
|
|
21
|
-
});
|
|
22
|
-
if (!response.ok) {
|
|
23
|
-
const responseText = await response.text();
|
|
24
|
-
throw new Error(`Sync PR comment API returned ${response.status} ${response.statusText}: ${responseText}`);
|
|
25
|
-
}
|
|
26
|
-
return response.json();
|
|
4
|
+
const responseText = await postToDashboard('/api/ci-reports/sync-pr-comment', {
|
|
5
|
+
repo
|
|
6
|
+
}, 'The pull request comment sync');
|
|
7
|
+
return JSON.parse(responseText);
|
|
27
8
|
}
|
package/upload.d.mts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { BenchmarkUpload } from "./ciReport.mjs";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Uploads a benchmark report to the CI report store.
|
|
4
|
+
*
|
|
5
|
+
* Validated before sending: the server stores `report` without inspecting it, so a malformed one
|
|
6
|
+
* would be accepted here and only fail later, when the dashboard tried to render it.
|
|
7
|
+
*/
|
|
8
|
+
declare function uploadCiReport(report: BenchmarkUpload): Promise<void>;
|
|
5
9
|
export { uploadCiReport };
|
package/upload.mjs
CHANGED
|
@@ -1,34 +1,17 @@
|
|
|
1
|
+
/* eslint-disable no-console -- progress belongs in the CI log. */
|
|
2
|
+
|
|
3
|
+
import { postToDashboard } from "./ciApi.mjs";
|
|
1
4
|
import { benchmarkUploadSchema } from "./ciReport.mjs";
|
|
2
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Uploads a benchmark report to the CI report store.
|
|
7
|
+
*
|
|
8
|
+
* Validated before sending: the server stores `report` without inspecting it, so a malformed one
|
|
9
|
+
* would be accepted here and only fail later, when the dashboard tried to render it.
|
|
10
|
+
*/
|
|
11
|
+
async function uploadCiReport(report) {
|
|
3
12
|
benchmarkUploadSchema.parse(report);
|
|
4
|
-
const
|
|
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
|
-
}
|
|
13
|
+
const responseText = await postToDashboard('/api/ci-reports/upload', report, 'The benchmark upload');
|
|
30
14
|
const result = JSON.parse(responseText);
|
|
31
|
-
// eslint-disable-next-line no-console
|
|
32
15
|
console.log(`Benchmark results uploaded successfully. S3 key: ${result.key}`);
|
|
33
16
|
}
|
|
34
17
|
export { uploadCiReport };
|
package/utils/git.d.mts
ADDED
package/utils/git.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
|
|
3
|
+
/** Resolves a committish to the commit it names, peeling an annotated tag to its commit. */
|
|
4
|
+
export async function resolveCommit(repoRoot, ref) {
|
|
5
|
+
const result = await execa('git', ['rev-parse', '--verify', `${ref}^{commit}`], {
|
|
6
|
+
cwd: repoRoot,
|
|
7
|
+
reject: false
|
|
8
|
+
});
|
|
9
|
+
if (result.exitCode !== 0) {
|
|
10
|
+
throw new Error(`Could not resolve git ref "${ref}": ${String(result.stderr).trim()}`);
|
|
11
|
+
}
|
|
12
|
+
return result.stdout.trim();
|
|
13
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface PackedPackage {
|
|
2
|
+
/** Package name from its package.json. */
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
/** Absolute path to the packed `.tgz`. */
|
|
6
|
+
tarball: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PackRefOptions {
|
|
9
|
+
/** The repository the ref lives in; also where the worktree is added from. */
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
/** A git ref (SHA, branch, or tag) to pack. */
|
|
12
|
+
ref: string;
|
|
13
|
+
/** Cache root. The packed folder lands at `<outRoot>/<sha>` and is reused on the next call. */
|
|
14
|
+
outRoot: string;
|
|
15
|
+
/**
|
|
16
|
+
* Command run in the checkout to install before building. Defaults to a frozen-lockfile
|
|
17
|
+
* `pnpm install`; pass `''` to skip installing.
|
|
18
|
+
*/
|
|
19
|
+
installCmd?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Command run in the checkout to build before packing. Defaults to `pnpm release:build`. Part of
|
|
22
|
+
* the cache key.
|
|
23
|
+
*/
|
|
24
|
+
buildCmd?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Packs every public workspace package at `ref` into `<outRoot>/<sha>`, returning the packed
|
|
28
|
+
* tarballs.
|
|
29
|
+
*
|
|
30
|
+
* The folder is cached by SHA: a later call for the same commit, built with the same `buildCmd`,
|
|
31
|
+
* reuses it and skips the checkout, install and build. It is assembled in a staging folder and
|
|
32
|
+
* renamed into place, so an interrupted run never leaves something that looks like a cache.
|
|
33
|
+
*/
|
|
34
|
+
export declare function packRef(options: PackRefOptions): Promise<PackedPackage[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Builds and packs the working tree into `outRoot`, naming each tarball by a hash of its bytes: an
|
|
37
|
+
* unchanged build keeps its path, so an install that depends on it has nothing to redo.
|
|
38
|
+
*/
|
|
39
|
+
export declare function packWorkingTree(options: {
|
|
40
|
+
/** The workspace to build and pack. */
|
|
41
|
+
repoRoot: string;
|
|
42
|
+
/** Directory to hold the hashed tarballs. Replaced on each call. */
|
|
43
|
+
outRoot: string;
|
|
44
|
+
/** Command that builds the publishable packages. Defaults to `pnpm release:build`. */
|
|
45
|
+
buildCmd?: string;
|
|
46
|
+
}): Promise<PackedPackage[]>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
|
|
3
|
+
// Packs a workspace's public packages with `pnpm pack`, so each tarball is exactly what
|
|
4
|
+
// `pnpm publish` would upload — for installing a build the way a consumer installs a release.
|
|
5
|
+
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as os from 'node:os';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createReadStream } from 'node:fs';
|
|
10
|
+
import { access, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { mapAsync } from 'es-toolkit/array';
|
|
14
|
+
import { execa, parseCommandString } from 'execa';
|
|
15
|
+
import { resolveCommit } from "./git.mjs";
|
|
16
|
+
import { listPublishablePackages } from "./pnpm.mjs";
|
|
17
|
+
const MANIFEST = 'manifest.json';
|
|
18
|
+
|
|
19
|
+
/** `@scope/pkg` → `scope+pkg.tgz`; `+` cannot appear in a package name, so no two collide. */
|
|
20
|
+
function tarballName(pkgName) {
|
|
21
|
+
return `${pkgName.replace(/^@/, '').replace('/', '+')}.tgz`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Removes a temporary checkout, and git's record of it. */
|
|
25
|
+
async function removeCheckout(repoRoot, checkout) {
|
|
26
|
+
await rm(checkout, {
|
|
27
|
+
recursive: true,
|
|
28
|
+
force: true,
|
|
29
|
+
maxRetries: 5,
|
|
30
|
+
retryDelay: 200
|
|
31
|
+
});
|
|
32
|
+
await execa('git', ['worktree', 'prune'], {
|
|
33
|
+
cwd: repoRoot
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Runs `buildCmd` in `cwd`, with the nx daemon off so nothing keeps writing after it returns. */
|
|
38
|
+
async function build(cwd, buildCmd) {
|
|
39
|
+
const [file, ...args] = parseCommandString(buildCmd);
|
|
40
|
+
await execa(file, args, {
|
|
41
|
+
cwd,
|
|
42
|
+
env: {
|
|
43
|
+
NX_DAEMON: 'false'
|
|
44
|
+
},
|
|
45
|
+
stdio: 'inherit',
|
|
46
|
+
verbose: 'short'
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Content hash of a file, short enough to live in a filename. */
|
|
51
|
+
async function hashFile(file) {
|
|
52
|
+
const hash = createHash('sha256');
|
|
53
|
+
await pipeline(createReadStream(file), hash);
|
|
54
|
+
return hash.digest('hex').slice(0, 12);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `dir`'s packages if it holds a cache of the requested build — built with the same `buildCmd`,
|
|
59
|
+
* every tarball still present — or `null` otherwise.
|
|
60
|
+
*/
|
|
61
|
+
async function readFreshCache(dir, buildCmd) {
|
|
62
|
+
let manifest;
|
|
63
|
+
try {
|
|
64
|
+
manifest = JSON.parse(await readFile(path.join(dir, MANIFEST), 'utf8'));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT') {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (manifest.buildCmd !== buildCmd) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const packages = manifest.packages.map(pkg => ({
|
|
75
|
+
...pkg,
|
|
76
|
+
tarball: path.join(dir, pkg.tarball)
|
|
77
|
+
}));
|
|
78
|
+
const present = await Promise.allSettled(packages.map(pkg => access(pkg.tarball)));
|
|
79
|
+
return present.every(result => result.status === 'fulfilled') ? packages : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Packs the already-built public workspace packages of `workspaceDir` into `outDir`.
|
|
84
|
+
*
|
|
85
|
+
* A public package that depends on a private workspace package cannot be installed from these —
|
|
86
|
+
* the private one is never packed — which is a packaging bug in the ref, not something to hide here.
|
|
87
|
+
*/
|
|
88
|
+
async function packBuiltPackages(workspaceDir, outDir) {
|
|
89
|
+
await mkdir(outDir, {
|
|
90
|
+
recursive: true
|
|
91
|
+
});
|
|
92
|
+
const packages = await listPublishablePackages(workspaceDir);
|
|
93
|
+
if (packages.length === 0) {
|
|
94
|
+
throw new Error(`No public workspace packages found in ${workspaceDir}.`);
|
|
95
|
+
}
|
|
96
|
+
// Each `pnpm pack` is a node process of its own.
|
|
97
|
+
return mapAsync(packages, async ({
|
|
98
|
+
path: pkgDir,
|
|
99
|
+
name,
|
|
100
|
+
version
|
|
101
|
+
}) => {
|
|
102
|
+
const tarball = path.join(outDir, tarballName(name));
|
|
103
|
+
await execa('pnpm', ['pack', '--out', tarball], {
|
|
104
|
+
cwd: pkgDir,
|
|
105
|
+
verbose: 'short'
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
version,
|
|
110
|
+
tarball
|
|
111
|
+
};
|
|
112
|
+
}, {
|
|
113
|
+
concurrency: os.availableParallelism()
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Packs every public workspace package at `ref` into `<outRoot>/<sha>`, returning the packed
|
|
119
|
+
* tarballs.
|
|
120
|
+
*
|
|
121
|
+
* The folder is cached by SHA: a later call for the same commit, built with the same `buildCmd`,
|
|
122
|
+
* reuses it and skips the checkout, install and build. It is assembled in a staging folder and
|
|
123
|
+
* renamed into place, so an interrupted run never leaves something that looks like a cache.
|
|
124
|
+
*/
|
|
125
|
+
export async function packRef(options) {
|
|
126
|
+
const {
|
|
127
|
+
repoRoot,
|
|
128
|
+
ref,
|
|
129
|
+
outRoot,
|
|
130
|
+
installCmd = 'pnpm install --frozen-lockfile --prefer-offline --config.engine-strict=false',
|
|
131
|
+
buildCmd = 'pnpm release:build'
|
|
132
|
+
} = options;
|
|
133
|
+
const sha = await resolveCommit(repoRoot, ref);
|
|
134
|
+
const dir = path.join(outRoot, sha);
|
|
135
|
+
const cached = await readFreshCache(dir, buildCmd);
|
|
136
|
+
if (cached) {
|
|
137
|
+
console.log(chalk.green(`\nReusing packed workspace for "${ref}" (${sha.slice(0, 9)}).`));
|
|
138
|
+
return cached;
|
|
139
|
+
}
|
|
140
|
+
await mkdir(outRoot, {
|
|
141
|
+
recursive: true
|
|
142
|
+
});
|
|
143
|
+
const staging = await mkdtemp(path.join(outRoot, '.staging-'));
|
|
144
|
+
const checkout = await mkdtemp(path.join(os.tmpdir(), 'pack-workspace-'));
|
|
145
|
+
try {
|
|
146
|
+
console.log(chalk.cyan(`\nChecking out "${ref}" (${sha.slice(0, 9)}) at ${checkout}`));
|
|
147
|
+
await execa('git', ['worktree', 'add', '--detach', checkout, sha], {
|
|
148
|
+
cwd: repoRoot,
|
|
149
|
+
verbose: 'short'
|
|
150
|
+
});
|
|
151
|
+
if (installCmd) {
|
|
152
|
+
console.log(chalk.cyan(`\nInstalling dependencies for ${sha.slice(0, 9)}…`));
|
|
153
|
+
const [installFile, ...installArgs] = parseCommandString(installCmd);
|
|
154
|
+
await execa(installFile, installArgs, {
|
|
155
|
+
cwd: checkout,
|
|
156
|
+
stdio: 'inherit',
|
|
157
|
+
verbose: 'short'
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
console.log(chalk.cyan(`\nBuilding packages for ${sha.slice(0, 9)}…`));
|
|
161
|
+
await build(checkout, buildCmd);
|
|
162
|
+
const packages = await packBuiltPackages(checkout, staging);
|
|
163
|
+
const manifest = {
|
|
164
|
+
buildCmd,
|
|
165
|
+
packages: packages.map(pkg => ({
|
|
166
|
+
...pkg,
|
|
167
|
+
tarball: path.basename(pkg.tarball)
|
|
168
|
+
}))
|
|
169
|
+
};
|
|
170
|
+
// Written last: it is what marks the folder complete.
|
|
171
|
+
await writeFile(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
172
|
+
await rm(dir, {
|
|
173
|
+
recursive: true,
|
|
174
|
+
force: true
|
|
175
|
+
});
|
|
176
|
+
await rename(staging, dir);
|
|
177
|
+
return packages.map(pkg => ({
|
|
178
|
+
...pkg,
|
|
179
|
+
tarball: path.join(dir, path.basename(pkg.tarball))
|
|
180
|
+
}));
|
|
181
|
+
} finally {
|
|
182
|
+
// On success the staging folder has already become `dir`.
|
|
183
|
+
await Promise.all([rm(staging, {
|
|
184
|
+
recursive: true,
|
|
185
|
+
force: true
|
|
186
|
+
}), removeCheckout(repoRoot, checkout)]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Builds and packs the working tree into `outRoot`, naming each tarball by a hash of its bytes: an
|
|
192
|
+
* unchanged build keeps its path, so an install that depends on it has nothing to redo.
|
|
193
|
+
*/
|
|
194
|
+
export async function packWorkingTree(options) {
|
|
195
|
+
const {
|
|
196
|
+
repoRoot,
|
|
197
|
+
outRoot,
|
|
198
|
+
buildCmd = 'pnpm release:build'
|
|
199
|
+
} = options;
|
|
200
|
+
console.log(chalk.cyan(`\nBuilding workspace packages for "working tree" (${buildCmd})…`));
|
|
201
|
+
await build(repoRoot, buildCmd);
|
|
202
|
+
console.log(chalk.cyan('\nPacking the working tree…'));
|
|
203
|
+
await rm(outRoot, {
|
|
204
|
+
recursive: true,
|
|
205
|
+
force: true
|
|
206
|
+
});
|
|
207
|
+
const packed = await packBuiltPackages(repoRoot, outRoot);
|
|
208
|
+
return Promise.all(packed.map(async pkg => {
|
|
209
|
+
const hash = await hashFile(pkg.tarball);
|
|
210
|
+
const tarball = path.join(outRoot, `${path.basename(pkg.tarball, '.tgz')}-${hash}.tgz`);
|
|
211
|
+
await rename(pkg.tarball, tarball);
|
|
212
|
+
return {
|
|
213
|
+
...pkg,
|
|
214
|
+
tarball
|
|
215
|
+
};
|
|
216
|
+
}));
|
|
217
|
+
}
|
package/utils/pnpm.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A publishable workspace package, as `pnpm ls` reports it. */
|
|
2
|
+
export interface PublishablePackage {
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
/** Absolute path to the package directory. */
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The publishable workspace packages of `cwd`: those pnpm lists that are not private and have the
|
|
10
|
+
* name and version publishing requires.
|
|
11
|
+
*/
|
|
12
|
+
export declare function listPublishablePackages(cwd: string): Promise<PublishablePackage[]>;
|
package/utils/pnpm.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { $ } from 'execa';
|
|
2
|
+
|
|
3
|
+
/** A publishable workspace package, as `pnpm ls` reports it. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The publishable workspace packages of `cwd`: those pnpm lists that are not private and have the
|
|
7
|
+
* name and version publishing requires.
|
|
8
|
+
*/
|
|
9
|
+
export async function listPublishablePackages(cwd) {
|
|
10
|
+
const result = await $({
|
|
11
|
+
cwd
|
|
12
|
+
})`pnpm ls -r --json --depth -1`;
|
|
13
|
+
const listed = JSON.parse(result.stdout);
|
|
14
|
+
return listed.flatMap(pkg => pkg.private || !pkg.name || !pkg.version ? [] : [{
|
|
15
|
+
name: pkg.name,
|
|
16
|
+
version: pkg.version,
|
|
17
|
+
path: pkg.path
|
|
18
|
+
}]);
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { onTestFinished } from 'vitest';
|
|
5
|
+
|
|
6
|
+
/** Creates a temporary directory and removes it when the current test ends, even if it throws. */
|
|
7
|
+
export async function makeTempDir() {
|
|
8
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'benchmark-test-'));
|
|
9
|
+
onTestFinished(async () => {
|
|
10
|
+
await fs.rm(tmpDir, {
|
|
11
|
+
recursive: true,
|
|
12
|
+
force: true
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
return tmpDir;
|
|
16
|
+
}
|
package/vitest.mjs
CHANGED
|
@@ -34,6 +34,18 @@ function resolveViewport(option) {
|
|
|
34
34
|
// workload needs them.
|
|
35
35
|
const LAUNCH_ARGS = ['--js-flags=--expose-gc', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding'];
|
|
36
36
|
export function createBenchmarkVitestConfig(options) {
|
|
37
|
+
// Benchmarks have to measure production React, which is why the config below sets NODE_ENV to
|
|
38
|
+
// "production" for the code running in the browser. That alone isn't enough: Vite picks which
|
|
39
|
+
// JSX runtime to compile against by reading NODE_ENV from the Node process, where Vitest has set
|
|
40
|
+
// it to "test". Benchmarks then get development JSX calling into a production React build, which
|
|
41
|
+
// has no `jsxDEV` function, and every benchmark crashes on its first render with
|
|
42
|
+
// "_jsxDEV is not a function".
|
|
43
|
+
//
|
|
44
|
+
// Setting NODE_ENV here, while this config file is still being evaluated, means Vite reads
|
|
45
|
+
// "production" as well and the two agree. Vitest considers this the intended way to run tests
|
|
46
|
+
// against production React: https://github.com/vitest-dev/vitest/issues/11265
|
|
47
|
+
// eslint-disable-next-line mui/consistent-production-guard -- an assignment, not a guard
|
|
48
|
+
process.env.NODE_ENV = 'production';
|
|
37
49
|
const {
|
|
38
50
|
outputPath,
|
|
39
51
|
baselinePath,
|