main
ts 285 lines 7.8 KB
Raw
1 import * as babel from '@babel/core';
2 import puppeteer from 'puppeteer';
3 // @ts-ignore
4 import * as babelPresetTypescript from '@babel/preset-typescript';
5 // @ts-ignore
6 import * as babelPresetEnv from '@babel/preset-env';
7 // @ts-ignore
8 import * as babelPresetReact from '@babel/preset-react';
9
10 type PerformanceResults = {
11 renderTime: number[];
12 webVitals: {
13 cls: number[];
14 lcp: number[];
15 inp: number[];
16 fid: number[];
17 ttfb: number[];
18 };
19 reactProfiler: {
20 id: number[];
21 phase: number[];
22 actualDuration: number[];
23 baseDuration: number[];
24 startTime: number[];
25 commitTime: number[];
26 };
27 error: Error | null;
28 };
29
30 type EvaluationResults = {
31 renderTime: number | null;
32 webVitals: {
33 cls: number | null;
34 lcp: number | null;
35 inp: number | null;
36 fid: number | null;
37 ttfb: number | null;
38 };
39 reactProfiler: {
40 id: number | null;
41 phase: number | null;
42 actualDuration: number | null;
43 baseDuration: number | null;
44 startTime: number | null;
45 commitTime: number | null;
46 };
47 error: Error | null;
48 };
49
50 function delay(time: number) {
51 return new Promise(function (resolve) {
52 setTimeout(resolve, time);
53 });
54 }
55
56 export async function measurePerformance(
57 code: string,
58 iterations: number,
59 ): Promise<PerformanceResults> {
60 const babelOptions: babel.TransformOptions = {
61 filename: 'anonymous.tsx',
62 configFile: false,
63 babelrc: false,
64 presets: [babelPresetTypescript, babelPresetEnv, babelPresetReact],
65 };
66
67 const parsed = await babel.parseAsync(code, babelOptions);
68 if (!parsed) {
69 throw new Error('Failed to parse code');
70 }
71
72 const transformResult = await babel.transformFromAstAsync(parsed, undefined, {
73 ...babelOptions,
74 plugins: [
75 () => ({
76 visitor: {
77 ImportDeclaration(
78 path: babel.NodePath<babel.types.ImportDeclaration>,
79 ) {
80 const value = path.node.source.value;
81 if (value === 'react' || value === 'react-dom') {
82 path.remove();
83 }
84 },
85 },
86 }),
87 ],
88 });
89
90 const transpiled = transformResult?.code || undefined;
91 if (!transpiled) {
92 throw new Error('Failed to transpile code');
93 }
94
95 const browser = await puppeteer.launch();
96 const page = await browser.newPage();
97 await page.setViewport({width: 1280, height: 720});
98 const html = buildHtml(transpiled);
99
100 let performanceResults: PerformanceResults = {
101 renderTime: [],
102 webVitals: {
103 cls: [],
104 lcp: [],
105 inp: [],
106 fid: [],
107 ttfb: [],
108 },
109 reactProfiler: {
110 id: [],
111 phase: [],
112 actualDuration: [],
113 baseDuration: [],
114 startTime: [],
115 commitTime: [],
116 },
117 error: null,
118 };
119
120 for (let ii = 0; ii < iterations; ii++) {
121 await page.setContent(html, {waitUntil: 'networkidle0'});
122 await page.waitForFunction(
123 'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
124 );
125
126 // ui chaos monkey
127 const selectors = await page.evaluate(() => {
128 window.__INTERACTABLE_SELECTORS__ = [];
129 const elements = Array.from(document.querySelectorAll('a')).concat(
130 Array.from(document.querySelectorAll('button')),
131 );
132 for (const el of elements) {
133 window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
134 }
135 return window.__INTERACTABLE_SELECTORS__;
136 });
137
138 await Promise.all(
139 selectors.map(async (selector: string) => {
140 try {
141 await page.click(selector);
142 } catch (e) {
143 console.log(`warning: Could not click ${selector}: ${e.message}`);
144 }
145 }),
146 );
147 await delay(500);
148
149 // Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
150 const tempPage = await browser.newPage();
151 await tempPage.evaluate(() => {
152 return new Promise(resolve => {
153 setTimeout(() => {
154 resolve(true);
155 }, 1000);
156 });
157 });
158 await tempPage.close();
159
160 const evaluationResult: EvaluationResults = await page.evaluate(() => {
161 return (window as any).__RESULT__;
162 });
163
164 if (evaluationResult.renderTime !== null) {
165 performanceResults.renderTime.push(evaluationResult.renderTime);
166 }
167
168 const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
169 for (const metric of webVitalMetrics) {
170 if (evaluationResult.webVitals[metric] !== null) {
171 performanceResults.webVitals[metric].push(
172 evaluationResult.webVitals[metric],
173 );
174 }
175 }
176
177 const profilerMetrics = [
178 'id',
179 'phase',
180 'actualDuration',
181 'baseDuration',
182 'startTime',
183 'commitTime',
184 ] as const;
185 for (const metric of profilerMetrics) {
186 if (evaluationResult.reactProfiler[metric] !== null) {
187 performanceResults.reactProfiler[metric].push(
188 evaluationResult.reactProfiler[metric],
189 );
190 }
191 }
192
193 performanceResults.error = evaluationResult.error;
194 }
195
196 await browser.close();
197
198 return performanceResults;
199 }
200
201 function buildHtml(transpiled: string) {
202 const html = `
203 <!DOCTYPE html>
204 <html>
205 <head>
206 <meta charset="UTF-8">
207 <title>React Performance Test</title>
208 <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
209 <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
210 <script src="https://unpkg.com/web-vitals@3.0.0/dist/web-vitals.iife.js"></script>
211 <style>
212 body { margin: 0; }
213 #root { padding: 20px; }
214 </style>
215 </head>
216 <body>
217 <div id="root"></div>
218 <script>
219 window.__RESULT__ = {
220 renderTime: null,
221 webVitals: {},
222 reactProfiler: {},
223 error: null,
224 };
225
226 webVitals.onCLS(({value}) => { window.__RESULT__.webVitals.cls = value; });
227 webVitals.onLCP(({value}) => { window.__RESULT__.webVitals.lcp = value; });
228 webVitals.onINP(({value}) => { window.__RESULT__.webVitals.inp = value; });
229 webVitals.onFID(({value}) => { window.__RESULT__.webVitals.fid = value; });
230 webVitals.onTTFB(({value}) => { window.__RESULT__.webVitals.ttfb = value; });
231
232 try {
233 ${transpiled}
234
235 window.App = App;
236
237 // Render the component to the DOM with profiling
238 const AppComponent = window.App || (() => React.createElement('div', null, 'No App component exported'));
239
240 const root = ReactDOM.createRoot(document.getElementById('root'), {
241 onUncaughtError: (error, errorInfo) => {
242 window.__RESULT__.error = error;
243 }
244 });
245
246 const renderStart = performance.now()
247
248 root.render(
249 React.createElement(React.Profiler, {
250 id: 'App',
251 onRender: (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
252 window.__RESULT__.reactProfiler.id = id;
253 window.__RESULT__.reactProfiler.phase = phase;
254 window.__RESULT__.reactProfiler.actualDuration = actualDuration;
255 window.__RESULT__.reactProfiler.baseDuration = baseDuration;
256 window.__RESULT__.reactProfiler.startTime = startTime;
257 window.__RESULT__.reactProfiler.commitTime = commitTime;
258 }
259 }, React.createElement(AppComponent))
260 );
261
262 const renderEnd = performance.now();
263
264 window.__RESULT__.renderTime = renderEnd - renderStart;
265 } catch (error) {
266 console.error('Error rendering component:', error);
267 window.__RESULT__.error = error;
268 }
269 </script>
270 <script>
271 window.onerror = function(message, url, lineNumber) {
272 const formattedMessage = message + '@' + lineNumber;
273 if (window.__RESULT__.error && window.__RESULT__.error.message != null) {
274 window.__RESULT__.error = window.__RESULT__.error + '\n\n' + formattedMessage;
275 } else {
276 window.__RESULT__.error = message + formattedMessage;
277 }
278 };
279 </script>
280 </body>
281 </html>
282 `;
283
284 return html;
285 }