@vizzly-testing/cli 0.20.1-beta.0 → 0.20.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 (38) hide show
  1. package/README.md +16 -18
  2. package/dist/cli.js +177 -2
  3. package/dist/client/index.js +144 -77
  4. package/dist/commands/doctor.js +118 -33
  5. package/dist/commands/finalize.js +8 -3
  6. package/dist/commands/init.js +13 -18
  7. package/dist/commands/login.js +42 -49
  8. package/dist/commands/logout.js +13 -5
  9. package/dist/commands/project.js +95 -67
  10. package/dist/commands/run.js +32 -6
  11. package/dist/commands/status.js +81 -50
  12. package/dist/commands/tdd-daemon.js +61 -32
  13. package/dist/commands/tdd.js +14 -26
  14. package/dist/commands/upload.js +18 -9
  15. package/dist/commands/whoami.js +40 -38
  16. package/dist/reporter/reporter-bundle.css +1 -1
  17. package/dist/reporter/reporter-bundle.iife.js +204 -22
  18. package/dist/server/handlers/tdd-handler.js +113 -7
  19. package/dist/server/http-server.js +9 -3
  20. package/dist/server/routers/baseline.js +58 -0
  21. package/dist/server/routers/dashboard.js +10 -6
  22. package/dist/server/routers/screenshot.js +32 -0
  23. package/dist/server-manager/core.js +5 -2
  24. package/dist/server-manager/operations.js +2 -1
  25. package/dist/services/config-service.js +306 -0
  26. package/dist/tdd/tdd-service.js +190 -126
  27. package/dist/types/client.d.ts +25 -2
  28. package/dist/utils/colors.js +187 -39
  29. package/dist/utils/config-loader.js +3 -6
  30. package/dist/utils/context.js +228 -0
  31. package/dist/utils/output.js +449 -14
  32. package/docs/api-reference.md +173 -8
  33. package/docs/tui-elements.md +560 -0
  34. package/package.json +13 -7
  35. package/dist/report-generator/core.js +0 -315
  36. package/dist/report-generator/index.js +0 -8
  37. package/dist/report-generator/operations.js +0 -196
  38. package/dist/services/static-report-generator.js +0 -65
@@ -1,11 +1,11 @@
1
1
  import { Buffer as defaultBuffer } from 'node:buffer';
2
- import { existsSync as defaultExistsSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
2
+ import { existsSync as defaultExistsSync, readFileSync as defaultReadFileSync, unlinkSync as defaultUnlinkSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
3
3
  import { join as defaultJoin, resolve as defaultResolve } from 'node:path';
4
4
  import { getDimensionsSync as defaultGetDimensionsSync } from '@vizzly-testing/honeydiff';
5
5
  import { TddService as DefaultTddService } from '../../tdd/tdd-service.js';
6
6
  import { detectImageInputType as defaultDetectImageInputType } from '../../utils/image-input-detector.js';
7
7
  import * as defaultOutput from '../../utils/output.js';
8
- import { sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
8
+ import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
9
9
 
10
10
  /**
11
11
  * Unwrap double-nested properties if needed
@@ -142,12 +142,14 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
142
142
  TddService = DefaultTddService,
143
143
  existsSync = defaultExistsSync,
144
144
  readFileSync = defaultReadFileSync,
145
+ unlinkSync = defaultUnlinkSync,
145
146
  writeFileSync = defaultWriteFileSync,
146
147
  join = defaultJoin,
147
148
  resolve = defaultResolve,
148
149
  Buffer = defaultBuffer,
149
150
  getDimensionsSync = defaultGetDimensionsSync,
150
151
  detectImageInputType = defaultDetectImageInputType,
152
+ safePath = defaultSafePath,
151
153
  sanitizeScreenshotName = defaultSanitizeScreenshotName,
152
154
  validateScreenshotProperties = defaultValidateScreenshotProperties,
153
155
  output = defaultOutput
@@ -228,6 +230,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
228
230
  groups: reportData.groups.length,
229
231
  passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'new').length,
230
232
  failed: reportData.comparisons.filter(c => c.status === 'failed').length,
233
+ rejected: reportData.comparisons.filter(c => c.status === 'rejected').length,
231
234
  errors: reportData.comparisons.filter(c => c.status === 'error').length
232
235
  };
233
236
  writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
@@ -236,7 +239,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
236
239
  }
237
240
  };
238
241
  const initialize = async () => {
239
- output.debug('tdd', 'setting up local comparison');
242
+ output.debug('tdd', 'initializing local mode');
240
243
 
241
244
  // In baseline update mode, skip all baseline loading/downloading
242
245
  if (setBaseline) {
@@ -247,7 +250,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
247
250
  // Check if we have baseline override flags that should force a fresh download
248
251
  const shouldForceDownload = (baselineBuild || baselineComparison) && config.apiKey;
249
252
  if (shouldForceDownload) {
250
- output.debug('tdd', 'downloading baselines from cloud');
253
+ output.debug('tdd', 'downloading baselines');
251
254
  await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
252
255
  return;
253
256
  }
@@ -255,13 +258,13 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
255
258
  if (!baseline) {
256
259
  // Only download baselines if explicitly requested via baseline flags
257
260
  if ((baselineBuild || baselineComparison) && config.apiKey) {
258
- output.debug('tdd', 'downloading baselines from cloud');
261
+ output.debug('tdd', 'downloading baselines');
259
262
  await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
260
263
  } else {
261
- output.debug('tdd', 'no baselines found, will create on first run');
264
+ output.debug('tdd', 'no baselines yet');
262
265
  }
263
266
  } else {
264
- output.debug('tdd', `using baseline: ${baseline.buildName}`);
267
+ output.debug('tdd', `baseline: ${baseline.buildName}`);
265
268
  }
266
269
  };
267
270
  const handleScreenshot = async (_buildId, name, image, properties = {}) => {
@@ -488,6 +491,33 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
488
491
  throw error;
489
492
  }
490
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
+ };
491
521
  const acceptAllBaselines = async () => {
492
522
  try {
493
523
  output.debug('tdd', 'accepting all baselines');
@@ -622,6 +652,80 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
622
652
  throw error;
623
653
  }
624
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
+ };
625
729
  const cleanup = () => {
626
730
  // Report data is persisted to file, no in-memory cleanup needed
627
731
  };
@@ -630,8 +734,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
630
734
  handleScreenshot,
631
735
  getResults,
632
736
  acceptBaseline,
737
+ rejectBaseline,
633
738
  acceptAllBaselines,
634
739
  resetBaselines,
740
+ deleteComparison,
635
741
  cleanup,
636
742
  // Expose tddService for baseline download operations
637
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 {
@@ -75,17 +75,20 @@ export function buildServerInfo({
75
75
  * @param {Object} [options.services] - Base services object
76
76
  * @param {string|null} [options.buildId] - Build ID
77
77
  * @param {Object|null} [options.tddService] - TDD service (only in TDD mode)
78
+ * @param {string|null} [options.workingDir] - Working directory for report data
78
79
  * @returns {Object} Services object with extras
79
80
  */
80
81
  export function buildServicesWithExtras({
81
82
  services = {},
82
83
  buildId = null,
83
- tddService = null
84
+ tddService = null,
85
+ workingDir = null
84
86
  }) {
85
87
  return {
86
88
  ...services,
87
89
  buildId,
88
- tddService
90
+ tddService,
91
+ workingDir
89
92
  };
90
93
  }
91
94
 
@@ -71,7 +71,8 @@ export async function startServer({
71
71
  let servicesWithExtras = buildServicesWithExtras({
72
72
  services,
73
73
  buildId,
74
- tddService: tddMode ? handler.tddService : null
74
+ tddService: tddMode ? handler.tddService : null,
75
+ workingDir: projectRoot
75
76
  });
76
77
 
77
78
  // Create and start HTTP server