react-doctor-cli-dev 1.0.6 → 1.0.9
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/data/screenshots/4--fcp.png +0 -0
- package/backend/data/screenshots/4--fullLoad.png +0 -0
- package/backend/data/screenshots/4-docs-fullLoad.png +0 -0
- package/backend/data/screenshots/4-white-fullLoad.png +0 -0
- package/backend/dist/db.js +38 -9
- package/backend/dist/index.js +29 -3
- package/backend/dist/routes/reports.js +105 -22
- 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 +46 -14
- package/backend/src/index.ts +31 -3
- package/backend/src/routes/reports.ts +140 -52
- package/cli/dist/commands/full.js +82 -48
- package/cli/src/commands/full.ts +161 -115
- package/package.json +25 -4
- package/shared/dist/index.d.ts +0 -2
- package/shared/dist/index.js +0 -19
- package/shared/dist/schemas.d.ts +0 -91
- package/shared/dist/schemas.js +0 -82
- package/shared/dist/types.d.ts +0 -44
- package/shared/dist/types.js +0 -2
- package/shared/package-lock.json +0 -47
- package/shared/package.json +0 -21
- package/shared/src/index.ts +0 -4
- package/shared/src/schemas.ts +0 -136
- package/shared/src/types.ts +0 -137
- package/shared/tsconfig.json +0 -15
- package/tsconfig.json +0 -25
|
@@ -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,11 +36,10 @@
|
|
|
33
36
|
|
|
34
37
|
import { Router, Request, Response, RequestHandler } from "express";
|
|
35
38
|
import path from "path";
|
|
36
|
-
import fs
|
|
39
|
+
import fs from "fs";
|
|
37
40
|
import db from "../db";
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
const screenshotsDir = "data/screenshots";
|
|
41
|
+
import { screenshotsDir } from "../db";
|
|
42
|
+
import { requireApiKey } from "../middleware/auth";
|
|
41
43
|
|
|
42
44
|
const router = Router();
|
|
43
45
|
|
|
@@ -79,6 +81,96 @@ router.get("/project/:name", (req: Request, res: Response) => {
|
|
|
79
81
|
}
|
|
80
82
|
});
|
|
81
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
|
+
|
|
82
174
|
// ── POST /api/reports/upload ──────────────────────────────────
|
|
83
175
|
// Receives a FinalReport from the CLI, strips screenshots to disk,
|
|
84
176
|
// and stores the three JSON blobs in separate columns.
|
|
@@ -94,14 +186,14 @@ router.post(
|
|
|
94
186
|
if (
|
|
95
187
|
!body ||
|
|
96
188
|
!body.projectName ||
|
|
97
|
-
!body.analyzedAt
|
|
189
|
+
!body.analyzedAt ||
|
|
98
190
|
body.performanceScore === undefined ||
|
|
99
|
-
!body.static
|
|
100
|
-
!body.runtime
|
|
191
|
+
!body.static ||
|
|
192
|
+
!body.runtime ||
|
|
101
193
|
!body.suggestions
|
|
102
194
|
) {
|
|
103
195
|
res.status(400).json({
|
|
104
|
-
error:
|
|
196
|
+
error: "Invalid report",
|
|
105
197
|
missing: getMissingFields(body),
|
|
106
198
|
});
|
|
107
199
|
return;
|
|
@@ -111,9 +203,6 @@ router.post(
|
|
|
111
203
|
const grade: string = body.static?.grade ?? "N/A";
|
|
112
204
|
|
|
113
205
|
// ── Strip screenshots from runtime, save as .png files ──
|
|
114
|
-
// We do this BEFORE inserting so the DB never holds base64.
|
|
115
|
-
// The row ID isn't known yet — we'll rename after insert.
|
|
116
|
-
// For now we use a temp prefix and rename below.
|
|
117
206
|
const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
|
|
118
207
|
|
|
119
208
|
// ── Insert the row ───────────────────────────────────────
|
|
@@ -140,7 +229,6 @@ router.post(
|
|
|
140
229
|
const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
|
|
141
230
|
|
|
142
231
|
// ── Patch runtime_json with final screenshot paths ───────
|
|
143
|
-
// Now that we have the reportId we can write the correct paths.
|
|
144
232
|
if (savedScreenshots.length > 0) {
|
|
145
233
|
const patchedRuntime = patchScreenshotPaths(
|
|
146
234
|
cleanedRuntime,
|
|
@@ -152,8 +240,8 @@ router.post(
|
|
|
152
240
|
}
|
|
153
241
|
|
|
154
242
|
res.status(201).json({
|
|
155
|
-
message:
|
|
156
|
-
id:
|
|
243
|
+
message: "Report saved successfully",
|
|
244
|
+
id: reportId,
|
|
157
245
|
screenshots: savedScreenshots.length,
|
|
158
246
|
});
|
|
159
247
|
} catch (err: any) {
|
|
@@ -179,15 +267,14 @@ router.get("/:id", (req: Request, res: Response) => {
|
|
|
179
267
|
}
|
|
180
268
|
|
|
181
269
|
res.json({
|
|
182
|
-
id:
|
|
183
|
-
project:
|
|
184
|
-
score:
|
|
185
|
-
grade:
|
|
186
|
-
analyzedAt:
|
|
187
|
-
createdAt:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
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),
|
|
191
278
|
suggestions: JSON.parse(row.suggestions),
|
|
192
279
|
});
|
|
193
280
|
} catch (err: any) {
|
|
@@ -209,19 +296,14 @@ function getMissingFields(body: any): string[] {
|
|
|
209
296
|
|
|
210
297
|
interface PendingScreenshot {
|
|
211
298
|
routeKey: string;
|
|
212
|
-
label:
|
|
213
|
-
buffer:
|
|
214
|
-
// placeholder path written into cleanedRuntime — replaced after insert
|
|
299
|
+
label: string;
|
|
300
|
+
buffer: Buffer;
|
|
215
301
|
tempPath: string;
|
|
216
302
|
}
|
|
217
303
|
|
|
218
304
|
/**
|
|
219
305
|
* Walk the runtime map, strip every screenshot.dataUrl,
|
|
220
306
|
* and collect them as Buffers ready to write to disk.
|
|
221
|
-
*
|
|
222
|
-
* Returns:
|
|
223
|
-
* cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
|
|
224
|
-
* pendingScreenshots — list of screenshots to save once we have a reportId
|
|
225
307
|
*/
|
|
226
308
|
function extractScreenshots(
|
|
227
309
|
runtime: Record<string, any>,
|
|
@@ -234,21 +316,25 @@ function extractScreenshots(
|
|
|
234
316
|
|
|
235
317
|
if (Array.isArray(routeClone.screenshots)) {
|
|
236
318
|
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
237
|
-
if
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
242
|
-
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");
|
|
243
323
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
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}`;
|
|
248
327
|
|
|
249
|
-
|
|
328
|
+
pending.push({ routeKey, label: shot.label, buffer, tempPath });
|
|
250
329
|
|
|
251
|
-
|
|
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
|
+
}
|
|
252
338
|
});
|
|
253
339
|
}
|
|
254
340
|
|
|
@@ -260,32 +346,35 @@ function extractScreenshots(
|
|
|
260
346
|
|
|
261
347
|
interface SavedScreenshot {
|
|
262
348
|
routeKey: string;
|
|
263
|
-
label:
|
|
349
|
+
label: string;
|
|
264
350
|
tempPath: string;
|
|
265
|
-
filePath: string;
|
|
351
|
+
filePath: string;
|
|
266
352
|
}
|
|
267
353
|
|
|
268
354
|
/**
|
|
269
355
|
* Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
|
|
270
|
-
* Returns the list of saved files with their final URL paths.
|
|
271
356
|
*/
|
|
272
357
|
function saveScreenshots(
|
|
273
358
|
reportId: number,
|
|
274
|
-
pending:
|
|
359
|
+
pending: PendingScreenshot[],
|
|
275
360
|
): SavedScreenshot[] {
|
|
276
361
|
const saved: SavedScreenshot[] = [];
|
|
277
362
|
|
|
363
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
364
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
365
|
+
}
|
|
366
|
+
|
|
278
367
|
for (const shot of pending) {
|
|
279
368
|
const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
280
369
|
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
281
|
-
const filename
|
|
282
|
-
const fullPath
|
|
370
|
+
const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
|
|
371
|
+
const fullPath = path.join(screenshotsDir, filename);
|
|
283
372
|
|
|
284
373
|
try {
|
|
285
374
|
fs.writeFileSync(fullPath, shot.buffer);
|
|
286
375
|
saved.push({
|
|
287
376
|
routeKey: shot.routeKey,
|
|
288
|
-
label:
|
|
377
|
+
label: shot.label,
|
|
289
378
|
tempPath: shot.tempPath,
|
|
290
379
|
filePath: `/screenshots/${filename}`,
|
|
291
380
|
});
|
|
@@ -303,9 +392,8 @@ function saveScreenshots(
|
|
|
303
392
|
*/
|
|
304
393
|
function patchScreenshotPaths(
|
|
305
394
|
runtime: Record<string, any>,
|
|
306
|
-
saved:
|
|
395
|
+
saved: SavedScreenshot[],
|
|
307
396
|
): Record<string, any> {
|
|
308
|
-
// Build a lookup from tempPath → filePath
|
|
309
397
|
const lookup: Record<string, string> = {};
|
|
310
398
|
for (const s of saved) lookup[s.tempPath] = s.filePath;
|
|
311
399
|
|
|
@@ -327,4 +415,4 @@ function patchScreenshotPaths(
|
|
|
327
415
|
}
|
|
328
416
|
|
|
329
417
|
return patched;
|
|
330
|
-
}
|
|
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"));
|