@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1

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 (84) hide show
  1. package/dist/api/client.js +134 -0
  2. package/dist/api/core.js +341 -0
  3. package/dist/api/endpoints.js +314 -0
  4. package/dist/api/index.js +19 -0
  5. package/dist/auth/client.js +91 -0
  6. package/dist/auth/core.js +176 -0
  7. package/dist/auth/index.js +30 -0
  8. package/dist/auth/operations.js +148 -0
  9. package/dist/cli.js +178 -3
  10. package/dist/client/index.js +144 -77
  11. package/dist/commands/doctor.js +121 -36
  12. package/dist/commands/finalize.js +49 -18
  13. package/dist/commands/init.js +13 -18
  14. package/dist/commands/login.js +49 -55
  15. package/dist/commands/logout.js +17 -9
  16. package/dist/commands/project.js +100 -71
  17. package/dist/commands/run.js +189 -95
  18. package/dist/commands/status.js +101 -66
  19. package/dist/commands/tdd-daemon.js +61 -32
  20. package/dist/commands/tdd.js +104 -98
  21. package/dist/commands/upload.js +78 -34
  22. package/dist/commands/whoami.js +44 -42
  23. package/dist/config/core.js +438 -0
  24. package/dist/config/index.js +13 -0
  25. package/dist/config/operations.js +327 -0
  26. package/dist/index.js +1 -1
  27. package/dist/project/core.js +295 -0
  28. package/dist/project/index.js +13 -0
  29. package/dist/project/operations.js +393 -0
  30. package/dist/reporter/reporter-bundle.css +1 -1
  31. package/dist/reporter/reporter-bundle.iife.js +16 -16
  32. package/dist/screenshot-server/core.js +157 -0
  33. package/dist/screenshot-server/index.js +11 -0
  34. package/dist/screenshot-server/operations.js +183 -0
  35. package/dist/sdk/index.js +3 -2
  36. package/dist/server/handlers/api-handler.js +14 -5
  37. package/dist/server/handlers/tdd-handler.js +191 -53
  38. package/dist/server/http-server.js +9 -3
  39. package/dist/server/routers/baseline.js +58 -0
  40. package/dist/server/routers/dashboard.js +10 -6
  41. package/dist/server/routers/screenshot.js +32 -0
  42. package/dist/server-manager/core.js +186 -0
  43. package/dist/server-manager/index.js +81 -0
  44. package/dist/server-manager/operations.js +209 -0
  45. package/dist/services/build-manager.js +2 -69
  46. package/dist/services/index.js +21 -48
  47. package/dist/services/screenshot-server.js +40 -74
  48. package/dist/services/server-manager.js +45 -80
  49. package/dist/services/test-runner.js +90 -250
  50. package/dist/services/uploader.js +56 -358
  51. package/dist/tdd/core/hotspot-coverage.js +112 -0
  52. package/dist/tdd/core/signature.js +101 -0
  53. package/dist/tdd/index.js +19 -0
  54. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  55. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  56. package/dist/tdd/services/baseline-downloader.js +151 -0
  57. package/dist/tdd/services/baseline-manager.js +166 -0
  58. package/dist/tdd/services/comparison-service.js +230 -0
  59. package/dist/tdd/services/hotspot-service.js +71 -0
  60. package/dist/tdd/services/result-service.js +123 -0
  61. package/dist/tdd/tdd-service.js +1145 -0
  62. package/dist/test-runner/core.js +255 -0
  63. package/dist/test-runner/index.js +13 -0
  64. package/dist/test-runner/operations.js +483 -0
  65. package/dist/types/client.d.ts +25 -2
  66. package/dist/uploader/core.js +396 -0
  67. package/dist/uploader/index.js +11 -0
  68. package/dist/uploader/operations.js +412 -0
  69. package/dist/utils/colors.js +187 -39
  70. package/dist/utils/config-loader.js +3 -6
  71. package/dist/utils/context.js +228 -0
  72. package/dist/utils/output.js +449 -14
  73. package/docs/api-reference.md +173 -8
  74. package/docs/tui-elements.md +560 -0
  75. package/package.json +13 -13
  76. package/dist/services/api-service.js +0 -412
  77. package/dist/services/auth-service.js +0 -226
  78. package/dist/services/config-service.js +0 -369
  79. package/dist/services/html-report-generator.js +0 -455
  80. package/dist/services/project-service.js +0 -326
  81. package/dist/services/report-generator/report.css +0 -411
  82. package/dist/services/report-generator/viewer.js +0 -102
  83. package/dist/services/static-report-generator.js +0 -207
  84. package/dist/services/tdd-service.js +0 -1437
@@ -1,17 +1,66 @@
1
- import { Buffer } from 'node:buffer';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
4
- import { getDimensionsSync } from '@vizzly-testing/honeydiff';
5
- import { TddService } from '../../services/tdd-service.js';
6
- import { detectImageInputType } from '../../utils/image-input-detector.js';
7
- import * as output from '../../utils/output.js';
8
- import { sanitizeScreenshotName, validateScreenshotProperties } from '../../utils/security.js';
1
+ import { Buffer as defaultBuffer } from 'node:buffer';
2
+ import { existsSync as defaultExistsSync, readFileSync as defaultReadFileSync, unlinkSync as defaultUnlinkSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
3
+ import { join as defaultJoin, resolve as defaultResolve } from 'node:path';
4
+ import { getDimensionsSync as defaultGetDimensionsSync } from '@vizzly-testing/honeydiff';
5
+ import { TddService as DefaultTddService } from '../../tdd/tdd-service.js';
6
+ import { detectImageInputType as defaultDetectImageInputType } from '../../utils/image-input-detector.js';
7
+ import * as defaultOutput from '../../utils/output.js';
8
+ import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
9
+
10
+ /**
11
+ * Unwrap double-nested properties if needed
12
+ * Client SDK wraps options in properties field, so we may get { properties: { properties: {...} } }
13
+ */
14
+ export const unwrapProperties = properties => {
15
+ if (!properties) return {};
16
+ if (properties.properties && typeof properties.properties === 'object') {
17
+ // Merge top-level properties with nested properties
18
+ let unwrapped = {
19
+ ...properties,
20
+ ...properties.properties
21
+ };
22
+ // Remove the nested properties field to avoid confusion
23
+ delete unwrapped.properties;
24
+ return unwrapped;
25
+ }
26
+ return properties;
27
+ };
28
+
29
+ /**
30
+ * Extract properties to top-level format matching cloud API
31
+ * Normalizes viewport to viewport_width/height, ensures browser is set
32
+ */
33
+ export const extractProperties = validatedProperties => {
34
+ if (!validatedProperties) return {};
35
+ return {
36
+ ...validatedProperties,
37
+ // Normalize viewport to top-level viewport_width/height (cloud format)
38
+ viewport_width: validatedProperties.viewport?.width ?? validatedProperties.viewport_width ?? null,
39
+ viewport_height: validatedProperties.viewport?.height ?? validatedProperties.viewport_height ?? null,
40
+ browser: validatedProperties.browser ?? null,
41
+ // Preserve nested structure in metadata for backward compatibility
42
+ metadata: validatedProperties
43
+ };
44
+ };
45
+
46
+ /**
47
+ * Convert absolute file paths to web-accessible URLs
48
+ */
49
+ export const convertPathToUrl = (filePath, vizzlyDir) => {
50
+ if (!filePath) return null;
51
+ // Convert absolute path to relative path from .vizzly directory
52
+ if (filePath.startsWith(vizzlyDir)) {
53
+ let relativePath = filePath.substring(vizzlyDir.length + 1);
54
+ return `/images/${relativePath}`;
55
+ }
56
+ return filePath;
57
+ };
9
58
 
10
59
  /**
11
60
  * Group comparisons by screenshot name with variant structure
12
61
  * Matches cloud product's grouping logic from comparison.js
13
62
  */
14
- const groupComparisons = comparisons => {
63
+ export const groupComparisons = comparisons => {
15
64
  const groups = new Map();
16
65
 
17
66
  // Group by screenshot name
@@ -87,7 +136,24 @@ const groupComparisons = comparisons => {
87
136
  return a.name.localeCompare(b.name);
88
137
  });
89
138
  };
90
- export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison, setBaseline = false) => {
139
+ export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison, setBaseline = false, deps = {}) => {
140
+ // Inject dependencies with defaults
141
+ let {
142
+ TddService = DefaultTddService,
143
+ existsSync = defaultExistsSync,
144
+ readFileSync = defaultReadFileSync,
145
+ unlinkSync = defaultUnlinkSync,
146
+ writeFileSync = defaultWriteFileSync,
147
+ join = defaultJoin,
148
+ resolve = defaultResolve,
149
+ Buffer = defaultBuffer,
150
+ getDimensionsSync = defaultGetDimensionsSync,
151
+ detectImageInputType = defaultDetectImageInputType,
152
+ safePath = defaultSafePath,
153
+ sanitizeScreenshotName = defaultSanitizeScreenshotName,
154
+ validateScreenshotProperties = defaultValidateScreenshotProperties,
155
+ output = defaultOutput
156
+ } = deps;
91
157
  const tddService = new TddService(config, workingDir, setBaseline);
92
158
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
93
159
  const readReportData = () => {
@@ -164,6 +230,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
164
230
  groups: reportData.groups.length,
165
231
  passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'new').length,
166
232
  failed: reportData.comparisons.filter(c => c.status === 'failed').length,
233
+ rejected: reportData.comparisons.filter(c => c.status === 'rejected').length,
167
234
  errors: reportData.comparisons.filter(c => c.status === 'error').length
168
235
  };
169
236
  writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
@@ -172,7 +239,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
172
239
  }
173
240
  };
174
241
  const initialize = async () => {
175
- output.debug('tdd', 'setting up local comparison');
242
+ output.debug('tdd', 'initializing local mode');
176
243
 
177
244
  // In baseline update mode, skip all baseline loading/downloading
178
245
  if (setBaseline) {
@@ -183,7 +250,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
183
250
  // Check if we have baseline override flags that should force a fresh download
184
251
  const shouldForceDownload = (baselineBuild || baselineComparison) && config.apiKey;
185
252
  if (shouldForceDownload) {
186
- output.debug('tdd', 'downloading baselines from cloud');
253
+ output.debug('tdd', 'downloading baselines');
187
254
  await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
188
255
  return;
189
256
  }
@@ -191,13 +258,13 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
191
258
  if (!baseline) {
192
259
  // Only download baselines if explicitly requested via baseline flags
193
260
  if ((baselineBuild || baselineComparison) && config.apiKey) {
194
- output.debug('tdd', 'downloading baselines from cloud');
261
+ output.debug('tdd', 'downloading baselines');
195
262
  await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
196
263
  } else {
197
- output.debug('tdd', 'no baselines found, will create on first run');
264
+ output.debug('tdd', 'no baselines yet');
198
265
  }
199
266
  } else {
200
- output.debug('tdd', `using baseline: ${baseline.buildName}`);
267
+ output.debug('tdd', `baseline: ${baseline.buildName}`);
201
268
  }
202
269
  };
203
270
  const handleScreenshot = async (_buildId, name, image, properties = {}) => {
@@ -217,18 +284,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
217
284
  }
218
285
 
219
286
  // Unwrap double-nested properties if needed (client SDK wraps options in properties field)
220
- // This happens when test helper passes { properties: {...}, threshold: 2.0 }
221
- // and client SDK wraps it as { properties: options }
222
- let unwrappedProperties = properties;
223
- if (properties.properties && typeof properties.properties === 'object') {
224
- // Merge top-level properties with nested properties
225
- unwrappedProperties = {
226
- ...properties,
227
- ...properties.properties
228
- };
229
- // Remove the nested properties field to avoid confusion
230
- delete unwrappedProperties.properties;
231
- }
287
+ let unwrappedProperties = unwrapProperties(properties);
232
288
 
233
289
  // Validate and sanitize properties
234
290
  let validatedProperties;
@@ -246,19 +302,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
246
302
  }
247
303
 
248
304
  // Extract ALL properties to top-level (matching cloud API behavior)
249
- // This ensures signature generation works correctly for custom properties like theme, device, etc.
250
- // Spread all validated properties first, then normalize viewport/browser for cloud format
251
- const extractedProperties = {
252
- ...validatedProperties,
253
- // Normalize viewport to top-level viewport_width/height (cloud format)
254
- // Use nullish coalescing to preserve any existing top-level values
255
- viewport_width: validatedProperties.viewport?.width ?? validatedProperties.viewport_width ?? null,
256
- viewport_height: validatedProperties.viewport?.height ?? validatedProperties.viewport_height ?? null,
257
- browser: validatedProperties.browser ?? null,
258
- // Preserve nested structure in metadata for backward compatibility
259
- // Signature generation checks multiple locations: top-level, metadata.*, metadata.properties.*
260
- metadata: validatedProperties
261
- };
305
+ const extractedProperties = extractProperties(validatedProperties);
262
306
 
263
307
  // Support both base64 encoded images and file paths
264
308
  // Vitest browser mode returns file paths, so we need to handle both
@@ -336,16 +380,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
336
380
  // Comparison tracked by tdd.js event handler
337
381
 
338
382
  // Convert absolute file paths to web-accessible URLs
339
- const convertPathToUrl = filePath => {
340
- if (!filePath) return null;
341
- // Convert absolute path to relative path from .vizzly directory
342
- const vizzlyDir = join(workingDir, '.vizzly');
343
- if (filePath.startsWith(vizzlyDir)) {
344
- const relativePath = filePath.substring(vizzlyDir.length + 1);
345
- return `/images/${relativePath}`;
346
- }
347
- return filePath;
348
- };
383
+ const vizzlyDir = join(workingDir, '.vizzly');
349
384
 
350
385
  // Record the comparison for the dashboard
351
386
  const newComparison = {
@@ -354,9 +389,9 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
354
389
  name: comparison.name,
355
390
  originalName: name,
356
391
  status: comparison.status,
357
- baseline: convertPathToUrl(comparison.baseline),
358
- current: convertPathToUrl(comparison.current),
359
- diff: convertPathToUrl(comparison.diff),
392
+ baseline: convertPathToUrl(comparison.baseline, vizzlyDir),
393
+ current: convertPathToUrl(comparison.current, vizzlyDir),
394
+ diff: convertPathToUrl(comparison.diff, vizzlyDir),
360
395
  diffPercentage: comparison.diffPercentage,
361
396
  threshold: comparison.threshold,
362
397
  properties: extractedProperties,
@@ -456,6 +491,33 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
456
491
  throw error;
457
492
  }
458
493
  };
494
+ const rejectBaseline = async comparisonId => {
495
+ try {
496
+ // Read current report data to get the comparison
497
+ const reportData = readReportData();
498
+ const comparison = reportData.comparisons.find(c => c.id === comparisonId);
499
+ if (!comparison) {
500
+ throw new Error(`Comparison not found with ID: ${comparisonId}`);
501
+ }
502
+
503
+ // Rejecting means: keep the current baseline, mark comparison as rejected
504
+ // The user is saying "I don't want this change, the baseline is correct"
505
+ // We update the status to 'rejected' so the UI shows the decision was made
506
+ const updatedComparison = {
507
+ ...comparison,
508
+ status: 'rejected'
509
+ };
510
+ updateComparison(updatedComparison);
511
+ output.info(`Changes rejected for comparison ${comparisonId}`);
512
+ return {
513
+ success: true,
514
+ id: comparisonId
515
+ };
516
+ } catch (error) {
517
+ output.error(`Failed to reject baseline for ${comparisonId}:`, error);
518
+ throw error;
519
+ }
520
+ };
459
521
  const acceptAllBaselines = async () => {
460
522
  try {
461
523
  output.debug('tdd', 'accepting all baselines');
@@ -590,6 +652,80 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
590
652
  throw error;
591
653
  }
592
654
  };
655
+
656
+ /**
657
+ * Safely delete a file within the .vizzly directory
658
+ * @param {string} imagePath - Path like "/images/baselines/foo.png"
659
+ * @param {string} label - Label for logging (e.g., "baseline", "current", "diff")
660
+ * @param {string} name - Screenshot name for logging
661
+ */
662
+ const safeDeleteFile = (imagePath, label, name) => {
663
+ if (!imagePath) return;
664
+ try {
665
+ // Use safePath to validate the path stays within workingDir
666
+ const filePath = safePath(workingDir, '.vizzly', imagePath.replace('/images/', ''));
667
+ if (existsSync(filePath)) {
668
+ unlinkSync(filePath);
669
+ output.debug(`Deleted ${label} for ${name}`);
670
+ }
671
+ } catch (error) {
672
+ // safePath throws if path traversal is attempted
673
+ output.warn(`Failed to delete ${label} for ${name}: ${error.message}`);
674
+ }
675
+ };
676
+ const deleteComparison = async comparisonId => {
677
+ const reportData = readReportData();
678
+ const comparison = reportData.comparisons.find(c => c.id === comparisonId);
679
+ if (!comparison) {
680
+ const error = new Error(`Comparison not found with ID: ${comparisonId}`);
681
+ error.code = 'NOT_FOUND';
682
+ throw error;
683
+ }
684
+
685
+ // Delete image files (safePath validates paths stay within workingDir)
686
+ safeDeleteFile(comparison.baseline, 'baseline', comparison.name);
687
+ safeDeleteFile(comparison.current, 'current', comparison.name);
688
+ safeDeleteFile(comparison.diff, 'diff', comparison.name);
689
+
690
+ // Remove from baseline metadata if it exists
691
+ try {
692
+ const metadataPath = safePath(workingDir, '.vizzly', 'baselines', 'metadata.json');
693
+ if (existsSync(metadataPath) && comparison.signature) {
694
+ const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
695
+ if (metadata.screenshots) {
696
+ const originalLength = metadata.screenshots.length;
697
+ metadata.screenshots = metadata.screenshots.filter(s => s.signature !== comparison.signature);
698
+ if (metadata.screenshots.length < originalLength) {
699
+ writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
700
+ output.debug(`Removed ${comparison.signature} from baseline metadata`);
701
+ }
702
+ }
703
+ }
704
+ } catch (error) {
705
+ output.warn(`Failed to update baseline metadata: ${error.message}`);
706
+ }
707
+
708
+ // Remove comparison from report data
709
+ reportData.comparisons = reportData.comparisons.filter(c => c.id !== comparisonId);
710
+
711
+ // Regenerate groups and summary
712
+ reportData.groups = groupComparisons(reportData.comparisons);
713
+ reportData.timestamp = Date.now();
714
+ reportData.summary = {
715
+ total: reportData.comparisons.length,
716
+ groups: reportData.groups.length,
717
+ passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'new').length,
718
+ failed: reportData.comparisons.filter(c => c.status === 'failed').length,
719
+ rejected: reportData.comparisons.filter(c => c.status === 'rejected').length,
720
+ errors: reportData.comparisons.filter(c => c.status === 'error').length
721
+ };
722
+ writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
723
+ output.info(`Deleted comparison ${comparisonId} (${comparison.name})`);
724
+ return {
725
+ success: true,
726
+ id: comparisonId
727
+ };
728
+ };
593
729
  const cleanup = () => {
594
730
  // Report data is persisted to file, no in-memory cleanup needed
595
731
  };
@@ -598,8 +734,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
598
734
  handleScreenshot,
599
735
  getResults,
600
736
  acceptBaseline,
737
+ rejectBaseline,
601
738
  acceptAllBaselines,
602
739
  resetBaselines,
740
+ deleteComparison,
603
741
  cleanup,
604
742
  // Expose tddService for baseline download operations
605
743
  tddService
@@ -28,7 +28,8 @@ export const createHttpServer = (port, screenshotHandler, services = {}) => {
28
28
  configService,
29
29
  authService,
30
30
  projectService,
31
- tddService
31
+ tddService,
32
+ workingDir
32
33
  } = services;
33
34
 
34
35
  // Create router context
@@ -40,6 +41,7 @@ export const createHttpServer = (port, screenshotHandler, services = {}) => {
40
41
  authService,
41
42
  projectService,
42
43
  tddService,
44
+ workingDir: workingDir || process.cwd(),
43
45
  apiUrl: 'https://app.vizzly.dev'
44
46
  };
45
47
 
@@ -98,7 +100,8 @@ export const createHttpServer = (port, screenshotHandler, services = {}) => {
98
100
  if (error) {
99
101
  reject(error);
100
102
  } else {
101
- output.debug('server', `listening on :${port}`);
103
+ // Don't log here - let the caller handle success logging via onServerReady callback
104
+ // This prevents duplicate "listening on" messages
102
105
  resolve();
103
106
  }
104
107
  });
@@ -114,9 +117,12 @@ export const createHttpServer = (port, screenshotHandler, services = {}) => {
114
117
  const stop = () => {
115
118
  if (server) {
116
119
  return new Promise(resolve => {
120
+ // Close all keep-alive connections immediately (Node 18.2+)
121
+ if (server.closeAllConnections) {
122
+ server.closeAllConnections();
123
+ }
117
124
  server.close(() => {
118
125
  server = null;
119
- output.debug('server', 'stopped');
120
126
  resolve();
121
127
  });
122
128
  });
@@ -67,6 +67,64 @@ export function createBaselineRouter({
67
67
  }
68
68
  }
69
69
 
70
+ // Reject a single comparison (keep current baseline, discard changes)
71
+ if (req.method === 'POST' && pathname === '/api/baseline/reject') {
72
+ if (!screenshotHandler?.rejectBaseline) {
73
+ sendError(res, 400, 'Baseline management not available');
74
+ return true;
75
+ }
76
+ try {
77
+ const {
78
+ id
79
+ } = await parseJsonBody(req);
80
+ if (!id) {
81
+ sendError(res, 400, 'Comparison ID required');
82
+ return true;
83
+ }
84
+ await screenshotHandler.rejectBaseline(id);
85
+ sendSuccess(res, {
86
+ success: true,
87
+ message: `Changes rejected for comparison ${id}`
88
+ });
89
+ return true;
90
+ } catch (error) {
91
+ output.error('Error rejecting baseline:', error);
92
+ sendError(res, 500, error.message);
93
+ return true;
94
+ }
95
+ }
96
+
97
+ // Delete a comparison entirely (removes from report and deletes files)
98
+ if (req.method === 'POST' && pathname === '/api/baseline/delete') {
99
+ if (!screenshotHandler?.deleteComparison) {
100
+ sendError(res, 400, 'Baseline management not available');
101
+ return true;
102
+ }
103
+ try {
104
+ const {
105
+ id
106
+ } = await parseJsonBody(req);
107
+ if (!id) {
108
+ sendError(res, 400, 'Comparison ID required');
109
+ return true;
110
+ }
111
+ await screenshotHandler.deleteComparison(id);
112
+ sendSuccess(res, {
113
+ success: true,
114
+ message: `Comparison ${id} deleted`
115
+ });
116
+ return true;
117
+ } catch (error) {
118
+ if (error.code === 'NOT_FOUND') {
119
+ sendError(res, 404, error.message);
120
+ } else {
121
+ output.error('Error deleting comparison:', error);
122
+ sendError(res, 500, error.message);
123
+ }
124
+ return true;
125
+ }
126
+ }
127
+
70
128
  // Reset baselines to previous state
71
129
  if (req.method === 'POST' && pathname === '/api/baseline/reset') {
72
130
  if (!screenshotHandler?.resetBaselines) {
@@ -9,14 +9,18 @@ import * as output from '../../utils/output.js';
9
9
  import { sendHtml, sendSuccess } from '../middleware/response.js';
10
10
 
11
11
  // SPA routes that should serve the dashboard HTML
12
- const SPA_ROUTES = ['/', '/dashboard', '/stats', '/settings', '/projects', '/builds'];
12
+ const SPA_ROUTES = ['/', '/stats', '/settings', '/projects', '/builds'];
13
13
 
14
14
  /**
15
15
  * Create dashboard router
16
16
  * @param {Object} context - Router context
17
+ * @param {string} context.workingDir - Working directory for report data
17
18
  * @returns {Function} Route handler
18
19
  */
19
- export function createDashboardRouter() {
20
+ export function createDashboardRouter(context) {
21
+ const {
22
+ workingDir = process.cwd()
23
+ } = context || {};
20
24
  return async function handleDashboardRoute(req, res, pathname) {
21
25
  if (req.method !== 'GET') {
22
26
  return false;
@@ -24,7 +28,7 @@ export function createDashboardRouter() {
24
28
 
25
29
  // API endpoint for fetching report data
26
30
  if (pathname === '/api/report-data') {
27
- const reportDataPath = join(process.cwd(), '.vizzly', 'report-data.json');
31
+ const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
28
32
  if (existsSync(reportDataPath)) {
29
33
  try {
30
34
  const data = readFileSync(reportDataPath, 'utf8');
@@ -50,8 +54,8 @@ export function createDashboardRouter() {
50
54
 
51
55
  // API endpoint for real-time status
52
56
  if (pathname === '/api/status') {
53
- const reportDataPath = join(process.cwd(), '.vizzly', 'report-data.json');
54
- const baselineMetadataPath = join(process.cwd(), '.vizzly', 'baselines', 'metadata.json');
57
+ const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
58
+ const baselineMetadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
55
59
  let reportData = null;
56
60
  let baselineInfo = null;
57
61
  if (existsSync(reportDataPath)) {
@@ -84,7 +88,7 @@ export function createDashboardRouter() {
84
88
 
85
89
  // Serve React SPA for dashboard routes
86
90
  if (SPA_ROUTES.includes(pathname) || pathname.startsWith('/comparison/')) {
87
- const reportDataPath = join(process.cwd(), '.vizzly', 'report-data.json');
91
+ const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
88
92
  let reportData = null;
89
93
  if (existsSync(reportDataPath)) {
90
94
  try {
@@ -52,6 +52,38 @@ export function createScreenshotRouter({
52
52
  }
53
53
  }
54
54
 
55
+ // Flush endpoint - signals test completion and prints summary
56
+ if (pathname === '/flush') {
57
+ try {
58
+ if (screenshotHandler.getResults) {
59
+ // This triggers printResults() which outputs the summary
60
+ const results = await screenshotHandler.getResults();
61
+ sendJson(res, 200, {
62
+ success: true,
63
+ summary: {
64
+ total: results.total || 0,
65
+ passed: results.passed || 0,
66
+ failed: results.failed || 0,
67
+ new: results.new || 0,
68
+ errors: results.errors || 0
69
+ }
70
+ });
71
+ } else {
72
+ sendJson(res, 200, {
73
+ success: true,
74
+ message: 'No TDD results'
75
+ });
76
+ }
77
+ return true;
78
+ } catch (error) {
79
+ output.debug('Flush error:', {
80
+ error: error.message
81
+ });
82
+ sendError(res, 500, 'Failed to flush');
83
+ return true;
84
+ }
85
+ }
86
+
55
87
  // Legacy accept-baseline endpoint
56
88
  if (pathname === '/accept-baseline') {
57
89
  try {