react-doctor-cli-dev 1.0.0
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/backend/.env +3 -0
- package/backend/dist/index.js +43 -0
- package/backend/dist/middleware/auth.js +16 -0
- package/backend/dist/routes/reports.js +93 -0
- package/backend/package-lock.json +2000 -0
- package/backend/package.json +30 -0
- package/backend/src/db.ts +24 -0
- package/backend/src/index.ts +49 -0
- package/backend/src/middleware/auth.ts +21 -0
- package/backend/src/routes/reports.ts +110 -0
- package/backend/tsconfig.json +12 -0
- package/cli/bin/react-doctor.js +29 -0
- package/cli/dist/commands/analyze.js +125 -0
- package/cli/dist/commands/full.js +366 -0
- package/cli/dist/commands/install.js +138 -0
- package/cli/dist/commands/profile.js +166 -0
- package/cli/dist/index.js +78 -0
- package/cli/dist/ui.js +113 -0
- package/cli/package-lock.json +936 -0
- package/cli/package.json +34 -0
- package/cli/src/commands/analyze.ts +162 -0
- package/cli/src/commands/full.ts +574 -0
- package/cli/src/commands/install.ts +163 -0
- package/cli/src/commands/profile.ts +246 -0
- package/cli/src/index.ts +84 -0
- package/cli/src/ui.ts +120 -0
- package/cli/tsconfig.json +16 -0
- package/core/report-compiler/index.ts +359 -0
- package/core/report-compiler/test-report-compiler.ts +126 -0
- package/core/rule-engine/context-builder.ts +146 -0
- package/core/rule-engine/evaluator.ts +131 -0
- package/core/rule-engine/index.ts +222 -0
- package/core/rule-engine/rules.json +304 -0
- package/core/rule-engine/suggestion-builder.ts +209 -0
- package/core/rule-engine/test-rule-engine.ts +144 -0
- package/core/rule-engine/types.ts +202 -0
- package/core/runtime/profiler/browser.ts +121 -0
- package/core/runtime/profiler/collectors.ts +216 -0
- package/core/runtime/profiler/index.ts +311 -0
- package/core/runtime/profiler/porfiler.ts +967 -0
- package/core/runtime/profiler/route-scanner.ts +76 -0
- package/core/runtime/profiler/score.ts +59 -0
- package/core/runtime/profiler/server.ts +115 -0
- package/core/runtime/profiler/types.ts +65 -0
- package/core/runtime/test-runtime-profiler.ts +226 -0
- package/core/static-ana/static/analyzer.ts +145 -0
- package/core/static-ana/static/ast-parser.ts +31 -0
- package/core/static-ana/static/detectors/console-log.ts +49 -0
- package/core/static-ana/static/detectors/dead-code.ts +51 -0
- package/core/static-ana/static/detectors/effect-loop.ts +45 -0
- package/core/static-ana/static/detectors/index.ts +16 -0
- package/core/static-ana/static/detectors/inline-function.ts +59 -0
- package/core/static-ana/static/detectors/inline-style.ts +52 -0
- package/core/static-ana/static/detectors/large-component.ts +79 -0
- package/core/static-ana/static/detectors/missing-key.ts +56 -0
- package/core/static-ana/static/detectors/missing-memo.ts +59 -0
- package/core/static-ana/static/detectors/prop-drilling.ts +66 -0
- package/core/static-ana/static/helpers.ts +81 -0
- package/core/static-ana/static/scanner.ts +93 -0
- package/core/static-ana/test-analyzer.ts +115 -0
- package/core/static-ana/types.ts +25 -0
- package/core/tests/mock-react-project/src/app.tsx +22 -0
- package/core/tests/mock-react-project/src/components/Button.tsx +9 -0
- package/core/tests/mock-react-project/src/components/Header.tsx +3 -0
- package/core/tests/mock-react-project/src/components/ListTesting.tsx +51 -0
- package/core/tests/mock-react-project/src/components/UserDashboard.tsx +66 -0
- package/core/tests/mock-react-project/src/utils.ts +4 -0
- package/package.json +55 -0
- package/react-doctor-cli-dev-1.0.0.tgz +0 -0
- package/shared/dist/index.d.ts +2 -0
- package/shared/dist/index.js +19 -0
- package/shared/dist/schemas.d.ts +91 -0
- package/shared/dist/schemas.js +82 -0
- package/shared/dist/types.d.ts +44 -0
- package/shared/dist/types.js +2 -0
- package/shared/package-lock.json +47 -0
- package/shared/package.json +21 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/schemas.ts +136 -0
- package/shared/src/types.ts +137 -0
- package/shared/tsconfig.json +15 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Page } from "puppeteer-core";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import {
|
|
5
|
+
WebVitals,
|
|
6
|
+
PageError,
|
|
7
|
+
Screenshot,
|
|
8
|
+
} from "../../../shared/src/types";
|
|
9
|
+
import { ReactProfilerData, DeviceType } from "./types";
|
|
10
|
+
|
|
11
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
12
|
+
// WEB VITALS
|
|
13
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
14
|
+
|
|
15
|
+
export async function collectWebVitals(
|
|
16
|
+
page: Page,
|
|
17
|
+
webVitalsCode: string,
|
|
18
|
+
): Promise<WebVitals> {
|
|
19
|
+
await page.addScriptTag({ content: webVitalsCode });
|
|
20
|
+
|
|
21
|
+
return (await page.evaluate(() => {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const results: any = { lcp: 0, fcp: 0, cls: 0, inp: 0, ttfb: 0 };
|
|
24
|
+
let count = 0;
|
|
25
|
+
let resolved = false;
|
|
26
|
+
|
|
27
|
+
const v = (globalThis as any).webVitals;
|
|
28
|
+
|
|
29
|
+
const finalize = () => {
|
|
30
|
+
if (results.lcp === 0) results.lcp = results.fcp;
|
|
31
|
+
return results;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const done = () => {
|
|
35
|
+
if (++count === 5 && !resolved) {
|
|
36
|
+
resolved = true;
|
|
37
|
+
resolve(finalize());
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
v.onLCP((m: any) => { results.lcp = m.value; done(); });
|
|
42
|
+
v.onFCP((m: any) => { results.fcp = m.value; done(); });
|
|
43
|
+
v.onCLS((m: any) => { results.cls = m.value; done(); });
|
|
44
|
+
v.onINP((m: any) => { results.inp = m.value; done(); });
|
|
45
|
+
v.onTTFB((m: any) => { results.ttfb = m.value; done(); });
|
|
46
|
+
|
|
47
|
+
// Fail-safe: resolve after 8s with whatever metrics we have
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
if (!resolved) { resolved = true; resolve(finalize()); }
|
|
50
|
+
}, 8000);
|
|
51
|
+
});
|
|
52
|
+
})) as WebVitals;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
56
|
+
// REACT PROFILER
|
|
57
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
58
|
+
|
|
59
|
+
export async function collectReactProfilerData(
|
|
60
|
+
page: Page,
|
|
61
|
+
renderTime: number,
|
|
62
|
+
): Promise<ReactProfilerData> {
|
|
63
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
64
|
+
|
|
65
|
+
const result = await page.evaluate((renderTimeMs: number) => {
|
|
66
|
+
const win = globalThis as any;
|
|
67
|
+
const data = win.__reactDoctorData__;
|
|
68
|
+
const hookExists = !!win.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
69
|
+
const hookSupportsFiber = win.__REACT_DEVTOOLS_GLOBAL_HOOK__?.supportsFiber ?? false;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
hookExists,
|
|
73
|
+
hookSupportsFiber,
|
|
74
|
+
dataExists: !!data,
|
|
75
|
+
rerenders: data?.rerenders ?? {},
|
|
76
|
+
commitDurations: data?.commitDurations ?? [],
|
|
77
|
+
renderTime: renderTimeMs,
|
|
78
|
+
};
|
|
79
|
+
}, renderTime);
|
|
80
|
+
|
|
81
|
+
console.log(
|
|
82
|
+
` โ๏ธ Hook exists: ${result.hookExists} | ` +
|
|
83
|
+
`supportsFiber: ${result.hookSupportsFiber} | ` +
|
|
84
|
+
`data captured: ${result.dataExists}`,
|
|
85
|
+
);
|
|
86
|
+
console.log(
|
|
87
|
+
` โ๏ธ Commits: ${result.commitDurations.length} | ` +
|
|
88
|
+
`Components: ${Object.keys(result.rerenders).length}`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
rerenders: result.rerenders,
|
|
93
|
+
commitDurations: result.commitDurations,
|
|
94
|
+
renderTime: result.renderTime,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
99
|
+
// RESOURCE USAGE
|
|
100
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
101
|
+
|
|
102
|
+
export async function collectResourceUsage(page: Page) {
|
|
103
|
+
return await page.evaluate(() => {
|
|
104
|
+
const entries = performance.getEntriesByType("resource");
|
|
105
|
+
const totalBytes = entries.reduce(
|
|
106
|
+
(acc, e: any) => acc + (e.transferSize || 0), 0,
|
|
107
|
+
);
|
|
108
|
+
const sorted = [...entries].sort(
|
|
109
|
+
(a: any, b: any) => (b.transferSize || 0) - (a.transferSize || 0),
|
|
110
|
+
);
|
|
111
|
+
const heaviest = sorted[0] as any;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
totalMB: totalBytes / 1024 / 1024,
|
|
115
|
+
topFile: heaviest
|
|
116
|
+
? {
|
|
117
|
+
name: heaviest.name.split("/").pop() || "unknown",
|
|
118
|
+
size: (heaviest.transferSize || 0) / 1024 / 1024,
|
|
119
|
+
}
|
|
120
|
+
: null,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
126
|
+
// SCREENSHOTS
|
|
127
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
128
|
+
|
|
129
|
+
export async function captureScreenshots(
|
|
130
|
+
page: Page,
|
|
131
|
+
url: string,
|
|
132
|
+
device: DeviceType,
|
|
133
|
+
renderTime: number,
|
|
134
|
+
screenshotDir: string,
|
|
135
|
+
): Promise<Screenshot[]> {
|
|
136
|
+
const screenshots: Screenshot[] = [];
|
|
137
|
+
|
|
138
|
+
const timings = await page.evaluate(() => {
|
|
139
|
+
const fcpEntry = performance.getEntriesByName("first-contentful-paint")[0];
|
|
140
|
+
const lcpEntries = (performance as any).getEntriesByType("largest-contentful-paint");
|
|
141
|
+
return {
|
|
142
|
+
fcp: fcpEntry?.startTime ?? 0,
|
|
143
|
+
lcp: lcpEntries.length > 0
|
|
144
|
+
? lcpEntries[lcpEntries.length - 1].startTime
|
|
145
|
+
: 0,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const urlSafe = url
|
|
150
|
+
.replace(/https?:\/\//, "")
|
|
151
|
+
.replace(/[/:?#]/g, "-")
|
|
152
|
+
.replace(/-+/g, "-")
|
|
153
|
+
.replace(/^-|-$/g, "");
|
|
154
|
+
|
|
155
|
+
const timestamp = Date.now();
|
|
156
|
+
const fullLoadBuffer = await page.screenshot({ type: "png", fullPage: false });
|
|
157
|
+
|
|
158
|
+
// โโ Safe Buffer conversion โ works on both Windows and Linux
|
|
159
|
+
// Puppeteer can return Buffer or Uint8Array depending on version/platform.
|
|
160
|
+
// Converting through Uint8Array first guarantees it works either way.
|
|
161
|
+
const bufferSafe = Buffer.isBuffer(fullLoadBuffer)
|
|
162
|
+
? fullLoadBuffer
|
|
163
|
+
: Buffer.from(fullLoadBuffer as Uint8Array);
|
|
164
|
+
|
|
165
|
+
const fullLoadBase64 = `data:image/png;base64,${bufferSafe.toString("base64")}`;
|
|
166
|
+
const fullLoadFilename = `${urlSafe}-${device}-fullLoad-${timestamp}.png`;
|
|
167
|
+
|
|
168
|
+
await fs.writeFile(path.join(screenshotDir, fullLoadFilename), bufferSafe);
|
|
169
|
+
console.log(` ๐ธ Screenshot saved: ${fullLoadFilename}`);
|
|
170
|
+
|
|
171
|
+
screenshots.push({ label: "fullLoad", dataUrl: fullLoadBase64, takenAt: renderTime });
|
|
172
|
+
|
|
173
|
+
if (timings.fcp > 0) {
|
|
174
|
+
screenshots.push({ label: "fcp", dataUrl: fullLoadBase64, takenAt: Math.round(timings.fcp) });
|
|
175
|
+
}
|
|
176
|
+
if (timings.lcp > 0) {
|
|
177
|
+
screenshots.push({ label: "lcp", dataUrl: fullLoadBase64, takenAt: Math.round(timings.lcp) });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return screenshots;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
184
|
+
// ERROR LISTENERS
|
|
185
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
186
|
+
|
|
187
|
+
export function attachErrorListeners(page: Page): PageError[] {
|
|
188
|
+
const errors: PageError[] = [];
|
|
189
|
+
|
|
190
|
+
const PROFILER_NOISE = [
|
|
191
|
+
"Deprecated API for given entry type",
|
|
192
|
+
"web-vitals",
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
page.on("pageerror", (err) => {
|
|
196
|
+
const error = err as Error;
|
|
197
|
+
errors.push({
|
|
198
|
+
type: "error",
|
|
199
|
+
message: error?.message ?? String(err),
|
|
200
|
+
source: "pageerror",
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
page.on("console", (msg) => {
|
|
205
|
+
const text = msg.text();
|
|
206
|
+
if (PROFILER_NOISE.some(noise => text.includes(noise))) return;
|
|
207
|
+
|
|
208
|
+
if (msg.type() === "error") {
|
|
209
|
+
errors.push({ type: "error", message: text, source: "console" });
|
|
210
|
+
} else if (msg.type() === "warn") {
|
|
211
|
+
errors.push({ type: "warning", message: text, source: "console" });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return errors;
|
|
216
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
2
|
+
// index.ts
|
|
3
|
+
// RuntimeProfiler โ the main class.
|
|
4
|
+
// Orchestrates all the other modules to run a full audit.
|
|
5
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
6
|
+
|
|
7
|
+
import puppeteer, { Browser } from "puppeteer-core";
|
|
8
|
+
import { ChildProcess } from "child_process";
|
|
9
|
+
import { RuntimeReport } from "../../../shared/src/types";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ProfileOptions,
|
|
16
|
+
DeviceType,
|
|
17
|
+
ThrottlePreset,
|
|
18
|
+
CpuThrottle,
|
|
19
|
+
NETWORK_PRESETS,
|
|
20
|
+
DEVICE_PRESETS,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { calculatePerformanceScore } from "./score";
|
|
23
|
+
import { RouteScanner } from "./route-scanner";
|
|
24
|
+
import { spawnDevServer, waitForServer, killDevServer } from "./server";
|
|
25
|
+
import { getBrowserPath, getWebVitalsScript } from "./browser";
|
|
26
|
+
import {
|
|
27
|
+
collectWebVitals,
|
|
28
|
+
collectReactProfilerData,
|
|
29
|
+
collectResourceUsage,
|
|
30
|
+
captureScreenshots,
|
|
31
|
+
attachErrorListeners,
|
|
32
|
+
} from "./collectors";
|
|
33
|
+
|
|
34
|
+
export { calculatePerformanceScore } from "./score";
|
|
35
|
+
export type { ProfileOptions, DeviceType, ThrottlePreset, CpuThrottle } from "./types";
|
|
36
|
+
|
|
37
|
+
export class RuntimeProfiler {
|
|
38
|
+
private projectPath: string;
|
|
39
|
+
private devServer?: ChildProcess;
|
|
40
|
+
private browser?: Browser;
|
|
41
|
+
private reportDir: string;
|
|
42
|
+
private screenshotDir: string;
|
|
43
|
+
|
|
44
|
+
constructor(projectPath: string, outputDir?: string) {
|
|
45
|
+
this.projectPath = path.resolve(projectPath);
|
|
46
|
+
this.reportDir = outputDir ?? path.resolve(__dirname, "..", "..", "reports");
|
|
47
|
+
this.screenshotDir = path.join(this.reportDir, "screenshots");
|
|
48
|
+
fs.ensureDirSync(this.reportDir);
|
|
49
|
+
fs.ensureDirSync(this.screenshotDir);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Main entry point. Runs the full profiling pipeline.
|
|
54
|
+
*
|
|
55
|
+
* Pass [] for manualRoutes to auto-discover routes via AST scanning.
|
|
56
|
+
* Pass a routes array like ["/", "/about"] to test only those pages.
|
|
57
|
+
*
|
|
58
|
+
* Flow:
|
|
59
|
+
* 1. spawnDevServer() โ boots the React app
|
|
60
|
+
* 2. RouteScanner โ finds routes from source code
|
|
61
|
+
* 3. For each device x route:
|
|
62
|
+
* a. attachErrorListeners() โ listen before navigation
|
|
63
|
+
* b. page.goto() โ load the page
|
|
64
|
+
* c. collectWebVitals() โ LCP, FCP, CLS, INP, TTFB
|
|
65
|
+
* d. collectReactProfiler() โ re-renders, commit durations
|
|
66
|
+
* e. captureScreenshots() โ PNG + base64 + FCP/LCP timestamps
|
|
67
|
+
* f. collectResourceUsage() โ heap, DOM, payload, top offender
|
|
68
|
+
* g. calculateScore() โ 0-100 weighted score
|
|
69
|
+
* 4. cleanup() โ kills browser + dev server
|
|
70
|
+
* 5. writeJson() โ saves runtimereport.json
|
|
71
|
+
*/
|
|
72
|
+
async profile(
|
|
73
|
+
manualRoutes: string[] = [],
|
|
74
|
+
options: ProfileOptions = {},
|
|
75
|
+
): Promise<Record<string, RuntimeReport>> {
|
|
76
|
+
const throttle = options.throttle ?? "none";
|
|
77
|
+
const cpuThrottle = options.cpuThrottle ?? 1;
|
|
78
|
+
|
|
79
|
+
const rawDevice = options.device ?? "desktop";
|
|
80
|
+
const devices: DeviceType[] = Array.isArray(rawDevice) ? rawDevice : [rawDevice];
|
|
81
|
+
const multiDevice = devices.length > 1;
|
|
82
|
+
|
|
83
|
+
console.log("๐ Starting React Doctor Analysis...");
|
|
84
|
+
console.log(` Devices: ${devices.join(", ")} | Network: ${throttle} | CPU: ${cpuThrottle}x`);
|
|
85
|
+
|
|
86
|
+
const masterReport: Record<string, RuntimeReport> = {};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
this.devServer = spawnDevServer(this.projectPath);
|
|
90
|
+
const port = await waitForServer(this.devServer);
|
|
91
|
+
const baseUrl = `http://localhost:${port}`;
|
|
92
|
+
|
|
93
|
+
let targetRoutes = manualRoutes;
|
|
94
|
+
if (targetRoutes.length === 0) {
|
|
95
|
+
console.log("๐ Smart Scanning source code for routes...");
|
|
96
|
+
targetRoutes = await RouteScanner.scanForRoutes(this.projectPath);
|
|
97
|
+
console.log(`๐ฏ Discovered ${targetRoutes.length} route(s): ${targetRoutes.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const device of devices) {
|
|
101
|
+
console.log(`\n๐ฑ Starting ${device.toUpperCase()} pass...`);
|
|
102
|
+
|
|
103
|
+
for (const route of targetRoutes) {
|
|
104
|
+
const url = `${baseUrl}${route}`;
|
|
105
|
+
console.log(`\n ๐ Auditing [${device}]: ${route}`);
|
|
106
|
+
|
|
107
|
+
const { vitals, reactData, stats, errors, screenshots } =
|
|
108
|
+
await this.collectMetrics(url, device, throttle, cpuThrottle);
|
|
109
|
+
|
|
110
|
+
const performanceScore = calculatePerformanceScore(
|
|
111
|
+
vitals, reactData.renderTime, reactData.commitDurations, errors,
|
|
112
|
+
);
|
|
113
|
+
console.log(` ๐ Score: ${performanceScore}/100`);
|
|
114
|
+
|
|
115
|
+
const key = multiDevice ? `${route}::${device}` : route;
|
|
116
|
+
|
|
117
|
+
masterReport[key] = {
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
url,
|
|
120
|
+
deviceType: device,
|
|
121
|
+
metrics: vitals,
|
|
122
|
+
rerenders: reactData.rerenders,
|
|
123
|
+
commitDurations: reactData.commitDurations,
|
|
124
|
+
renderTime: reactData.renderTime,
|
|
125
|
+
stats,
|
|
126
|
+
performanceScore,
|
|
127
|
+
errors,
|
|
128
|
+
screenshots,
|
|
129
|
+
cpuThrottling: cpuThrottle,
|
|
130
|
+
networkThrottle: throttle,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await this.cleanup();
|
|
136
|
+
|
|
137
|
+
const reportPath = path.join(this.reportDir, "runtimereport.json");
|
|
138
|
+
await fs.writeJson(reportPath, masterReport, { spaces: 2 });
|
|
139
|
+
console.log(`\n๐ Report saved to: ${reportPath}`);
|
|
140
|
+
|
|
141
|
+
return masterReport;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await this.cleanup();
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
149
|
+
// PRIVATE: single page audit
|
|
150
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
151
|
+
|
|
152
|
+
private async collectMetrics(
|
|
153
|
+
url: string,
|
|
154
|
+
device: DeviceType,
|
|
155
|
+
throttle: ThrottlePreset,
|
|
156
|
+
cpuThrottle: CpuThrottle,
|
|
157
|
+
) {
|
|
158
|
+
if (!this.browser) {
|
|
159
|
+
this.browser = await puppeteer.launch({
|
|
160
|
+
executablePath: getBrowserPath(),
|
|
161
|
+
headless: true,
|
|
162
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const page = await this.browser.newPage();
|
|
167
|
+
const devicePreset = DEVICE_PRESETS[device];
|
|
168
|
+
|
|
169
|
+
await page.setViewport({
|
|
170
|
+
...devicePreset.viewport,
|
|
171
|
+
hasTouch: devicePreset.hasTouch,
|
|
172
|
+
isMobile: devicePreset.isMobile,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (devicePreset.userAgent) {
|
|
176
|
+
await page.setUserAgent(devicePreset.userAgent);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// CDP session: cache clearing, network throttling, CPU throttling
|
|
180
|
+
const cdpClient = await page.createCDPSession();
|
|
181
|
+
await cdpClient.send("Network.clearBrowserCache");
|
|
182
|
+
|
|
183
|
+
if (NETWORK_PRESETS[throttle]) {
|
|
184
|
+
await cdpClient.send("Network.emulateNetworkConditions", {
|
|
185
|
+
offline: false,
|
|
186
|
+
...NETWORK_PRESETS[throttle],
|
|
187
|
+
});
|
|
188
|
+
console.log(` ๐ Network: ${throttle}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (cpuThrottle > 1) {
|
|
192
|
+
await cdpClient.send("Emulation.setCPUThrottlingRate", { rate: cpuThrottle });
|
|
193
|
+
console.log(` โ๏ธ CPU: ${cpuThrottle}x slowdown`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Attach error listeners BEFORE navigation so nothing is missed
|
|
197
|
+
const errors = attachErrorListeners(page);
|
|
198
|
+
|
|
199
|
+
// Inject the React DevTools hook BEFORE the page loads.
|
|
200
|
+
// evaluateOnNewDocument() runs before any page script executes,
|
|
201
|
+
// so React finds our hook already in place and uses it.
|
|
202
|
+
// Uses inject() as the trigger point โ fires when React first
|
|
203
|
+
// registers itself, guaranteed before the first commit.
|
|
204
|
+
await page.evaluateOnNewDocument(() => {
|
|
205
|
+
const win = (globalThis as any);
|
|
206
|
+
const rerenders: Record<string, number> = {};
|
|
207
|
+
const commitDurations: number[] = [];
|
|
208
|
+
|
|
209
|
+
function walkFiber(fiber: any): void {
|
|
210
|
+
if (!fiber) return;
|
|
211
|
+
const name: string =
|
|
212
|
+
fiber.type?.displayName ||
|
|
213
|
+
fiber.type?.name ||
|
|
214
|
+
(typeof fiber.type === "string" ? fiber.type : null);
|
|
215
|
+
if (name && /^[A-Z]/.test(name)) {
|
|
216
|
+
rerenders[name] = (rerenders[name] || 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
walkFiber(fiber.child);
|
|
219
|
+
walkFiber(fiber.sibling);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function patchHook(hook: any): void {
|
|
223
|
+
if (hook.__reactDoctorPatched__) return;
|
|
224
|
+
hook.__reactDoctorPatched__ = true;
|
|
225
|
+
const originalOnCommit = hook.onCommitFiberRoot;
|
|
226
|
+
hook.onCommitFiberRoot = (rendererID: any, fiberRoot: any) => {
|
|
227
|
+
if (originalOnCommit) originalOnCommit.call(hook, rendererID, fiberRoot);
|
|
228
|
+
try {
|
|
229
|
+
const rootFiber = fiberRoot.current;
|
|
230
|
+
if (rootFiber?.actualDuration != null) {
|
|
231
|
+
commitDurations.push(parseFloat(rootFiber.actualDuration.toFixed(2)));
|
|
232
|
+
}
|
|
233
|
+
walkFiber(rootFiber);
|
|
234
|
+
} catch (e) {}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
win.__reactDoctorData__ = { rerenders, commitDurations };
|
|
239
|
+
|
|
240
|
+
if (win.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
|
|
241
|
+
patchHook(win.__REACT_DEVTOOLS_GLOBAL_HOOK__);
|
|
242
|
+
} else {
|
|
243
|
+
win.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
244
|
+
isDisabled: false,
|
|
245
|
+
supportsFiber: true,
|
|
246
|
+
renderers: new Map(),
|
|
247
|
+
onScheduleFiberRoot: () => {},
|
|
248
|
+
onCommitFiberUnmount: () => {},
|
|
249
|
+
onCommitFiberRoot: () => {},
|
|
250
|
+
inject(renderer: any) {
|
|
251
|
+
patchHook(win.__REACT_DEVTOOLS_GLOBAL_HOOK__);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const navStart = Date.now();
|
|
259
|
+
await page.goto(url, { waitUntil: "networkidle0", timeout: 60000 });
|
|
260
|
+
const renderTime = Date.now() - navStart;
|
|
261
|
+
|
|
262
|
+
// Simulate a click so INP has a real interaction to measure
|
|
263
|
+
await page.mouse.click(
|
|
264
|
+
devicePreset.viewport.width / 2,
|
|
265
|
+
devicePreset.viewport.height / 2,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const perfMetrics = await page.metrics();
|
|
269
|
+
const webVitalsCode = getWebVitalsScript(this.projectPath, __dirname);
|
|
270
|
+
const vitals = await collectWebVitals(page, webVitalsCode);
|
|
271
|
+
const reactData = await collectReactProfilerData(page, renderTime);
|
|
272
|
+
const screenshots = await captureScreenshots(page, url, device, renderTime, this.screenshotDir);
|
|
273
|
+
const resources = await collectResourceUsage(page);
|
|
274
|
+
|
|
275
|
+
await page.close();
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
vitals,
|
|
279
|
+
reactData,
|
|
280
|
+
errors,
|
|
281
|
+
screenshots,
|
|
282
|
+
stats: {
|
|
283
|
+
domNodes: perfMetrics.Nodes ?? 0,
|
|
284
|
+
jsHeapMB: ((perfMetrics.JSHeapUsedSize ?? 0) / 1024 / 1024).toFixed(2),
|
|
285
|
+
payloadMB: resources.totalMB.toFixed(2),
|
|
286
|
+
topOffender: resources.topFile,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
} catch (err: unknown) {
|
|
290
|
+
await page.close();
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
296
|
+
// PRIVATE: cleanup
|
|
297
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
298
|
+
|
|
299
|
+
private async cleanup(): Promise<void> {
|
|
300
|
+
if (this.browser) {
|
|
301
|
+
await this.browser.close();
|
|
302
|
+
this.browser = undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (this.devServer) {
|
|
306
|
+
console.log("๐งน Cleaning up background processes...");
|
|
307
|
+
killDevServer(this.devServer);
|
|
308
|
+
this.devServer = undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|