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,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
+ }