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.
Files changed (65) hide show
  1. package/backend/data/screenshots/4--fcp.png +0 -0
  2. package/backend/data/screenshots/4--fullLoad.png +0 -0
  3. package/backend/data/screenshots/4-docs-fullLoad.png +0 -0
  4. package/backend/data/screenshots/4-white-fullLoad.png +0 -0
  5. package/backend/dist/db.js +38 -9
  6. package/backend/dist/index.js +29 -3
  7. package/backend/dist/routes/reports.js +105 -22
  8. package/backend/public/assets/index-BpODc0fS.css +1 -0
  9. package/backend/public/assets/index-zKyZPsv1.js +118 -0
  10. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  11. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  12. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  13. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  14. package/backend/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  15. package/backend/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  16. package/backend/public/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  17. package/backend/public/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  18. package/backend/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  19. package/backend/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  20. package/backend/public/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  21. package/backend/public/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  22. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  23. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  24. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  25. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  26. package/backend/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  27. package/backend/public/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  28. package/backend/public/assets/tajawal-arabic-300-normal-Bq0yWa0Z.woff +0 -0
  29. package/backend/public/assets/tajawal-arabic-300-normal-By07C9pa.woff2 +0 -0
  30. package/backend/public/assets/tajawal-arabic-400-normal-CyCXRvzh.woff2 +0 -0
  31. package/backend/public/assets/tajawal-arabic-400-normal-DCQxawbB.woff +0 -0
  32. package/backend/public/assets/tajawal-arabic-500-normal-BZ8ojJNu.woff2 +0 -0
  33. package/backend/public/assets/tajawal-arabic-500-normal-CbVEaYEW.woff +0 -0
  34. package/backend/public/assets/tajawal-arabic-700-normal-9L7Zusdl.woff +0 -0
  35. package/backend/public/assets/tajawal-arabic-700-normal-D2-eand5.woff2 +0 -0
  36. package/backend/public/assets/tajawal-latin-300-normal-C0-xR3ms.woff +0 -0
  37. package/backend/public/assets/tajawal-latin-300-normal-CeEKeOxZ.woff2 +0 -0
  38. package/backend/public/assets/tajawal-latin-400-normal-BVNSOH3d.woff2 +0 -0
  39. package/backend/public/assets/tajawal-latin-400-normal-BdYcZznU.woff +0 -0
  40. package/backend/public/assets/tajawal-latin-500-normal-CoYeBiSI.woff2 +0 -0
  41. package/backend/public/assets/tajawal-latin-500-normal-DU9v6xgj.woff +0 -0
  42. package/backend/public/assets/tajawal-latin-700-normal-BypgxfGb.woff2 +0 -0
  43. package/backend/public/assets/tajawal-latin-700-normal-CV3bxpHe.woff +0 -0
  44. package/backend/public/favicon.svg +1 -0
  45. package/backend/public/icons.svg +24 -0
  46. package/backend/public/index.html +254 -0
  47. package/backend/src/db.ts +46 -14
  48. package/backend/src/index.ts +31 -3
  49. package/backend/src/routes/reports.ts +140 -52
  50. package/cli/dist/commands/full.js +82 -48
  51. package/cli/src/commands/full.ts +161 -115
  52. package/package.json +25 -4
  53. package/shared/dist/index.d.ts +0 -2
  54. package/shared/dist/index.js +0 -19
  55. package/shared/dist/schemas.d.ts +0 -91
  56. package/shared/dist/schemas.js +0 -82
  57. package/shared/dist/types.d.ts +0 -44
  58. package/shared/dist/types.js +0 -2
  59. package/shared/package-lock.json +0 -47
  60. package/shared/package.json +0 -21
  61. package/shared/src/index.ts +0 -4
  62. package/shared/src/schemas.ts +0 -136
  63. package/shared/src/types.ts +0 -137
  64. package/shared/tsconfig.json +0 -15
  65. 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 from "fs";
39
+ import fs from "fs";
37
40
  import db from "../db";
38
- import { requireApiKey } from "../middleware/auth";
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: "Invalid report",
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: "Report saved successfully",
156
- id: reportId,
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: row.id,
183
- project: row.project,
184
- score: row.score,
185
- grade: row.grade,
186
- analyzedAt: row.analyzed_at,
187
- createdAt: row.created_at,
188
- // Parse the three JSON blobs back into objects
189
- static: JSON.parse(row.static_json),
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: string;
213
- buffer: 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 (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
238
- return shot;
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
- // Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
245
- const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
246
- const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
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
- pending.push({ routeKey, label: shot.label, buffer, tempPath });
328
+ pending.push({ routeKey, label: shot.label, buffer, tempPath });
250
329
 
251
- return { ...shot, dataUrl: tempPath };
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: string;
349
+ label: string;
264
350
  tempPath: string;
265
- filePath: string; // relative URL path served by express static
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: PendingScreenshot[],
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 = `${reportId}-${safeRoute}-${safeLabel}.png`;
282
- const fullPath = path.join(screenshotsDir, filename);
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: shot.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: SavedScreenshot[],
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 = "Backend not found. Starting local server automatically...";
301
- // Extract port safely
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
- const backendProcess = (0, child_process_1.spawn)(command, args, {
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. Perform the actual upload
335
- uploadSpin.text = "Uploading report...";
336
- await axios_1.default.post(`${apiUrl}/api/reports/upload`, finalReport, {
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: 10000,
359
+ timeout: 30000, // Longer timeout for images
342
360
  });
343
- uploadSpin.succeed(chalk_1.default.green("Report uploaded successfully"));
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"));