react-doctor-cli-dev 1.0.7 → 1.0.12
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/dist/db.js +33 -11
- package/backend/dist/index.js +29 -3
- package/backend/dist/routes/reports.js +106 -55
- package/backend/public/assets/index-BpODc0fS.css +1 -0
- package/backend/public/assets/index-zKyZPsv1.js +118 -0
- package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/backend/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/backend/public/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/backend/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/backend/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/backend/public/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/backend/public/assets/tajawal-arabic-300-normal-Bq0yWa0Z.woff +0 -0
- package/backend/public/assets/tajawal-arabic-300-normal-By07C9pa.woff2 +0 -0
- package/backend/public/assets/tajawal-arabic-400-normal-CyCXRvzh.woff2 +0 -0
- package/backend/public/assets/tajawal-arabic-400-normal-DCQxawbB.woff +0 -0
- package/backend/public/assets/tajawal-arabic-500-normal-BZ8ojJNu.woff2 +0 -0
- package/backend/public/assets/tajawal-arabic-500-normal-CbVEaYEW.woff +0 -0
- package/backend/public/assets/tajawal-arabic-700-normal-9L7Zusdl.woff +0 -0
- package/backend/public/assets/tajawal-arabic-700-normal-D2-eand5.woff2 +0 -0
- package/backend/public/assets/tajawal-latin-300-normal-C0-xR3ms.woff +0 -0
- package/backend/public/assets/tajawal-latin-300-normal-CeEKeOxZ.woff2 +0 -0
- package/backend/public/assets/tajawal-latin-400-normal-BVNSOH3d.woff2 +0 -0
- package/backend/public/assets/tajawal-latin-400-normal-BdYcZznU.woff +0 -0
- package/backend/public/assets/tajawal-latin-500-normal-CoYeBiSI.woff2 +0 -0
- package/backend/public/assets/tajawal-latin-500-normal-DU9v6xgj.woff +0 -0
- package/backend/public/assets/tajawal-latin-700-normal-BypgxfGb.woff2 +0 -0
- package/backend/public/assets/tajawal-latin-700-normal-CV3bxpHe.woff +0 -0
- package/backend/public/favicon.svg +1 -0
- package/backend/public/icons.svg +24 -0
- package/backend/public/index.html +254 -0
- package/backend/src/db.ts +42 -18
- package/backend/src/index.ts +31 -3
- package/backend/src/routes/reports.ts +141 -52
- package/cli/dist/commands/full.js +82 -48
- package/cli/src/commands/full.ts +161 -115
- package/dashboard/index.html +253 -0
- package/dashboard/package-lock.json +913 -0
- package/dashboard/package.json +19 -0
- package/dashboard/public/favicon.svg +1 -0
- package/dashboard/public/icons.svg +24 -0
- package/dashboard/src/api.js +107 -0
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/javascript.svg +1 -0
- package/dashboard/src/assets/vite.svg +1 -0
- package/dashboard/src/css/main.css +441 -0
- package/dashboard/src/data.js +202 -0
- package/dashboard/src/main.js +53 -0
- package/dashboard/src/pages.js +797 -0
- package/dashboard/src/router.js +77 -0
- package/dashboard/src/utils.js +163 -0
- package/dashboard/vite.config.js +19 -0
- package/data/screenshots/5--fcp.png +0 -0
- package/data/screenshots/5--fullLoad.png +0 -0
- package/data/screenshots/5-docs-fullLoad.png +0 -0
- package/data/screenshots/5-white-fullLoad.png +0 -0
- package/data/screenshots/6--fcp.png +0 -0
- package/data/screenshots/6--fullLoad.png +0 -0
- package/data/screenshots/6-docs-fullLoad.png +0 -0
- package/data/screenshots/6-white-fullLoad.png +0 -0
- package/package.json +4 -1
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// Returns the full report for one run — static + runtime +
|
|
14
14
|
// suggestions all parsed back to objects.
|
|
15
15
|
//
|
|
16
|
+
// GET /api/reports/:id/screenshots
|
|
17
|
+
// Returns all screenshots for a report as base64 data URLs.
|
|
18
|
+
//
|
|
16
19
|
// GET /api/reports/project/:name
|
|
17
20
|
// All runs for a named project, summary only.
|
|
18
21
|
//
|
|
@@ -33,10 +36,10 @@
|
|
|
33
36
|
|
|
34
37
|
import { Router, Request, Response, RequestHandler } from "express";
|
|
35
38
|
import path from "path";
|
|
36
|
-
import fs
|
|
37
|
-
import db
|
|
38
|
-
|
|
39
|
-
import { requireApiKey }
|
|
39
|
+
import fs from "fs";
|
|
40
|
+
import db from "../db";
|
|
41
|
+
import { screenshotsDir } from "../db";
|
|
42
|
+
import { requireApiKey } from "../middleware/auth";
|
|
40
43
|
|
|
41
44
|
const router = Router();
|
|
42
45
|
|
|
@@ -78,6 +81,96 @@ router.get("/project/:name", (req: Request, res: Response) => {
|
|
|
78
81
|
}
|
|
79
82
|
});
|
|
80
83
|
|
|
84
|
+
// ── GET /api/reports/:id/screenshots ──────────────────────────
|
|
85
|
+
// Returns all screenshots for a report as base64 data URLs.
|
|
86
|
+
// This is used by the dashboard to display screenshots.
|
|
87
|
+
|
|
88
|
+
router.get("/:id/screenshots", (req: Request, res: Response) => {
|
|
89
|
+
try {
|
|
90
|
+
const reportId = req.params.id;
|
|
91
|
+
|
|
92
|
+
// First, check if screenshots table exists
|
|
93
|
+
const tableCheck = db.prepare(`
|
|
94
|
+
SELECT name FROM sqlite_master
|
|
95
|
+
WHERE type='table' AND name='screenshots'
|
|
96
|
+
`).get();
|
|
97
|
+
|
|
98
|
+
if (!tableCheck) {
|
|
99
|
+
return res.json({ screenshots: [] });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Get screenshots from the database
|
|
103
|
+
const stmt = db.prepare(`
|
|
104
|
+
SELECT route, label, taken_at, data_url
|
|
105
|
+
FROM screenshots
|
|
106
|
+
WHERE report_id = ?
|
|
107
|
+
ORDER BY taken_at ASC
|
|
108
|
+
`);
|
|
109
|
+
const dbScreenshots = stmt.all(reportId) as any[];
|
|
110
|
+
|
|
111
|
+
if (dbScreenshots.length === 0) {
|
|
112
|
+
// Fallback: try to extract from runtime_json
|
|
113
|
+
const report = db.prepare(`
|
|
114
|
+
SELECT runtime_json FROM reports WHERE id = ?
|
|
115
|
+
`).get(reportId) as any;
|
|
116
|
+
|
|
117
|
+
if (report) {
|
|
118
|
+
const runtime = JSON.parse(report.runtime_json || '{}');
|
|
119
|
+
const screenshots: any[] = [];
|
|
120
|
+
|
|
121
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
122
|
+
const route = routeData as any;
|
|
123
|
+
if (route.screenshots && Array.isArray(route.screenshots)) {
|
|
124
|
+
for (const screenshot of route.screenshots) {
|
|
125
|
+
if (screenshot.dataUrl && screenshot.dataUrl.startsWith('/screenshots/')) {
|
|
126
|
+
const filename = screenshot.dataUrl.replace('/screenshots/', '');
|
|
127
|
+
const filePath = path.join(screenshotsDir, filename);
|
|
128
|
+
|
|
129
|
+
if (fs.existsSync(filePath)) {
|
|
130
|
+
try {
|
|
131
|
+
const imageBuffer = fs.readFileSync(filePath);
|
|
132
|
+
const base64Image = imageBuffer.toString('base64');
|
|
133
|
+
|
|
134
|
+
screenshots.push({
|
|
135
|
+
route: routeKey,
|
|
136
|
+
label: screenshot.label || 'screenshot',
|
|
137
|
+
taken_at: screenshot.takenAt || 0,
|
|
138
|
+
data_url: `data:image/png;base64,${base64Image}`,
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn(`Could not read screenshot ${filename}: ${err}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else if (screenshot.dataUrl && screenshot.dataUrl.startsWith('data:image')) {
|
|
145
|
+
screenshots.push({
|
|
146
|
+
route: routeKey,
|
|
147
|
+
label: screenshot.label || 'screenshot',
|
|
148
|
+
taken_at: screenshot.takenAt || 0,
|
|
149
|
+
data_url: screenshot.dataUrl,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return res.json({ screenshots });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Return screenshots from database
|
|
160
|
+
const screenshots = dbScreenshots.map((s: any) => ({
|
|
161
|
+
route: s.route,
|
|
162
|
+
label: s.label,
|
|
163
|
+
taken_at: s.taken_at,
|
|
164
|
+
data_url: s.data_url,
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
res.json({ screenshots });
|
|
168
|
+
} catch (err: any) {
|
|
169
|
+
console.error("GET /:id/screenshots error:", err.message);
|
|
170
|
+
res.status(500).json({ error: "Internal server error" });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
81
174
|
// ── POST /api/reports/upload ──────────────────────────────────
|
|
82
175
|
// Receives a FinalReport from the CLI, strips screenshots to disk,
|
|
83
176
|
// and stores the three JSON blobs in separate columns.
|
|
@@ -93,14 +186,14 @@ router.post(
|
|
|
93
186
|
if (
|
|
94
187
|
!body ||
|
|
95
188
|
!body.projectName ||
|
|
96
|
-
!body.analyzedAt
|
|
189
|
+
!body.analyzedAt ||
|
|
97
190
|
body.performanceScore === undefined ||
|
|
98
|
-
!body.static
|
|
99
|
-
!body.runtime
|
|
191
|
+
!body.static ||
|
|
192
|
+
!body.runtime ||
|
|
100
193
|
!body.suggestions
|
|
101
194
|
) {
|
|
102
195
|
res.status(400).json({
|
|
103
|
-
error:
|
|
196
|
+
error: "Invalid report",
|
|
104
197
|
missing: getMissingFields(body),
|
|
105
198
|
});
|
|
106
199
|
return;
|
|
@@ -110,9 +203,6 @@ router.post(
|
|
|
110
203
|
const grade: string = body.static?.grade ?? "N/A";
|
|
111
204
|
|
|
112
205
|
// ── Strip screenshots from runtime, save as .png files ──
|
|
113
|
-
// We do this BEFORE inserting so the DB never holds base64.
|
|
114
|
-
// The row ID isn't known yet — we'll rename after insert.
|
|
115
|
-
// For now we use a temp prefix and rename below.
|
|
116
206
|
const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
|
|
117
207
|
|
|
118
208
|
// ── Insert the row ───────────────────────────────────────
|
|
@@ -139,7 +229,6 @@ router.post(
|
|
|
139
229
|
const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
|
|
140
230
|
|
|
141
231
|
// ── Patch runtime_json with final screenshot paths ───────
|
|
142
|
-
// Now that we have the reportId we can write the correct paths.
|
|
143
232
|
if (savedScreenshots.length > 0) {
|
|
144
233
|
const patchedRuntime = patchScreenshotPaths(
|
|
145
234
|
cleanedRuntime,
|
|
@@ -151,8 +240,8 @@ router.post(
|
|
|
151
240
|
}
|
|
152
241
|
|
|
153
242
|
res.status(201).json({
|
|
154
|
-
message:
|
|
155
|
-
id:
|
|
243
|
+
message: "Report saved successfully",
|
|
244
|
+
id: reportId,
|
|
156
245
|
screenshots: savedScreenshots.length,
|
|
157
246
|
});
|
|
158
247
|
} catch (err: any) {
|
|
@@ -178,15 +267,14 @@ router.get("/:id", (req: Request, res: Response) => {
|
|
|
178
267
|
}
|
|
179
268
|
|
|
180
269
|
res.json({
|
|
181
|
-
id:
|
|
182
|
-
project:
|
|
183
|
-
score:
|
|
184
|
-
grade:
|
|
185
|
-
analyzedAt:
|
|
186
|
-
createdAt:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
runtime: JSON.parse(row.runtime_json),
|
|
270
|
+
id: row.id,
|
|
271
|
+
project: row.project,
|
|
272
|
+
score: row.score,
|
|
273
|
+
grade: row.grade,
|
|
274
|
+
analyzedAt: row.analyzed_at,
|
|
275
|
+
createdAt: row.created_at,
|
|
276
|
+
static: JSON.parse(row.static_json),
|
|
277
|
+
runtime: JSON.parse(row.runtime_json),
|
|
190
278
|
suggestions: JSON.parse(row.suggestions),
|
|
191
279
|
});
|
|
192
280
|
} catch (err: any) {
|
|
@@ -208,19 +296,14 @@ function getMissingFields(body: any): string[] {
|
|
|
208
296
|
|
|
209
297
|
interface PendingScreenshot {
|
|
210
298
|
routeKey: string;
|
|
211
|
-
label:
|
|
212
|
-
buffer:
|
|
213
|
-
// placeholder path written into cleanedRuntime — replaced after insert
|
|
299
|
+
label: string;
|
|
300
|
+
buffer: Buffer;
|
|
214
301
|
tempPath: string;
|
|
215
302
|
}
|
|
216
303
|
|
|
217
304
|
/**
|
|
218
305
|
* Walk the runtime map, strip every screenshot.dataUrl,
|
|
219
306
|
* and collect them as Buffers ready to write to disk.
|
|
220
|
-
*
|
|
221
|
-
* Returns:
|
|
222
|
-
* cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
|
|
223
|
-
* pendingScreenshots — list of screenshots to save once we have a reportId
|
|
224
307
|
*/
|
|
225
308
|
function extractScreenshots(
|
|
226
309
|
runtime: Record<string, any>,
|
|
@@ -233,21 +316,25 @@ function extractScreenshots(
|
|
|
233
316
|
|
|
234
317
|
if (Array.isArray(routeClone.screenshots)) {
|
|
235
318
|
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
236
|
-
if
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
241
|
-
const buffer = Buffer.from(base64, "base64");
|
|
319
|
+
// Check if it's a base64 data URL
|
|
320
|
+
if (shot.dataUrl && shot.dataUrl.startsWith("data:image/png;base64,")) {
|
|
321
|
+
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
322
|
+
const buffer = Buffer.from(base64, "base64");
|
|
242
323
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
|
|
324
|
+
const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
325
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
326
|
+
const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
|
|
247
327
|
|
|
248
|
-
|
|
328
|
+
pending.push({ routeKey, label: shot.label, buffer, tempPath });
|
|
249
329
|
|
|
250
|
-
|
|
330
|
+
return { ...shot, dataUrl: tempPath };
|
|
331
|
+
} else if (shot.dataUrl && shot.dataUrl.startsWith('/screenshots/')) {
|
|
332
|
+
// Already a path - keep it
|
|
333
|
+
return shot;
|
|
334
|
+
} else {
|
|
335
|
+
// No valid dataUrl - keep as is or set to null
|
|
336
|
+
return { ...shot, dataUrl: null };
|
|
337
|
+
}
|
|
251
338
|
});
|
|
252
339
|
}
|
|
253
340
|
|
|
@@ -259,32 +346,35 @@ function extractScreenshots(
|
|
|
259
346
|
|
|
260
347
|
interface SavedScreenshot {
|
|
261
348
|
routeKey: string;
|
|
262
|
-
label:
|
|
349
|
+
label: string;
|
|
263
350
|
tempPath: string;
|
|
264
|
-
filePath: string;
|
|
351
|
+
filePath: string;
|
|
265
352
|
}
|
|
266
353
|
|
|
267
354
|
/**
|
|
268
355
|
* Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
|
|
269
|
-
* Returns the list of saved files with their final URL paths.
|
|
270
356
|
*/
|
|
271
357
|
function saveScreenshots(
|
|
272
358
|
reportId: number,
|
|
273
|
-
pending:
|
|
359
|
+
pending: PendingScreenshot[],
|
|
274
360
|
): SavedScreenshot[] {
|
|
275
361
|
const saved: SavedScreenshot[] = [];
|
|
276
362
|
|
|
363
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
364
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
365
|
+
}
|
|
366
|
+
|
|
277
367
|
for (const shot of pending) {
|
|
278
368
|
const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
279
369
|
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
280
|
-
const filename
|
|
281
|
-
const fullPath
|
|
370
|
+
const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
|
|
371
|
+
const fullPath = path.join(screenshotsDir, filename);
|
|
282
372
|
|
|
283
373
|
try {
|
|
284
374
|
fs.writeFileSync(fullPath, shot.buffer);
|
|
285
375
|
saved.push({
|
|
286
376
|
routeKey: shot.routeKey,
|
|
287
|
-
label:
|
|
377
|
+
label: shot.label,
|
|
288
378
|
tempPath: shot.tempPath,
|
|
289
379
|
filePath: `/screenshots/${filename}`,
|
|
290
380
|
});
|
|
@@ -302,9 +392,8 @@ function saveScreenshots(
|
|
|
302
392
|
*/
|
|
303
393
|
function patchScreenshotPaths(
|
|
304
394
|
runtime: Record<string, any>,
|
|
305
|
-
saved:
|
|
395
|
+
saved: SavedScreenshot[],
|
|
306
396
|
): Record<string, any> {
|
|
307
|
-
// Build a lookup from tempPath → filePath
|
|
308
397
|
const lookup: Record<string, string> = {};
|
|
309
398
|
for (const s of saved) lookup[s.tempPath] = s.filePath;
|
|
310
399
|
|
|
@@ -326,4 +415,4 @@ function patchScreenshotPaths(
|
|
|
326
415
|
}
|
|
327
416
|
|
|
328
417
|
return patched;
|
|
329
|
-
}
|
|
418
|
+
}
|
|
@@ -12,18 +12,7 @@
|
|
|
12
12
|
// 4. RuleEngine → combines both reports into suggestions
|
|
13
13
|
// 5. ReportCompiler → merges everything into finalreport.json
|
|
14
14
|
// 6. Upload → sends the report to the backend API
|
|
15
|
-
//
|
|
16
|
-
// HOW IMPORTS WORK:
|
|
17
|
-
// The CLI imports core modules directly as TypeScript classes.
|
|
18
|
-
// No shell commands, no spawning child processes, no ts-node
|
|
19
|
-
// inside ts-node. Everything runs in the same Node.js process,
|
|
20
|
-
// which means objects are passed between steps in memory —
|
|
21
|
-
// fast, clean, and no disk reads between steps.
|
|
22
|
-
//
|
|
23
|
-
// The core folder is 2 levels up from cli/src/commands/:
|
|
24
|
-
// cli/src/commands/full.ts
|
|
25
|
-
// → ../../.. = react-tool root
|
|
26
|
-
// → ../../../core = core folder
|
|
15
|
+
// and opens the dashboard to that report
|
|
27
16
|
// ─────────────────────────────────────────────────────────────
|
|
28
17
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
29
18
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -38,13 +27,7 @@ const axios_1 = __importDefault(require("axios"));
|
|
|
38
27
|
const child_process_1 = require("child_process");
|
|
39
28
|
const ui_1 = require("../ui");
|
|
40
29
|
// ── Core imports ──────────────────────────────────────────────
|
|
41
|
-
// These are the actual classes from the core folder.
|
|
42
|
-
// We use require() with a resolved path so they work whether
|
|
43
|
-
// the CLI is run from its own folder or from the project root.
|
|
44
30
|
function getCoreModule(relativePath) {
|
|
45
|
-
// __dirname = cli/src/commands/
|
|
46
|
-
// 3 levels up = react-tool root
|
|
47
|
-
// then into core/
|
|
48
31
|
return require(path_1.default.resolve(__dirname, "..", "..", "..", "core", relativePath));
|
|
49
32
|
}
|
|
50
33
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -61,17 +44,14 @@ function registerFullCommand(program) {
|
|
|
61
44
|
.option("--throttle <preset>", "Network throttle: none | slow4g | 3g (only meaningful against deployed URLs)", "none")
|
|
62
45
|
.option("--upload", "Upload the final report to the React Doctor backend API", false)
|
|
63
46
|
.option("--api-url <url>", "Backend API URL to upload to", "http://localhost:3000")
|
|
64
|
-
// ✅ Single --api-key option (defined BEFORE .action)
|
|
65
47
|
.option("--api-key <key>", "API key for backend authentication (overrides REACT_DOCTOR_API_KEY env var)", process.env.REACT_DOCTOR_API_KEY || "react-doctor-secret-key-change-this")
|
|
66
48
|
.option("--no-banner", "Skip the banner")
|
|
67
|
-
// ✅ .action() comes LAST, with no trailing semicolon/comment
|
|
68
49
|
.action(async (projectPath, options) => {
|
|
69
50
|
await runFullCommand(projectPath, options);
|
|
70
51
|
});
|
|
71
52
|
}
|
|
72
53
|
// ─────────────────────────────────────────────────────────────
|
|
73
54
|
// MAIN RUNNER
|
|
74
|
-
// Exported so other commands (analyze --full) can call it too.
|
|
75
55
|
// ─────────────────────────────────────────────────────────────
|
|
76
56
|
async function runFullCommand(projectPath, options = {}) {
|
|
77
57
|
const resolvedPath = path_1.default.resolve(projectPath);
|
|
@@ -85,10 +65,6 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
85
65
|
process.exit(1);
|
|
86
66
|
}
|
|
87
67
|
// ── Determine device configuration ──────────────────────────
|
|
88
|
-
// --desktop and --mobile are independent flags.
|
|
89
|
-
// If neither is passed, desktop is the default.
|
|
90
|
-
// If only --mobile is passed, only mobile runs.
|
|
91
|
-
// If both are passed, both run in one pass.
|
|
92
68
|
const wantDesktop = options.desktop || (!options.desktop && !options.mobile);
|
|
93
69
|
const wantMobile = options.mobile ?? false;
|
|
94
70
|
const devices = wantDesktop && wantMobile
|
|
@@ -109,8 +85,6 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
109
85
|
(0, ui_1.printInfo)("CPU", `${cpuLabel}x`);
|
|
110
86
|
(0, ui_1.printInfo)("Network", throttleLabel);
|
|
111
87
|
// ── Output directory ───────────────────────────────────────
|
|
112
|
-
// Reports are saved inside the user's project in a hidden
|
|
113
|
-
// .react-doctor/ folder — easy to find, easy to gitignore.
|
|
114
88
|
const outputDir = path_1.default.join(resolvedPath, ".react-doctor");
|
|
115
89
|
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
116
90
|
// ════════════════════════════════════════════════════════════
|
|
@@ -166,14 +140,12 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
166
140
|
fs_1.default.writeFileSync(path_1.default.join(outputDir, "runtimereport.json"), JSON.stringify(runtimeReports, null, 2));
|
|
167
141
|
const routeKeys = Object.keys(runtimeReports);
|
|
168
142
|
profilingSpin.succeed(chalk_1.default.green(`Profiling complete — ${routeKeys.length} route/device combination(s)`));
|
|
169
|
-
// Print results for each route
|
|
170
143
|
for (const [key, report] of Object.entries(runtimeReports)) {
|
|
171
144
|
const [route, device] = key.includes("::")
|
|
172
145
|
? key.split("::")
|
|
173
146
|
: [key, "desktop"];
|
|
174
147
|
console.log();
|
|
175
148
|
console.log(` ${chalk_1.default.bold(route)} ${chalk_1.default.gray(`[${device}]`)} Score: ${(0, ui_1.scoreBadge)(report.performanceScore)}`);
|
|
176
|
-
// ── Device / CPU / Network line ──────────────────────────
|
|
177
149
|
console.log(` ${chalk_1.default.gray("Device:")} ${device} ` +
|
|
178
150
|
`${chalk_1.default.gray("CPU:")} ${report.cpuThrottling ?? cpuLabel}x ` +
|
|
179
151
|
`${chalk_1.default.gray("Network:")} ${throttleLabel}`);
|
|
@@ -200,7 +172,6 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
200
172
|
catch (err) {
|
|
201
173
|
profilingSpin.fail(chalk_1.default.red("Runtime profiling failed"));
|
|
202
174
|
console.log(chalk_1.default.red(`\n ${err.message}\n`));
|
|
203
|
-
// Profiling failure is not fatal — rule engine can still run on static data
|
|
204
175
|
}
|
|
205
176
|
// ════════════════════════════════════════════════════════════
|
|
206
177
|
// STEP 3 — RULE ENGINE
|
|
@@ -261,13 +232,12 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
261
232
|
console.log(chalk_1.default.red(`\n ${err.message}\n`));
|
|
262
233
|
}
|
|
263
234
|
// ════════════════════════════════════════════════════════════
|
|
264
|
-
// OPTIONAL — UPLOAD TO BACKEND API
|
|
235
|
+
// OPTIONAL — UPLOAD TO BACKEND API (WITH SCREENSHOTS)
|
|
265
236
|
// ════════════════════════════════════════════════════════════
|
|
266
237
|
if (options.upload && finalReport) {
|
|
267
238
|
(0, ui_1.printSection)("Uploading to Backend");
|
|
268
239
|
const uploadSpin = (0, ui_1.spinner)(`Connecting to ${options.apiUrl}...`);
|
|
269
240
|
try {
|
|
270
|
-
// Ensure apiUrl has a default
|
|
271
241
|
const apiUrl = options.apiUrl ?? "http://localhost:3000";
|
|
272
242
|
// 1. Check if backend is already running
|
|
273
243
|
try {
|
|
@@ -276,13 +246,9 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
276
246
|
}
|
|
277
247
|
catch (err) {
|
|
278
248
|
// 2. If not running, start it automatically
|
|
279
|
-
// Determine the project root directory (where cli/ and backend/ are siblings)
|
|
280
249
|
const projectRoot = path_1.default.resolve(__dirname, "..", "..", "..");
|
|
281
|
-
// Backend is a sibling folder to cli at projectRoot
|
|
282
250
|
const backendRoot = path_1.default.resolve(projectRoot, "backend");
|
|
283
|
-
// Check for compiled JS first (for installed packages)
|
|
284
251
|
const backendDist = path_1.default.join(backendRoot, "dist", "index.js");
|
|
285
|
-
// Check for TS source (for local dev)
|
|
286
252
|
const backendSrc = path_1.default.join(backendRoot, "src", "index.ts");
|
|
287
253
|
let command;
|
|
288
254
|
let args;
|
|
@@ -297,14 +263,12 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
297
263
|
else {
|
|
298
264
|
throw new Error(`Cannot find backend at: ${backendRoot}. Ensure 'backend' folder exists next to 'cli'.`);
|
|
299
265
|
}
|
|
300
|
-
uploadSpin.text =
|
|
301
|
-
|
|
266
|
+
uploadSpin.text =
|
|
267
|
+
"Backend not found. Starting local server automatically...";
|
|
302
268
|
const port = new URL(apiUrl).port || "3000";
|
|
303
|
-
// Spawn the backend process
|
|
304
|
-
// Create backend data directory in the target project
|
|
305
269
|
const backendDataDir = path_1.default.join(outputDir, "backend-data");
|
|
306
270
|
fs_1.default.mkdirSync(backendDataDir, { recursive: true });
|
|
307
|
-
|
|
271
|
+
(0, child_process_1.spawn)(command, args, {
|
|
308
272
|
stdio: "inherit",
|
|
309
273
|
env: {
|
|
310
274
|
...process.env,
|
|
@@ -312,9 +276,9 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
312
276
|
PORT: port,
|
|
313
277
|
DB_PATH: path_1.default.join(backendDataDir, "reports.db"),
|
|
314
278
|
},
|
|
315
|
-
cwd: backendRoot
|
|
279
|
+
cwd: backendRoot,
|
|
316
280
|
});
|
|
317
|
-
// 3. Wait for backend to be ready
|
|
281
|
+
// 3. Wait for backend to be ready (up to 15s)
|
|
318
282
|
let isReady = false;
|
|
319
283
|
let retries = 0;
|
|
320
284
|
while (!isReady && retries < 15) {
|
|
@@ -331,16 +295,86 @@ async function runFullCommand(projectPath, options = {}) {
|
|
|
331
295
|
throw new Error("Backend failed to start after 15 seconds.");
|
|
332
296
|
uploadSpin.text = "Backend started successfully!";
|
|
333
297
|
}
|
|
334
|
-
// 4.
|
|
335
|
-
uploadSpin.text = "
|
|
336
|
-
|
|
298
|
+
// ── 4. Read and encode screenshots ──────────────────────
|
|
299
|
+
uploadSpin.text = "Processing screenshots...";
|
|
300
|
+
const screenshots = [];
|
|
301
|
+
const runtime = finalReport.runtime || {};
|
|
302
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
303
|
+
if (routeData.screenshots && routeData.screenshots.length > 0) {
|
|
304
|
+
for (const screenshot of routeData.screenshots) {
|
|
305
|
+
try {
|
|
306
|
+
// Check if this is a file path or already a data URL
|
|
307
|
+
if (screenshot.dataUrl && !screenshot.dataUrl.startsWith('data:')) {
|
|
308
|
+
// It's a file path - read and encode it
|
|
309
|
+
const screenshotPath = path_1.default.join(outputDir, screenshot.dataUrl);
|
|
310
|
+
if (fs_1.default.existsSync(screenshotPath)) {
|
|
311
|
+
const imageBuffer = fs_1.default.readFileSync(screenshotPath);
|
|
312
|
+
const base64Image = imageBuffer.toString('base64');
|
|
313
|
+
screenshots.push({
|
|
314
|
+
route: routeKey,
|
|
315
|
+
label: screenshot.label || 'screenshot',
|
|
316
|
+
takenAt: screenshot.takenAt || 0,
|
|
317
|
+
dataUrl: `data:image/png;base64,${base64Image}`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// File doesn't exist - store as placeholder
|
|
322
|
+
console.log(chalk_1.default.yellow(` ⚠️ Screenshot not found: ${screenshotPath}`));
|
|
323
|
+
screenshots.push({
|
|
324
|
+
route: routeKey,
|
|
325
|
+
label: screenshot.label || 'screenshot',
|
|
326
|
+
takenAt: screenshot.takenAt || 0,
|
|
327
|
+
dataUrl: null,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else if (screenshot.dataUrl && screenshot.dataUrl.startsWith('data:')) {
|
|
332
|
+
// Already a data URL - use it directly
|
|
333
|
+
screenshots.push({
|
|
334
|
+
route: routeKey,
|
|
335
|
+
label: screenshot.label || 'screenshot',
|
|
336
|
+
takenAt: screenshot.takenAt || 0,
|
|
337
|
+
dataUrl: screenshot.dataUrl,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
console.log(chalk_1.default.yellow(` ⚠️ Failed to process screenshot: ${err.message}`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// ── 5. Prepare upload data with screenshots ─────────────
|
|
348
|
+
const uploadData = {
|
|
349
|
+
...finalReport,
|
|
350
|
+
screenshots: screenshots,
|
|
351
|
+
};
|
|
352
|
+
uploadSpin.text = `Uploading report and ${screenshots.length} screenshots...`;
|
|
353
|
+
// ── 6. Perform the upload ──────────────────────────────
|
|
354
|
+
const response = await axios_1.default.post(`${apiUrl}/api/reports/upload`, uploadData, {
|
|
337
355
|
headers: {
|
|
338
356
|
"Content-Type": "application/json",
|
|
339
357
|
"x-api-key": options.apiKey || "react-doctor-secret-key-change-this",
|
|
340
358
|
},
|
|
341
|
-
timeout:
|
|
359
|
+
timeout: 30000, // Longer timeout for images
|
|
342
360
|
});
|
|
343
|
-
|
|
361
|
+
const reportId = response.data?.id;
|
|
362
|
+
const uploadedCount = response.data?.screenshots || 0;
|
|
363
|
+
uploadSpin.succeed(chalk_1.default.green(`Report uploaded successfully (${uploadedCount} screenshots)`));
|
|
364
|
+
// 7. Open the dashboard directly to this report
|
|
365
|
+
if (reportId) {
|
|
366
|
+
const dashboardUrl = `${apiUrl}/report/${reportId}`;
|
|
367
|
+
(0, ui_1.printInfo)("Opening dashboard", dashboardUrl);
|
|
368
|
+
const openCmd = process.platform === "win32"
|
|
369
|
+
? ["cmd", ["/c", "start", "", dashboardUrl]]
|
|
370
|
+
: process.platform === "darwin"
|
|
371
|
+
? ["open", [dashboardUrl]]
|
|
372
|
+
: ["xdg-open", [dashboardUrl]];
|
|
373
|
+
(0, child_process_1.spawn)(openCmd[0], openCmd[1], {
|
|
374
|
+
stdio: "ignore",
|
|
375
|
+
detached: true,
|
|
376
|
+
}).unref();
|
|
377
|
+
}
|
|
344
378
|
}
|
|
345
379
|
catch (err) {
|
|
346
380
|
uploadSpin.fail(chalk_1.default.yellow("Upload failed — report saved locally"));
|