react-doctor-cli-dev 1.0.7 → 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 +33 -11
  6. package/backend/dist/index.js +29 -3
  7. package/backend/dist/routes/reports.js +106 -55
  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 +42 -18
  48. package/backend/src/index.ts +31 -3
  49. package/backend/src/routes/reports.ts +141 -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,10 +36,10 @@
33
36
 
34
37
  import { Router, Request, Response, RequestHandler } from "express";
35
38
  import path from "path";
36
- import fs from "fs";
37
- import db, { screenshotsDir } from "../db";
38
-
39
- import { requireApiKey } from "../middleware/auth";
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: "Invalid report",
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: "Report saved successfully",
155
- id: reportId,
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: row.id,
182
- project: row.project,
183
- score: row.score,
184
- grade: row.grade,
185
- analyzedAt: row.analyzed_at,
186
- createdAt: row.created_at,
187
- // Parse the three JSON blobs back into objects
188
- static: JSON.parse(row.static_json),
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: string;
212
- buffer: 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 (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
237
- return shot;
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
- // Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
244
- const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
245
- const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
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
- pending.push({ routeKey, label: shot.label, buffer, tempPath });
328
+ pending.push({ routeKey, label: shot.label, buffer, tempPath });
249
329
 
250
- 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
+ }
251
338
  });
252
339
  }
253
340
 
@@ -259,32 +346,35 @@ function extractScreenshots(
259
346
 
260
347
  interface SavedScreenshot {
261
348
  routeKey: string;
262
- label: string;
349
+ label: string;
263
350
  tempPath: string;
264
- filePath: string; // relative URL path served by express static
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: PendingScreenshot[],
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 = `${reportId}-${safeRoute}-${safeLabel}.png`;
281
- const fullPath = path.join(screenshotsDir, filename);
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: shot.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: SavedScreenshot[],
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 = "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"));