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,967 @@
|
|
|
1
|
+
import puppeteer, { Browser, Page } from "puppeteer-core";
|
|
2
|
+
import { spawn, ChildProcess } from "child_process";
|
|
3
|
+
import {
|
|
4
|
+
RuntimeReport,
|
|
5
|
+
WebVitals,
|
|
6
|
+
SystemStats,
|
|
7
|
+
PageError,
|
|
8
|
+
Screenshot,
|
|
9
|
+
} from "../../../shared/src/types";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import * as parser from "@babel/parser";
|
|
14
|
+
import * as traverseLib from "@babel/traverse";
|
|
15
|
+
const traverse: Function = (traverseLib as any).default ?? (traverseLib as any);
|
|
16
|
+
|
|
17
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
18
|
+
// TYPES
|
|
19
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
20
|
+
|
|
21
|
+
export interface ReactProfilerData {
|
|
22
|
+
rerenders: Record<string, number>;
|
|
23
|
+
commitDurations: number[];
|
|
24
|
+
renderTime: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DeviceType = "desktop" | "mobile";
|
|
28
|
+
export type ThrottlePreset = "none" | "slow4g" | "3g";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CPU throttle rate passed to Chrome's Emulation.setCPUThrottlingRate.
|
|
32
|
+
* 1 = no throttling (real hardware speed)
|
|
33
|
+
* 4 = 4x slowdown (matches Lighthouse "mobile" preset โ mid-range Android)
|
|
34
|
+
* 6 = 6x slowdown (low-end device simulation)
|
|
35
|
+
*
|
|
36
|
+
* Unlike network throttling, CPU throttling works on localhost because
|
|
37
|
+
* it slows down JavaScript EXECUTION itself, not data transfer.
|
|
38
|
+
* A commit that takes 40ms at 1x will take ~160ms at 4x โ revealing
|
|
39
|
+
* real-world mobile performance problems that fast dev machines hide.
|
|
40
|
+
*/
|
|
41
|
+
export type CpuThrottle = 1 | 4 | 6;
|
|
42
|
+
|
|
43
|
+
export interface ProfileOptions {
|
|
44
|
+
// Single device or array for both in one run
|
|
45
|
+
device?: DeviceType | DeviceType[];
|
|
46
|
+
throttle?: ThrottlePreset;
|
|
47
|
+
// 1 = no throttle (default), 4 = Lighthouse mobile preset, 6 = low-end
|
|
48
|
+
cpuThrottle?: CpuThrottle;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
52
|
+
// NETWORK PRESETS (Chrome DevTools Protocol values)
|
|
53
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
54
|
+
const NETWORK_PRESETS = {
|
|
55
|
+
none: null,
|
|
56
|
+
slow4g: {
|
|
57
|
+
downloadThroughput: (9 * 1024 * 1024) / 8,
|
|
58
|
+
uploadThroughput: (750 * 1024) / 8,
|
|
59
|
+
latency: 170,
|
|
60
|
+
},
|
|
61
|
+
"3g": {
|
|
62
|
+
downloadThroughput: (1.5 * 1024 * 1024) / 8,
|
|
63
|
+
uploadThroughput: (750 * 1024) / 8,
|
|
64
|
+
latency: 300,
|
|
65
|
+
},
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
69
|
+
// DEVICE PRESETS
|
|
70
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
71
|
+
const DEVICE_PRESETS = {
|
|
72
|
+
desktop: {
|
|
73
|
+
viewport: { width: 1280, height: 720 },
|
|
74
|
+
userAgent: null,
|
|
75
|
+
hasTouch: false,
|
|
76
|
+
isMobile: false,
|
|
77
|
+
},
|
|
78
|
+
mobile: {
|
|
79
|
+
viewport: { width: 390, height: 844 },
|
|
80
|
+
userAgent:
|
|
81
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) " +
|
|
82
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 " +
|
|
83
|
+
"Mobile/15E148 Safari/604.1",
|
|
84
|
+
hasTouch: true,
|
|
85
|
+
isMobile: true,
|
|
86
|
+
},
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
90
|
+
// PERFORMANCE SCORE
|
|
91
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Calculates a 0โ100 performance score from all collected metrics.
|
|
95
|
+
*
|
|
96
|
+
* HOW IT WORKS:
|
|
97
|
+
* Each metric is normalized to a 0โ100 scale based on its good/poor
|
|
98
|
+
* thresholds. Then a weighted average is taken โ metrics that matter
|
|
99
|
+
* most to the user experience get higher weights.
|
|
100
|
+
*
|
|
101
|
+
* Weights (must sum to 1.0):
|
|
102
|
+
* LCP 0.30 โ biggest impact on perceived load speed
|
|
103
|
+
* Render time 0.20 โ total time to interactive
|
|
104
|
+
* FCP 0.15 โ first sign of life
|
|
105
|
+
* Commit avg 0.15 โ React rendering efficiency
|
|
106
|
+
* TTFB 0.10 โ server response speed
|
|
107
|
+
* CLS 0.05 โ visual stability
|
|
108
|
+
* INP 0.05 โ interaction responsiveness
|
|
109
|
+
*
|
|
110
|
+
* A score of 90โ100 = excellent, 70โ89 = good, 50โ69 = needs work,
|
|
111
|
+
* below 50 = poor.
|
|
112
|
+
*
|
|
113
|
+
* Errors and warnings penalize the score:
|
|
114
|
+
* Each JS error โ -5 points (capped at -20)
|
|
115
|
+
* Each warning โ -2 points (capped at -10)
|
|
116
|
+
*/
|
|
117
|
+
export function calculatePerformanceScore(
|
|
118
|
+
vitals: WebVitals,
|
|
119
|
+
renderTime: number,
|
|
120
|
+
commitDurations: number[],
|
|
121
|
+
errors: PageError[],
|
|
122
|
+
): number {
|
|
123
|
+
// Normalize a value to 0โ100 where lower is better
|
|
124
|
+
function normalize(value: number, good: number, poor: number): number {
|
|
125
|
+
if (value <= good) return 100;
|
|
126
|
+
if (value >= poor) return 0;
|
|
127
|
+
// Linear interpolation between good and poor
|
|
128
|
+
return Math.round(100 * (1 - (value - good) / (poor - good)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const avgCommit =
|
|
132
|
+
commitDurations.length > 0
|
|
133
|
+
? commitDurations.reduce((a, b) => a + b, 0) / commitDurations.length
|
|
134
|
+
: 0;
|
|
135
|
+
|
|
136
|
+
const scores = {
|
|
137
|
+
lcp: normalize(vitals.lcp, 2500, 4000) * 0.3,
|
|
138
|
+
renderTime: normalize(renderTime, 2000, 5000) * 0.2,
|
|
139
|
+
fcp: normalize(vitals.fcp, 1800, 3000) * 0.15,
|
|
140
|
+
commitAvg: normalize(avgCommit, 16, 100) * 0.15,
|
|
141
|
+
ttfb: normalize(vitals.ttfb, 800, 1800) * 0.1,
|
|
142
|
+
cls: normalize(vitals.cls, 0.1, 0.25) * 0.05,
|
|
143
|
+
inp: normalize(vitals.inp, 200, 500) * 0.05,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let score = Object.values(scores).reduce((a, b) => a + b, 0);
|
|
147
|
+
|
|
148
|
+
// Penalize for errors and warnings
|
|
149
|
+
const errorCount = errors.filter((e) => e.type === "error").length;
|
|
150
|
+
const warningCount = errors.filter((e) => e.type === "warning").length;
|
|
151
|
+
score -= Math.min(errorCount * 5, 20);
|
|
152
|
+
score -= Math.min(warningCount * 2, 10);
|
|
153
|
+
|
|
154
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
158
|
+
// ROUTE SCANNER
|
|
159
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
160
|
+
|
|
161
|
+
class RouteScanner {
|
|
162
|
+
static async scanForRoutes(projectPath: string): Promise<string[]> {
|
|
163
|
+
const routes: string[] = ["/"];
|
|
164
|
+
|
|
165
|
+
const potentialFiles = [
|
|
166
|
+
path.join(projectPath, "src", "App.tsx"),
|
|
167
|
+
path.join(projectPath, "src", "App.jsx"),
|
|
168
|
+
path.join(projectPath, "src", "main.tsx"),
|
|
169
|
+
path.join(projectPath, "src", "routes.tsx"),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (const filePath of potentialFiles) {
|
|
173
|
+
if (!fs.existsSync(filePath)) continue;
|
|
174
|
+
try {
|
|
175
|
+
const code = await fs.readFile(filePath, "utf-8");
|
|
176
|
+
const ast = parser.parse(code, {
|
|
177
|
+
sourceType: "module",
|
|
178
|
+
plugins: ["jsx", "typescript"],
|
|
179
|
+
});
|
|
180
|
+
traverse(ast, {
|
|
181
|
+
JSXOpeningElement(p: { node: { name: any; attributes: any[] } }) {
|
|
182
|
+
const isRoute = (p.node.name as any).name === "Route";
|
|
183
|
+
if (isRoute) {
|
|
184
|
+
const pathAttr = p.node.attributes.find(
|
|
185
|
+
(attr: any) => attr.name?.name === "path",
|
|
186
|
+
);
|
|
187
|
+
if (
|
|
188
|
+
pathAttr &&
|
|
189
|
+
"value" in pathAttr &&
|
|
190
|
+
pathAttr.value?.type === "StringLiteral"
|
|
191
|
+
) {
|
|
192
|
+
routes.push(pathAttr.value.value);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
// skip unparseable files silently
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const result = [...new Set(routes)];
|
|
203
|
+
return result.length > 0 ? result : ["/"];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
208
|
+
// RUNTIME PROFILER
|
|
209
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
210
|
+
|
|
211
|
+
export class RuntimeProfiler {
|
|
212
|
+
private projectPath: string;
|
|
213
|
+
private devServer?: ChildProcess;
|
|
214
|
+
private browser?: Browser;
|
|
215
|
+
private reportDir: string;
|
|
216
|
+
private screenshotDir: string;
|
|
217
|
+
|
|
218
|
+
constructor(projectPath: string) {
|
|
219
|
+
this.projectPath = path.resolve(projectPath);
|
|
220
|
+
// __dirname = core/runtime/profiler/
|
|
221
|
+
// 2 levels up (.., ..) = core/ โ reports folder lives here
|
|
222
|
+
this.reportDir = path.resolve(__dirname, "..", "..", "reports");
|
|
223
|
+
this.screenshotDir = path.join(this.reportDir, "screenshots");
|
|
224
|
+
fs.ensureDirSync(this.reportDir);
|
|
225
|
+
fs.ensureDirSync(this.screenshotDir);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Main entry point.
|
|
230
|
+
*
|
|
231
|
+
* New options:
|
|
232
|
+
* cpuThrottle: 1 (default, no throttle) | 4 (Lighthouse mobile) | 6 (low-end)
|
|
233
|
+
*
|
|
234
|
+
* Flow:
|
|
235
|
+
* 1. startDevServer() โ boots the React app
|
|
236
|
+
* 2. RouteScanner โ finds routes from source code
|
|
237
|
+
* 3. For each device ร route:
|
|
238
|
+
* a. collectErrors() โ attach listeners BEFORE navigation
|
|
239
|
+
* b. page.goto() โ load the page
|
|
240
|
+
* c. runVitalsScript() โ LCP, FCP, CLS, INP, TTFB
|
|
241
|
+
* d. runReactProfiler() โ re-renders, commit durations
|
|
242
|
+
* e. captureScreenshots()โ FCP moment, LCP moment, full load
|
|
243
|
+
* f. getResourceUsage() โ heap, DOM, payload, top offender
|
|
244
|
+
* g. calculateScore() โ 0โ100 weighted score
|
|
245
|
+
* 4. cleanup() โ kills browser + dev server
|
|
246
|
+
* 5. writeJson() โ saves runtimereport.json
|
|
247
|
+
*/
|
|
248
|
+
async profile(
|
|
249
|
+
manualRoutes: string[] = [],
|
|
250
|
+
options: ProfileOptions = {},
|
|
251
|
+
): Promise<Record<string, RuntimeReport>> {
|
|
252
|
+
const throttle = options.throttle ?? "none";
|
|
253
|
+
const cpuThrottle = options.cpuThrottle ?? 1;
|
|
254
|
+
|
|
255
|
+
const rawDevice = options.device ?? "desktop";
|
|
256
|
+
const devices: DeviceType[] = Array.isArray(rawDevice)
|
|
257
|
+
? rawDevice
|
|
258
|
+
: [rawDevice];
|
|
259
|
+
const multiDevice = devices.length > 1;
|
|
260
|
+
|
|
261
|
+
console.log("๐ Starting React Doctor Analysis...");
|
|
262
|
+
console.log(
|
|
263
|
+
` Devices: ${devices.join(", ")} | Network: ${throttle} | CPU: ${cpuThrottle}x`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const masterReport: Record<string, RuntimeReport> = {};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const port = await this.startDevServer();
|
|
270
|
+
const baseUrl = `http://localhost:${port}`;
|
|
271
|
+
|
|
272
|
+
let targetRoutes = manualRoutes;
|
|
273
|
+
if (targetRoutes.length === 0) {
|
|
274
|
+
console.log("๐ Smart Scanning source code for routes...");
|
|
275
|
+
targetRoutes = await RouteScanner.scanForRoutes(this.projectPath);
|
|
276
|
+
console.log(
|
|
277
|
+
`๐ฏ Discovered ${targetRoutes.length} route(s): ${targetRoutes.join(", ")}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const device of devices) {
|
|
282
|
+
console.log(`\n๐ฑ Starting ${device.toUpperCase()} pass...`);
|
|
283
|
+
|
|
284
|
+
for (const route of targetRoutes) {
|
|
285
|
+
const url = `${baseUrl}${route}`;
|
|
286
|
+
console.log(`\n ๐ Auditing [${device}]: ${route}`);
|
|
287
|
+
|
|
288
|
+
const { vitals, reactData, stats, errors, screenshots } =
|
|
289
|
+
await this.collectMetrics(url, device, throttle, cpuThrottle);
|
|
290
|
+
|
|
291
|
+
const performanceScore = calculatePerformanceScore(
|
|
292
|
+
vitals,
|
|
293
|
+
reactData.renderTime,
|
|
294
|
+
reactData.commitDurations,
|
|
295
|
+
errors,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
console.log(` ๐ Score: ${performanceScore}/100`);
|
|
299
|
+
|
|
300
|
+
const key = multiDevice ? `${route}::${device}` : route;
|
|
301
|
+
|
|
302
|
+
masterReport[key] = {
|
|
303
|
+
timestamp: new Date().toISOString(),
|
|
304
|
+
url,
|
|
305
|
+
deviceType: device,
|
|
306
|
+
metrics: vitals,
|
|
307
|
+
rerenders: reactData.rerenders,
|
|
308
|
+
commitDurations: reactData.commitDurations,
|
|
309
|
+
renderTime: reactData.renderTime,
|
|
310
|
+
stats,
|
|
311
|
+
performanceScore,
|
|
312
|
+
errors,
|
|
313
|
+
screenshots,
|
|
314
|
+
cpuThrottling: cpuThrottle,
|
|
315
|
+
networkThrottle: throttle,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await this.cleanup();
|
|
321
|
+
|
|
322
|
+
const reportPath = path.join(this.reportDir, "runtimereport.json");
|
|
323
|
+
await fs.writeJson(reportPath, masterReport, { spaces: 2 });
|
|
324
|
+
console.log(`\n๐ Report saved to: ${reportPath}`);
|
|
325
|
+
|
|
326
|
+
return masterReport;
|
|
327
|
+
} catch (error) {
|
|
328
|
+
await this.cleanup();
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
334
|
+
// PRIVATE: web-vitals loader
|
|
335
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
336
|
+
|
|
337
|
+
private getWebVitalsScript(): string {
|
|
338
|
+
const filename = "web-vitals.iife.js";
|
|
339
|
+
|
|
340
|
+
const candidates: string[] = [
|
|
341
|
+
// __dirname = react-tool/core/runtime/profiler/ โ 3 levels up = react-tool/
|
|
342
|
+
path.resolve(
|
|
343
|
+
__dirname,
|
|
344
|
+
"..",
|
|
345
|
+
"..",
|
|
346
|
+
"..",
|
|
347
|
+
"node_modules",
|
|
348
|
+
"web-vitals",
|
|
349
|
+
"dist",
|
|
350
|
+
filename,
|
|
351
|
+
),
|
|
352
|
+
path.resolve(
|
|
353
|
+
__dirname,
|
|
354
|
+
"..",
|
|
355
|
+
"..",
|
|
356
|
+
"..",
|
|
357
|
+
"..",
|
|
358
|
+
"node_modules",
|
|
359
|
+
"web-vitals",
|
|
360
|
+
"dist",
|
|
361
|
+
filename,
|
|
362
|
+
),
|
|
363
|
+
path.join(
|
|
364
|
+
this.projectPath,
|
|
365
|
+
"node_modules",
|
|
366
|
+
"web-vitals",
|
|
367
|
+
"dist",
|
|
368
|
+
filename,
|
|
369
|
+
),
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const pkgJson = require.resolve("web-vitals/package.json");
|
|
374
|
+
candidates.push(path.join(path.dirname(pkgJson), "dist", filename));
|
|
375
|
+
} catch {}
|
|
376
|
+
|
|
377
|
+
for (const candidate of candidates) {
|
|
378
|
+
if (fs.existsSync(candidate)) {
|
|
379
|
+
console.log(` โ
web-vitals loaded from disk (offline-safe)`);
|
|
380
|
+
return fs.readFileSync(candidate, "utf-8");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const searched = candidates.map((c) => `\n ${c}`).join("");
|
|
385
|
+
throw new Error(
|
|
386
|
+
`โ web-vitals not found.\n\n Searched in:${searched}\n\n` +
|
|
387
|
+
` Fix: run "npm install web-vitals" inside react-tool/\n`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
392
|
+
// PRIVATE: browser path
|
|
393
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
394
|
+
|
|
395
|
+
private getBrowserPath(): string {
|
|
396
|
+
if (os.platform() === "win32") {
|
|
397
|
+
const winPaths = [
|
|
398
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
399
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
400
|
+
`C:\\Users\\${os.userInfo().username}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe`,
|
|
401
|
+
];
|
|
402
|
+
for (const p of winPaths) {
|
|
403
|
+
if (fs.existsSync(p)) return p;
|
|
404
|
+
}
|
|
405
|
+
throw new Error("โ Chrome not found! Please install Google Chrome.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const unixPaths = [
|
|
409
|
+
"/usr/bin/google-chrome",
|
|
410
|
+
"/usr/bin/chromium-browser",
|
|
411
|
+
"/usr/bin/chromium",
|
|
412
|
+
"/snap/bin/chromium",
|
|
413
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
for (const p of unixPaths) {
|
|
417
|
+
if (fs.existsSync(p)) return p;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
throw new Error(
|
|
421
|
+
"โ No compatible browser found! Please install Google Chrome or Chromium.",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
426
|
+
// PRIVATE: dev server
|
|
427
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
428
|
+
|
|
429
|
+
private async startDevServer(): Promise<number> {
|
|
430
|
+
const isWin = os.platform() === "win32";
|
|
431
|
+
|
|
432
|
+
const pkgManager = fs.existsSync(path.join(this.projectPath, "yarn.lock"))
|
|
433
|
+
? "yarn"
|
|
434
|
+
: fs.existsSync(path.join(this.projectPath, "pnpm-lock.yaml"))
|
|
435
|
+
? "pnpm"
|
|
436
|
+
: "npm";
|
|
437
|
+
|
|
438
|
+
console.log(`๐ฆ Starting ${pkgManager} dev server...`);
|
|
439
|
+
|
|
440
|
+
this.devServer = spawn(pkgManager, ["run", "dev"], {
|
|
441
|
+
cwd: this.projectPath,
|
|
442
|
+
shell: isWin ? true : "/bin/bash",
|
|
443
|
+
env: {
|
|
444
|
+
...process.env,
|
|
445
|
+
...(isWin
|
|
446
|
+
? {}
|
|
447
|
+
: { PATH: process.env.PATH + ":/usr/local/bin:/usr/bin:/bin" }),
|
|
448
|
+
},
|
|
449
|
+
detached: !isWin,
|
|
450
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
451
|
+
windowsHide: true,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return this.waitForServer();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async waitForServer(): Promise<number> {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
let resolved = false;
|
|
460
|
+
|
|
461
|
+
const timeout = setTimeout(
|
|
462
|
+
() => reject(new Error("โฑ๏ธ Dev server timed out after 30 seconds!")),
|
|
463
|
+
30000,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const onData = (data: Buffer) => {
|
|
467
|
+
const output = data.toString().replace(/\x1B\[[0-9;]*[mGKHF]/g, "");
|
|
468
|
+
console.log(` ${output.trim()}`);
|
|
469
|
+
|
|
470
|
+
const match = output.match(/localhost:(\d+)/);
|
|
471
|
+
if (match && !resolved) {
|
|
472
|
+
resolved = true;
|
|
473
|
+
clearTimeout(timeout);
|
|
474
|
+
this.devServer?.stdout?.off("data", onData);
|
|
475
|
+
this.devServer?.stderr?.off("data", onData);
|
|
476
|
+
setTimeout(() => resolve(parseInt(match[1], 10)), 2000);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
this.devServer?.stdout?.on("data", onData);
|
|
481
|
+
this.devServer?.stderr?.on("data", onData);
|
|
482
|
+
this.devServer?.on("error", (err) => {
|
|
483
|
+
clearTimeout(timeout);
|
|
484
|
+
reject(new Error(`โ Dev server failed to start: ${err.message}`));
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
490
|
+
// PRIVATE: collectMetrics โ orchestrates one full page audit
|
|
491
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
492
|
+
|
|
493
|
+
private async collectMetrics(
|
|
494
|
+
url: string,
|
|
495
|
+
device: DeviceType,
|
|
496
|
+
throttle: ThrottlePreset,
|
|
497
|
+
cpuThrottle: CpuThrottle,
|
|
498
|
+
) {
|
|
499
|
+
if (!this.browser) {
|
|
500
|
+
this.browser = await puppeteer.launch({
|
|
501
|
+
executablePath: this.getBrowserPath(),
|
|
502
|
+
headless: true,
|
|
503
|
+
args: [
|
|
504
|
+
"--no-sandbox",
|
|
505
|
+
"--disable-setuid-sandbox",
|
|
506
|
+
"--disable-dev-shm-usage",
|
|
507
|
+
],
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const page = await this.browser.newPage();
|
|
512
|
+
const devicePreset = DEVICE_PRESETS[device];
|
|
513
|
+
|
|
514
|
+
await page.setViewport({
|
|
515
|
+
...devicePreset.viewport,
|
|
516
|
+
hasTouch: devicePreset.hasTouch,
|
|
517
|
+
isMobile: devicePreset.isMobile,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
if (devicePreset.userAgent) {
|
|
521
|
+
await page.setUserAgent(devicePreset.userAgent);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// CDP session used for cache clearing, network throttling, and CPU throttling
|
|
525
|
+
const cdpClient = await page.createCDPSession();
|
|
526
|
+
await cdpClient.send("Network.clearBrowserCache");
|
|
527
|
+
|
|
528
|
+
if (NETWORK_PRESETS[throttle]) {
|
|
529
|
+
await cdpClient.send("Network.emulateNetworkConditions", {
|
|
530
|
+
offline: false,
|
|
531
|
+
...NETWORK_PRESETS[throttle],
|
|
532
|
+
});
|
|
533
|
+
console.log(` ๐ Network: ${throttle}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// CPU throttling โ works on localhost unlike network throttling.
|
|
537
|
+
// Slows down JavaScript execution to simulate low-end hardware.
|
|
538
|
+
// rate 1 = real speed, rate 4 = 4x slower (Lighthouse mobile preset).
|
|
539
|
+
if (cpuThrottle > 1) {
|
|
540
|
+
await cdpClient.send("Emulation.setCPUThrottlingRate", {
|
|
541
|
+
rate: cpuThrottle,
|
|
542
|
+
});
|
|
543
|
+
console.log(` โ๏ธ CPU: ${cpuThrottle}x slowdown`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// โโ Error & warning collection โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
547
|
+
// Attach listeners BEFORE navigation so we catch errors that
|
|
548
|
+
// happen during the initial page load, not just after it settles.
|
|
549
|
+
//
|
|
550
|
+
// pageerror: uncaught JS exceptions (TypeError, ReferenceError, etc.)
|
|
551
|
+
// console: anything logged via console.error() or console.warn()
|
|
552
|
+
// React warns about missing keys, prop-type errors, etc.
|
|
553
|
+
// via console.error, so we capture those here.
|
|
554
|
+
const errors: PageError[] = [];
|
|
555
|
+
|
|
556
|
+
page.on("pageerror", (err) => {
|
|
557
|
+
// Puppeteer types pageerror as unknown in newer versions.
|
|
558
|
+
// Cast to Error since that is always what the browser sends.
|
|
559
|
+
const error = err as Error;
|
|
560
|
+
errors.push({
|
|
561
|
+
type: "error",
|
|
562
|
+
message: error?.message ?? String(err),
|
|
563
|
+
source: "pageerror",
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Internal messages generated by our own profiler scripts that
|
|
568
|
+
// would show up as false positives in the app's error report.
|
|
569
|
+
const PROFILER_NOISE = [
|
|
570
|
+
"Deprecated API for given entry type", // from getEntriesByType("largest-contentful-paint")
|
|
571
|
+
"web-vitals", // from our web-vitals injection
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
page.on("console", (msg) => {
|
|
575
|
+
const text = msg.text();
|
|
576
|
+
|
|
577
|
+
// Skip messages that come from our own injected scripts
|
|
578
|
+
if (PROFILER_NOISE.some((noise) => text.includes(noise))) return;
|
|
579
|
+
|
|
580
|
+
if (msg.type() === "error") {
|
|
581
|
+
errors.push({
|
|
582
|
+
type: "error",
|
|
583
|
+
message: text,
|
|
584
|
+
source: "console",
|
|
585
|
+
});
|
|
586
|
+
} else if (msg.type() === "warn") {
|
|
587
|
+
errors.push({
|
|
588
|
+
type: "warning",
|
|
589
|
+
message: text,
|
|
590
|
+
source: "console",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// โโ React DevTools hook injection โโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
596
|
+
await page.evaluateOnNewDocument(() => {
|
|
597
|
+
const win = globalThis as any;
|
|
598
|
+
const rerenders: Record<string, number> = {};
|
|
599
|
+
const commitDurations: number[] = [];
|
|
600
|
+
|
|
601
|
+
function walkFiber(fiber: any): void {
|
|
602
|
+
if (!fiber) return;
|
|
603
|
+
const name: string =
|
|
604
|
+
fiber.type?.displayName ||
|
|
605
|
+
fiber.type?.name ||
|
|
606
|
+
(typeof fiber.type === "string" ? fiber.type : null);
|
|
607
|
+
if (name && /^[A-Z]/.test(name)) {
|
|
608
|
+
rerenders[name] = (rerenders[name] || 0) + 1;
|
|
609
|
+
}
|
|
610
|
+
walkFiber(fiber.child);
|
|
611
|
+
walkFiber(fiber.sibling);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function patchHook(hook: any): void {
|
|
615
|
+
if (hook.__reactDoctorPatched__) return;
|
|
616
|
+
hook.__reactDoctorPatched__ = true;
|
|
617
|
+
const originalOnCommit = hook.onCommitFiberRoot;
|
|
618
|
+
hook.onCommitFiberRoot = (rendererID: any, fiberRoot: any) => {
|
|
619
|
+
if (originalOnCommit)
|
|
620
|
+
originalOnCommit.call(hook, rendererID, fiberRoot);
|
|
621
|
+
try {
|
|
622
|
+
const rootFiber = fiberRoot.current;
|
|
623
|
+
if (rootFiber?.actualDuration != null) {
|
|
624
|
+
commitDurations.push(
|
|
625
|
+
parseFloat(rootFiber.actualDuration.toFixed(2)),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
walkFiber(rootFiber);
|
|
629
|
+
} catch (e) {}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
win.__reactDoctorData__ = { rerenders, commitDurations };
|
|
634
|
+
|
|
635
|
+
if (win.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
|
|
636
|
+
patchHook(win.__REACT_DEVTOOLS_GLOBAL_HOOK__);
|
|
637
|
+
} else {
|
|
638
|
+
win.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
639
|
+
isDisabled: false,
|
|
640
|
+
supportsFiber: true,
|
|
641
|
+
renderers: new Map(),
|
|
642
|
+
onScheduleFiberRoot: () => {},
|
|
643
|
+
onCommitFiberUnmount: () => {},
|
|
644
|
+
onCommitFiberRoot: () => {},
|
|
645
|
+
inject(renderer: any) {
|
|
646
|
+
patchHook(win.__REACT_DEVTOOLS_GLOBAL_HOOK__);
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const navStart = Date.now();
|
|
654
|
+
await page.goto(url, { waitUntil: "networkidle0", timeout: 60000 });
|
|
655
|
+
const renderTime = Date.now() - navStart;
|
|
656
|
+
|
|
657
|
+
// Simulate click for INP
|
|
658
|
+
await page.mouse.click(
|
|
659
|
+
devicePreset.viewport.width / 2,
|
|
660
|
+
devicePreset.viewport.height / 2,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const perfMetrics = await page.metrics();
|
|
664
|
+
const vitals = await this.runVitalsScript(page);
|
|
665
|
+
const reactData = await this.runReactProfiler(page, renderTime);
|
|
666
|
+
const resources = await this.getResourceUsage(page);
|
|
667
|
+
const screenshots = await this.captureScreenshots(
|
|
668
|
+
page,
|
|
669
|
+
url,
|
|
670
|
+
device,
|
|
671
|
+
renderTime,
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
await page.close();
|
|
675
|
+
|
|
676
|
+
// Reset CPU throttling for next page (doesn't persist across pages
|
|
677
|
+
// automatically, but good practice to reset explicitly)
|
|
678
|
+
if (cpuThrottle > 1) {
|
|
679
|
+
// cdpClient is bound to the closed page โ new page gets fresh session
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
vitals,
|
|
684
|
+
reactData,
|
|
685
|
+
errors,
|
|
686
|
+
screenshots,
|
|
687
|
+
stats: {
|
|
688
|
+
domNodes: perfMetrics.Nodes ?? 0,
|
|
689
|
+
jsHeapMB: ((perfMetrics.JSHeapUsedSize ?? 0) / 1024 / 1024).toFixed(
|
|
690
|
+
2,
|
|
691
|
+
),
|
|
692
|
+
payloadMB: resources.totalMB.toFixed(2),
|
|
693
|
+
topOffender: resources.topFile,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
} catch (err: unknown) {
|
|
697
|
+
await page.close();
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
703
|
+
// PRIVATE: web vitals
|
|
704
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
705
|
+
|
|
706
|
+
private async runVitalsScript(page: Page): Promise<WebVitals> {
|
|
707
|
+
const webVitalsCode = this.getWebVitalsScript();
|
|
708
|
+
await page.addScriptTag({ content: webVitalsCode });
|
|
709
|
+
|
|
710
|
+
return (await page.evaluate(() => {
|
|
711
|
+
return new Promise((resolve) => {
|
|
712
|
+
const results: any = { lcp: 0, fcp: 0, cls: 0, inp: 0, ttfb: 0 };
|
|
713
|
+
let count = 0;
|
|
714
|
+
let resolved = false;
|
|
715
|
+
|
|
716
|
+
const v = (globalThis as any).webVitals;
|
|
717
|
+
|
|
718
|
+
const finalize = () => {
|
|
719
|
+
if (results.lcp === 0) results.lcp = results.fcp;
|
|
720
|
+
return results;
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const done = () => {
|
|
724
|
+
if (++count === 5 && !resolved) {
|
|
725
|
+
resolved = true;
|
|
726
|
+
resolve(finalize());
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
v.onLCP((m: any) => {
|
|
731
|
+
results.lcp = m.value;
|
|
732
|
+
done();
|
|
733
|
+
});
|
|
734
|
+
v.onFCP((m: any) => {
|
|
735
|
+
results.fcp = m.value;
|
|
736
|
+
done();
|
|
737
|
+
});
|
|
738
|
+
v.onCLS((m: any) => {
|
|
739
|
+
results.cls = m.value;
|
|
740
|
+
done();
|
|
741
|
+
});
|
|
742
|
+
v.onINP((m: any) => {
|
|
743
|
+
results.inp = m.value;
|
|
744
|
+
done();
|
|
745
|
+
});
|
|
746
|
+
v.onTTFB((m: any) => {
|
|
747
|
+
results.ttfb = m.value;
|
|
748
|
+
done();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
setTimeout(() => {
|
|
752
|
+
if (!resolved) {
|
|
753
|
+
resolved = true;
|
|
754
|
+
resolve(finalize());
|
|
755
|
+
}
|
|
756
|
+
}, 8000);
|
|
757
|
+
});
|
|
758
|
+
})) as WebVitals;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
762
|
+
// PRIVATE: React Profiler API
|
|
763
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
764
|
+
|
|
765
|
+
private async runReactProfiler(
|
|
766
|
+
page: Page,
|
|
767
|
+
renderTime: number,
|
|
768
|
+
): Promise<ReactProfilerData> {
|
|
769
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
770
|
+
|
|
771
|
+
const result = await page.evaluate((renderTimeMs: number) => {
|
|
772
|
+
const win = globalThis as any;
|
|
773
|
+
const data = win.__reactDoctorData__;
|
|
774
|
+
const hookExists = !!win.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
775
|
+
const hookSupportsFiber =
|
|
776
|
+
win.__REACT_DEVTOOLS_GLOBAL_HOOK__?.supportsFiber ?? false;
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
hookExists,
|
|
780
|
+
hookSupportsFiber,
|
|
781
|
+
dataExists: !!data,
|
|
782
|
+
rerenders: data?.rerenders ?? {},
|
|
783
|
+
commitDurations: data?.commitDurations ?? [],
|
|
784
|
+
renderTime: renderTimeMs,
|
|
785
|
+
};
|
|
786
|
+
}, renderTime);
|
|
787
|
+
|
|
788
|
+
console.log(
|
|
789
|
+
` โ๏ธ Hook exists: ${result.hookExists} | supportsFiber: ${result.hookSupportsFiber} | data captured: ${result.dataExists}`,
|
|
790
|
+
);
|
|
791
|
+
console.log(
|
|
792
|
+
` โ๏ธ Commits: ${result.commitDurations.length} | Components: ${Object.keys(result.rerenders).length}`,
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
rerenders: result.rerenders,
|
|
797
|
+
commitDurations: result.commitDurations,
|
|
798
|
+
renderTime: result.renderTime,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
803
|
+
// PRIVATE: screenshot capture
|
|
804
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Takes three screenshots that form a visual filmstrip of the load:
|
|
808
|
+
*
|
|
809
|
+
* 1. "fcp" โ taken right after FCP fires (first content appears)
|
|
810
|
+
* 2. "lcp" โ taken right after LCP fires (main content appears)
|
|
811
|
+
* 3. "fullLoad" โ taken after networkidle0 (page fully settled)
|
|
812
|
+
*
|
|
813
|
+
* HOW IT WORKS:
|
|
814
|
+
* We can't go back in time to capture FCP/LCP exactly as they happened
|
|
815
|
+
* because those events fired during page.goto(). Instead we take the
|
|
816
|
+
* full-load screenshot immediately (the page is still visible), and
|
|
817
|
+
* for FCP/LCP we use the browser's PerformanceObserver timestamps to
|
|
818
|
+
* know WHEN they happened โ then annotate the screenshot with that info.
|
|
819
|
+
*
|
|
820
|
+
* Screenshots are saved as:
|
|
821
|
+
* - Base64 data URLs in the JSON report (for the dashboard <img> tags)
|
|
822
|
+
* - PNG files in core/reports/screenshots/ (for direct viewing)
|
|
823
|
+
*
|
|
824
|
+
* The dataUrl format is: "data:image/png;base64,<base64string>"
|
|
825
|
+
* Drop it directly into an <img src="..."> in the dashboard.
|
|
826
|
+
*/
|
|
827
|
+
private async captureScreenshots(
|
|
828
|
+
page: Page,
|
|
829
|
+
url: string,
|
|
830
|
+
device: DeviceType,
|
|
831
|
+
renderTime: number,
|
|
832
|
+
): Promise<Screenshot[]> {
|
|
833
|
+
const screenshots: Screenshot[] = [];
|
|
834
|
+
|
|
835
|
+
// Get the FCP and LCP timestamps from the browser's Performance API
|
|
836
|
+
// so we can annotate the screenshots with when those events happened
|
|
837
|
+
const timings = await page.evaluate(() => {
|
|
838
|
+
const fcpEntry = performance.getEntriesByName(
|
|
839
|
+
"first-contentful-paint",
|
|
840
|
+
)[0];
|
|
841
|
+
const lcpEntries = (performance as any).getEntriesByType(
|
|
842
|
+
"largest-contentful-paint",
|
|
843
|
+
);
|
|
844
|
+
return {
|
|
845
|
+
fcp: fcpEntry?.startTime ?? 0,
|
|
846
|
+
lcp:
|
|
847
|
+
lcpEntries.length > 0
|
|
848
|
+
? lcpEntries[lcpEntries.length - 1].startTime
|
|
849
|
+
: 0,
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// Build a safe filename prefix from the URL and device
|
|
854
|
+
// e.g. "localhost-2004---desktop" for http://localhost:2004/
|
|
855
|
+
const urlSafe = url
|
|
856
|
+
.replace(/https?:\/\//, "")
|
|
857
|
+
.replace(/[/:?#]/g, "-")
|
|
858
|
+
.replace(/-+/g, "-")
|
|
859
|
+
.replace(/^-|-$/g, "");
|
|
860
|
+
|
|
861
|
+
const timestamp = Date.now();
|
|
862
|
+
|
|
863
|
+
// Take the full-load screenshot (page is currently at networkidle0 state)
|
|
864
|
+
const fullLoadBuffer = await page.screenshot({
|
|
865
|
+
type: "png",
|
|
866
|
+
fullPage: false,
|
|
867
|
+
});
|
|
868
|
+
const fullLoadBase64 = `data:image/png;base64,${Buffer.from(fullLoadBuffer as any).toString("base64")}`;
|
|
869
|
+
const fullLoadFilename = `${urlSafe}-${device}-fullLoad-${timestamp}.png`;
|
|
870
|
+
|
|
871
|
+
await fs.writeFile(
|
|
872
|
+
path.join(this.screenshotDir, fullLoadFilename),
|
|
873
|
+
fullLoadBuffer,
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
screenshots.push({
|
|
877
|
+
label: "fullLoad",
|
|
878
|
+
dataUrl: fullLoadBase64,
|
|
879
|
+
takenAt: renderTime,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// For FCP and LCP we record when they happened relative to navigation.
|
|
883
|
+
// We can't re-render those exact moments, but we store the timing
|
|
884
|
+
// so the dashboard can annotate the filmstrip correctly.
|
|
885
|
+
// We add them as metadata-only entries (same screenshot, different label)
|
|
886
|
+
// so the dashboard knows which frame corresponds to which event.
|
|
887
|
+
if (timings.fcp > 0) {
|
|
888
|
+
screenshots.push({
|
|
889
|
+
label: "fcp",
|
|
890
|
+
dataUrl: fullLoadBase64, // same visual โ annotated with timing in dashboard
|
|
891
|
+
takenAt: Math.round(timings.fcp),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (timings.lcp > 0) {
|
|
896
|
+
screenshots.push({
|
|
897
|
+
label: "lcp",
|
|
898
|
+
dataUrl: fullLoadBase64,
|
|
899
|
+
takenAt: Math.round(timings.lcp),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
console.log(` ๐ธ Screenshot saved: ${fullLoadFilename}`);
|
|
904
|
+
|
|
905
|
+
return screenshots;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
909
|
+
// PRIVATE: resource usage
|
|
910
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
911
|
+
|
|
912
|
+
private async getResourceUsage(page: Page) {
|
|
913
|
+
return await page.evaluate(() => {
|
|
914
|
+
const entries = performance.getEntriesByType("resource");
|
|
915
|
+
const totalBytes = entries.reduce(
|
|
916
|
+
(acc, e: any) => acc + (e.transferSize || 0),
|
|
917
|
+
0,
|
|
918
|
+
);
|
|
919
|
+
const sorted = [...entries].sort(
|
|
920
|
+
(a: any, b: any) => (b.transferSize || 0) - (a.transferSize || 0),
|
|
921
|
+
);
|
|
922
|
+
const heaviest = sorted[0] as any;
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
totalMB: totalBytes / 1024 / 1024,
|
|
926
|
+
topFile: heaviest
|
|
927
|
+
? {
|
|
928
|
+
name: heaviest.name.split("/").pop() || "unknown",
|
|
929
|
+
size: (heaviest.transferSize || 0) / 1024 / 1024,
|
|
930
|
+
}
|
|
931
|
+
: null,
|
|
932
|
+
};
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
937
|
+
// PRIVATE: cleanup
|
|
938
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
939
|
+
|
|
940
|
+
private async cleanup(): Promise<void> {
|
|
941
|
+
if (this.browser) {
|
|
942
|
+
await this.browser.close();
|
|
943
|
+
this.browser = undefined;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (this.devServer && this.devServer.pid) {
|
|
947
|
+
console.log("๐งน Cleaning up background processes...");
|
|
948
|
+
try {
|
|
949
|
+
if (os.platform() === "win32") {
|
|
950
|
+
spawn("taskkill", [
|
|
951
|
+
"/pid",
|
|
952
|
+
this.devServer.pid.toString(),
|
|
953
|
+
"/f",
|
|
954
|
+
"/t",
|
|
955
|
+
]);
|
|
956
|
+
} else {
|
|
957
|
+
process.kill(-this.devServer.pid);
|
|
958
|
+
}
|
|
959
|
+
} catch (error) {
|
|
960
|
+
const err = error as any;
|
|
961
|
+
if (err.code !== "ESRCH")
|
|
962
|
+
console.warn(` โ ๏ธ Cleanup warning: ${err.message}`);
|
|
963
|
+
}
|
|
964
|
+
this.devServer = undefined;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|