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.
Files changed (82) hide show
  1. package/backend/.env +3 -0
  2. package/backend/dist/index.js +43 -0
  3. package/backend/dist/middleware/auth.js +16 -0
  4. package/backend/dist/routes/reports.js +93 -0
  5. package/backend/package-lock.json +2000 -0
  6. package/backend/package.json +30 -0
  7. package/backend/src/db.ts +24 -0
  8. package/backend/src/index.ts +49 -0
  9. package/backend/src/middleware/auth.ts +21 -0
  10. package/backend/src/routes/reports.ts +110 -0
  11. package/backend/tsconfig.json +12 -0
  12. package/cli/bin/react-doctor.js +29 -0
  13. package/cli/dist/commands/analyze.js +125 -0
  14. package/cli/dist/commands/full.js +366 -0
  15. package/cli/dist/commands/install.js +138 -0
  16. package/cli/dist/commands/profile.js +166 -0
  17. package/cli/dist/index.js +78 -0
  18. package/cli/dist/ui.js +113 -0
  19. package/cli/package-lock.json +936 -0
  20. package/cli/package.json +34 -0
  21. package/cli/src/commands/analyze.ts +162 -0
  22. package/cli/src/commands/full.ts +574 -0
  23. package/cli/src/commands/install.ts +163 -0
  24. package/cli/src/commands/profile.ts +246 -0
  25. package/cli/src/index.ts +84 -0
  26. package/cli/src/ui.ts +120 -0
  27. package/cli/tsconfig.json +16 -0
  28. package/core/report-compiler/index.ts +359 -0
  29. package/core/report-compiler/test-report-compiler.ts +126 -0
  30. package/core/rule-engine/context-builder.ts +146 -0
  31. package/core/rule-engine/evaluator.ts +131 -0
  32. package/core/rule-engine/index.ts +222 -0
  33. package/core/rule-engine/rules.json +304 -0
  34. package/core/rule-engine/suggestion-builder.ts +209 -0
  35. package/core/rule-engine/test-rule-engine.ts +144 -0
  36. package/core/rule-engine/types.ts +202 -0
  37. package/core/runtime/profiler/browser.ts +121 -0
  38. package/core/runtime/profiler/collectors.ts +216 -0
  39. package/core/runtime/profiler/index.ts +311 -0
  40. package/core/runtime/profiler/porfiler.ts +967 -0
  41. package/core/runtime/profiler/route-scanner.ts +76 -0
  42. package/core/runtime/profiler/score.ts +59 -0
  43. package/core/runtime/profiler/server.ts +115 -0
  44. package/core/runtime/profiler/types.ts +65 -0
  45. package/core/runtime/test-runtime-profiler.ts +226 -0
  46. package/core/static-ana/static/analyzer.ts +145 -0
  47. package/core/static-ana/static/ast-parser.ts +31 -0
  48. package/core/static-ana/static/detectors/console-log.ts +49 -0
  49. package/core/static-ana/static/detectors/dead-code.ts +51 -0
  50. package/core/static-ana/static/detectors/effect-loop.ts +45 -0
  51. package/core/static-ana/static/detectors/index.ts +16 -0
  52. package/core/static-ana/static/detectors/inline-function.ts +59 -0
  53. package/core/static-ana/static/detectors/inline-style.ts +52 -0
  54. package/core/static-ana/static/detectors/large-component.ts +79 -0
  55. package/core/static-ana/static/detectors/missing-key.ts +56 -0
  56. package/core/static-ana/static/detectors/missing-memo.ts +59 -0
  57. package/core/static-ana/static/detectors/prop-drilling.ts +66 -0
  58. package/core/static-ana/static/helpers.ts +81 -0
  59. package/core/static-ana/static/scanner.ts +93 -0
  60. package/core/static-ana/test-analyzer.ts +115 -0
  61. package/core/static-ana/types.ts +25 -0
  62. package/core/tests/mock-react-project/src/app.tsx +22 -0
  63. package/core/tests/mock-react-project/src/components/Button.tsx +9 -0
  64. package/core/tests/mock-react-project/src/components/Header.tsx +3 -0
  65. package/core/tests/mock-react-project/src/components/ListTesting.tsx +51 -0
  66. package/core/tests/mock-react-project/src/components/UserDashboard.tsx +66 -0
  67. package/core/tests/mock-react-project/src/utils.ts +4 -0
  68. package/package.json +55 -0
  69. package/react-doctor-cli-dev-1.0.0.tgz +0 -0
  70. package/shared/dist/index.d.ts +2 -0
  71. package/shared/dist/index.js +19 -0
  72. package/shared/dist/schemas.d.ts +91 -0
  73. package/shared/dist/schemas.js +82 -0
  74. package/shared/dist/types.d.ts +44 -0
  75. package/shared/dist/types.js +2 -0
  76. package/shared/package-lock.json +47 -0
  77. package/shared/package.json +21 -0
  78. package/shared/src/index.ts +4 -0
  79. package/shared/src/schemas.ts +136 -0
  80. package/shared/src/types.ts +137 -0
  81. package/shared/tsconfig.json +15 -0
  82. 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
+ }