@vizzly-testing/cli 0.12.0 → 0.13.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.
@@ -1,5 +1,8 @@
1
1
  import { Buffer } from 'buffer';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { resolve } from 'path';
2
4
  import { createServiceLogger } from '../../utils/logger-factory.js';
5
+ import { detectImageInputType } from '../../utils/image-input-detector.js';
3
6
  const logger = createServiceLogger('API-HANDLER');
4
7
 
5
8
  /**
@@ -60,7 +63,56 @@ export const createApiHandler = apiService => {
60
63
  }
61
64
  };
62
65
  }
63
- const imageBuffer = Buffer.from(image, 'base64');
66
+
67
+ // Support both base64 encoded images and file paths
68
+ let imageBuffer;
69
+ const inputType = detectImageInputType(image);
70
+ if (inputType === 'file-path') {
71
+ // It's a file path - resolve and read the file
72
+ const filePath = resolve(image.replace('file://', ''));
73
+ if (!existsSync(filePath)) {
74
+ return {
75
+ statusCode: 400,
76
+ body: {
77
+ error: `Screenshot file not found: ${filePath}`,
78
+ originalPath: image
79
+ }
80
+ };
81
+ }
82
+ try {
83
+ imageBuffer = readFileSync(filePath);
84
+ logger.debug(`Loaded screenshot from file: ${filePath}`);
85
+ } catch (error) {
86
+ return {
87
+ statusCode: 500,
88
+ body: {
89
+ error: `Failed to read screenshot file: ${error.message}`,
90
+ filePath
91
+ }
92
+ };
93
+ }
94
+ } else if (inputType === 'base64') {
95
+ // It's base64 encoded
96
+ try {
97
+ imageBuffer = Buffer.from(image, 'base64');
98
+ } catch (error) {
99
+ return {
100
+ statusCode: 400,
101
+ body: {
102
+ error: `Invalid base64 image data: ${error.message}`
103
+ }
104
+ };
105
+ }
106
+ } else {
107
+ // Unknown input type
108
+ return {
109
+ statusCode: 400,
110
+ body: {
111
+ error: 'Invalid image input: must be a file path or base64 encoded image data',
112
+ receivedType: typeof image
113
+ }
114
+ };
115
+ }
64
116
  screenshotCount++;
65
117
 
66
118
  // Fire upload in background - DON'T AWAIT!
@@ -1,10 +1,96 @@
1
1
  import { Buffer } from 'buffer';
2
2
  import { writeFileSync, readFileSync, existsSync } from 'fs';
3
- import { join } from 'path';
3
+ import { join, resolve } from 'path';
4
+ import honeydiff from '@vizzly-testing/honeydiff';
4
5
  import { createServiceLogger } from '../../utils/logger-factory.js';
5
6
  import { TddService } from '../../services/tdd-service.js';
6
7
  import { sanitizeScreenshotName, validateScreenshotProperties } from '../../utils/security.js';
8
+ import { detectImageInputType } from '../../utils/image-input-detector.js';
9
+ let {
10
+ getDimensionsSync
11
+ } = honeydiff;
7
12
  const logger = createServiceLogger('TDD-HANDLER');
13
+
14
+ /**
15
+ * Group comparisons by screenshot name with variant structure
16
+ * Matches cloud product's grouping logic from comparison.js
17
+ */
18
+ const groupComparisons = comparisons => {
19
+ const groups = new Map();
20
+
21
+ // Group by screenshot name
22
+ for (const comp of comparisons) {
23
+ if (!groups.has(comp.name)) {
24
+ groups.set(comp.name, {
25
+ name: comp.name,
26
+ comparisons: [],
27
+ browsers: new Set(),
28
+ viewports: new Set(),
29
+ devices: new Set(),
30
+ totalVariants: 0
31
+ });
32
+ }
33
+ const group = groups.get(comp.name);
34
+ group.comparisons.push(comp);
35
+ group.totalVariants++;
36
+
37
+ // Track unique browsers, viewports, devices
38
+ if (comp.properties?.browser) {
39
+ group.browsers.add(comp.properties.browser);
40
+ }
41
+ if (comp.properties?.viewport_width && comp.properties?.viewport_height) {
42
+ group.viewports.add(`${comp.properties.viewport_width}x${comp.properties.viewport_height}`);
43
+ }
44
+ if (comp.properties?.device) {
45
+ group.devices.add(comp.properties.device);
46
+ }
47
+ }
48
+
49
+ // Convert to final structure
50
+ return Array.from(groups.values()).map(group => {
51
+ const browsers = Array.from(group.browsers);
52
+ const viewports = Array.from(group.viewports);
53
+ const devices = Array.from(group.devices);
54
+
55
+ // Build variants structure (browser -> viewport -> comparisons)
56
+ const variants = {};
57
+ group.comparisons.forEach(comp => {
58
+ const browser = comp.properties?.browser || null;
59
+ const viewport = comp.properties?.viewport_width && comp.properties?.viewport_height ? `${comp.properties.viewport_width}x${comp.properties.viewport_height}` : null;
60
+ if (!variants[browser]) variants[browser] = {};
61
+ if (!variants[browser][viewport]) variants[browser][viewport] = [];
62
+ variants[browser][viewport].push(comp);
63
+ });
64
+
65
+ // Determine grouping strategy
66
+ let groupingStrategy = 'flat';
67
+ if (browsers.length > 1) groupingStrategy = 'browser';else if (viewports.length > 1) groupingStrategy = 'viewport';
68
+
69
+ // Sort comparisons by viewport area (largest first)
70
+ group.comparisons.sort((a, b) => {
71
+ const aArea = (a.properties?.viewport_width || 0) * (a.properties?.viewport_height || 0);
72
+ const bArea = (b.properties?.viewport_width || 0) * (b.properties?.viewport_height || 0);
73
+ if (bArea !== aArea) return bArea - aArea;
74
+ return (b.properties?.viewport_width || 0) - (a.properties?.viewport_width || 0);
75
+ });
76
+ return {
77
+ ...group,
78
+ browsers,
79
+ viewports,
80
+ devices: Array.from(devices),
81
+ variants,
82
+ groupingStrategy
83
+ };
84
+ }).sort((a, b) => {
85
+ // Sort groups: multi-variant first (by variant count), then singles alphabetically
86
+ if (a.totalVariants > 1 && b.totalVariants === 1) return -1;
87
+ if (a.totalVariants === 1 && b.totalVariants > 1) return 1;
88
+ if (a.totalVariants > 1 && b.totalVariants > 1) {
89
+ return b.totalVariants - a.totalVariants;
90
+ }
91
+ return a.name.localeCompare(b.name);
92
+ });
93
+ };
8
94
  export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison, setBaseline = false) => {
9
95
  const tddService = new TddService(config, workingDir, setBaseline);
10
96
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
@@ -14,8 +100,12 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
14
100
  return {
15
101
  timestamp: Date.now(),
16
102
  comparisons: [],
103
+ // Internal flat list for easy updates
104
+ groups: [],
105
+ // Grouped structure for UI
17
106
  summary: {
18
107
  total: 0,
108
+ groups: 0,
19
109
  passed: 0,
20
110
  failed: 0,
21
111
  errors: 0
@@ -29,8 +119,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
29
119
  return {
30
120
  timestamp: Date.now(),
31
121
  comparisons: [],
122
+ groups: [],
32
123
  summary: {
33
124
  total: 0,
125
+ groups: 0,
34
126
  passed: 0,
35
127
  failed: 0,
36
128
  errors: 0
@@ -42,26 +134,36 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
42
134
  try {
43
135
  const reportData = readReportData();
44
136
 
45
- // Find existing comparison with same name and replace it, or add new one
46
- const existingIndex = reportData.comparisons.findIndex(c => c.name === newComparison.name);
137
+ // Ensure comparisons array exists (backward compatibility)
138
+ if (!reportData.comparisons) {
139
+ reportData.comparisons = [];
140
+ }
141
+
142
+ // Find existing comparison by unique ID
143
+ // This ensures we update the correct variant even with same name
144
+ const existingIndex = reportData.comparisons.findIndex(c => c.id === newComparison.id);
47
145
  if (existingIndex >= 0) {
48
146
  reportData.comparisons[existingIndex] = newComparison;
49
- logger.debug(`Updated comparison for ${newComparison.name}`);
147
+ logger.debug(`Updated comparison for ${newComparison.name} (${newComparison.properties?.viewport_width}x${newComparison.properties?.viewport_height})`);
50
148
  } else {
51
149
  reportData.comparisons.push(newComparison);
52
- logger.debug(`Added new comparison for ${newComparison.name}`);
150
+ logger.debug(`Added new comparison for ${newComparison.name} (${newComparison.properties?.viewport_width}x${newComparison.properties?.viewport_height})`);
53
151
  }
54
152
 
153
+ // Generate grouped structure from flat comparisons
154
+ reportData.groups = groupComparisons(reportData.comparisons);
155
+
55
156
  // Update summary
56
157
  reportData.timestamp = Date.now();
57
158
  reportData.summary = {
58
159
  total: reportData.comparisons.length,
59
- passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created').length,
160
+ groups: reportData.groups.length,
161
+ passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'new').length,
60
162
  failed: reportData.comparisons.filter(c => c.status === 'failed').length,
61
163
  errors: reportData.comparisons.filter(c => c.status === 'error').length
62
164
  };
63
165
  writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
64
- logger.debug('Report data saved to report-data.json');
166
+ logger.debug('Report data saved with grouped structure');
65
167
  } catch (error) {
66
168
  logger.error('Failed to update comparison:', error);
67
169
  }
@@ -111,10 +213,24 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
111
213
  };
112
214
  }
113
215
 
216
+ // Unwrap double-nested properties if needed (client SDK wraps options in properties field)
217
+ // This happens when test helper passes { properties: {...}, threshold: 0.1 }
218
+ // and client SDK wraps it as { properties: options }
219
+ let unwrappedProperties = properties;
220
+ if (properties.properties && typeof properties.properties === 'object') {
221
+ // Merge top-level properties with nested properties
222
+ unwrappedProperties = {
223
+ ...properties,
224
+ ...properties.properties
225
+ };
226
+ // Remove the nested properties field to avoid confusion
227
+ delete unwrappedProperties.properties;
228
+ }
229
+
114
230
  // Validate and sanitize properties
115
231
  let validatedProperties;
116
232
  try {
117
- validatedProperties = validateScreenshotProperties(properties);
233
+ validatedProperties = validateScreenshotProperties(unwrappedProperties);
118
234
  } catch (error) {
119
235
  return {
120
236
  statusCode: 400,
@@ -125,14 +241,95 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
125
241
  }
126
242
  };
127
243
  }
128
- const imageBuffer = Buffer.from(image, 'base64');
129
- logger.debug(`Received screenshot: ${name}`);
130
- logger.debug(`Image size: ${imageBuffer.length} bytes`);
131
- logger.debug(`Properties: ${JSON.stringify(validatedProperties)}`);
244
+
245
+ // Extract viewport/browser to top-level properties (matching cloud API behavior)
246
+ // This ensures signature generation works correctly with: name|viewport_width|browser
247
+ const extractedProperties = {
248
+ viewport_width: validatedProperties.viewport?.width || null,
249
+ viewport_height: validatedProperties.viewport?.height || null,
250
+ browser: validatedProperties.browser || null,
251
+ device: validatedProperties.device || null,
252
+ url: validatedProperties.url || null,
253
+ selector: validatedProperties.selector || null,
254
+ threshold: validatedProperties.threshold,
255
+ // Preserve full nested structure in metadata for compatibility
256
+ metadata: validatedProperties
257
+ };
258
+
259
+ // Support both base64 encoded images and file paths
260
+ // Vitest browser mode returns file paths, so we need to handle both
261
+ let imageBuffer;
262
+ const inputType = detectImageInputType(image);
263
+ if (inputType === 'file-path') {
264
+ // It's a file path - resolve and read the file
265
+ const filePath = resolve(image.replace('file://', ''));
266
+ if (!existsSync(filePath)) {
267
+ return {
268
+ statusCode: 400,
269
+ body: {
270
+ error: `Screenshot file not found: ${filePath}`,
271
+ originalPath: image,
272
+ tddMode: true
273
+ }
274
+ };
275
+ }
276
+ try {
277
+ imageBuffer = readFileSync(filePath);
278
+ logger.debug(`Loaded screenshot from file: ${filePath}`);
279
+ } catch (error) {
280
+ return {
281
+ statusCode: 500,
282
+ body: {
283
+ error: `Failed to read screenshot file: ${error.message}`,
284
+ filePath,
285
+ tddMode: true
286
+ }
287
+ };
288
+ }
289
+ } else if (inputType === 'base64') {
290
+ // It's base64 encoded
291
+ try {
292
+ imageBuffer = Buffer.from(image, 'base64');
293
+ } catch (error) {
294
+ return {
295
+ statusCode: 400,
296
+ body: {
297
+ error: `Invalid base64 image data: ${error.message}`,
298
+ tddMode: true
299
+ }
300
+ };
301
+ }
302
+ } else {
303
+ // Unknown input type
304
+ return {
305
+ statusCode: 400,
306
+ body: {
307
+ error: 'Invalid image input: must be a file path or base64 encoded image data',
308
+ receivedType: typeof image,
309
+ tddMode: true
310
+ }
311
+ };
312
+ }
313
+
314
+ // Auto-detect image dimensions if viewport not provided
315
+ if (!extractedProperties.viewport_width || !extractedProperties.viewport_height) {
316
+ try {
317
+ const dimensions = getDimensionsSync(imageBuffer);
318
+ if (!extractedProperties.viewport_width) {
319
+ extractedProperties.viewport_width = dimensions.width;
320
+ }
321
+ if (!extractedProperties.viewport_height) {
322
+ extractedProperties.viewport_height = dimensions.height;
323
+ }
324
+ logger.debug(`Auto-detected dimensions: ${dimensions.width}x${dimensions.height}`);
325
+ } catch (err) {
326
+ logger.debug(`Failed to auto-detect dimensions: ${err.message}`);
327
+ }
328
+ }
132
329
 
133
330
  // Use the sanitized name as-is (no modification with browser/viewport)
134
331
  // Baseline matching uses signature logic (name + viewport_width + browser)
135
- const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, validatedProperties);
332
+ const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties);
136
333
  logger.debug(`Comparison result: ${comparison.status}`);
137
334
 
138
335
  // Convert absolute file paths to web-accessible URLs
@@ -149,6 +346,8 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
149
346
 
150
347
  // Record the comparison for the dashboard
151
348
  const newComparison = {
349
+ id: comparison.id,
350
+ // Include unique ID for variant identification
152
351
  name: comparison.name,
153
352
  originalName: name,
154
353
  status: comparison.status,
@@ -157,7 +356,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
157
356
  diff: convertPathToUrl(comparison.diff),
158
357
  diffPercentage: comparison.diffPercentage,
159
358
  threshold: comparison.threshold,
160
- properties: validatedProperties,
359
+ properties: extractedProperties,
360
+ // Use extracted properties with top-level viewport_width/browser
361
+ signature: comparison.signature,
362
+ // Include signature for debugging
161
363
  timestamp: Date.now()
162
364
  };
163
365
 
@@ -223,16 +425,14 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
223
425
  const getResults = async () => {
224
426
  return await tddService.printResults();
225
427
  };
226
- const acceptBaseline = async screenshotName => {
428
+ const acceptBaseline = async comparisonId => {
227
429
  try {
228
- logger.debug(`Accepting baseline for screenshot: ${screenshotName}`);
229
-
230
430
  // Use TDD service to accept the baseline
231
- const result = await tddService.acceptBaseline(screenshotName);
431
+ const result = await tddService.acceptBaseline(comparisonId);
232
432
 
233
433
  // Read current report data and update the comparison status
234
434
  const reportData = readReportData();
235
- const comparison = reportData.comparisons.find(c => c.name === screenshotName);
435
+ const comparison = reportData.comparisons.find(c => c.id === comparisonId);
236
436
  if (comparison) {
237
437
  // Update the comparison to passed status
238
438
  const updatedComparison = {
@@ -242,14 +442,13 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
242
442
  diff: null
243
443
  };
244
444
  updateComparison(updatedComparison);
245
- logger.debug('Comparison updated in report-data.json');
246
445
  } else {
247
- logger.error(`Comparison not found in report data for: ${screenshotName}`);
446
+ logger.error(`Comparison not found in report data for ID: ${comparisonId}`);
248
447
  }
249
- logger.info(`Baseline accepted for ${screenshotName}`);
448
+ logger.info(`Baseline accepted for comparison ${comparisonId}`);
250
449
  return result;
251
450
  } catch (error) {
252
- logger.error(`Failed to accept baseline for ${screenshotName}:`, error);
451
+ logger.error(`Failed to accept baseline for ${comparisonId}:`, error);
253
452
  throw error;
254
453
  }
255
454
  };
@@ -262,7 +461,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
262
461
  // Accept all failed or new comparisons
263
462
  for (const comparison of reportData.comparisons) {
264
463
  if (comparison.status === 'failed' || comparison.status === 'new') {
265
- await tddService.acceptBaseline(comparison.name);
464
+ await tddService.acceptBaseline(comparison.id);
266
465
 
267
466
  // Update the comparison to passed status
268
467
  updateComparison({
@@ -363,8 +562,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
363
562
  const freshReportData = {
364
563
  timestamp: Date.now(),
365
564
  comparisons: [],
565
+ groups: [],
366
566
  summary: {
367
567
  total: 0,
568
+ groups: 0,
368
569
  passed: 0,
369
570
  failed: 0,
370
571
  errors: 0
@@ -233,21 +233,21 @@ export const createHttpServer = (port, screenshotHandler) => {
233
233
  }
234
234
  try {
235
235
  const {
236
- name
236
+ id
237
237
  } = await parseRequestBody(req);
238
- if (!name) {
238
+ if (!id) {
239
239
  res.statusCode = 400;
240
240
  res.end(JSON.stringify({
241
- error: 'Screenshot name required'
241
+ error: 'Comparison ID required'
242
242
  }));
243
243
  return;
244
244
  }
245
- await screenshotHandler.acceptBaseline(name);
245
+ await screenshotHandler.acceptBaseline(id);
246
246
  res.setHeader('Content-Type', 'application/json');
247
247
  res.statusCode = 200;
248
248
  res.end(JSON.stringify({
249
249
  success: true,
250
- message: `Baseline accepted for ${name}`
250
+ message: `Baseline accepted for comparison ${id}`
251
251
  }));
252
252
  } catch (error) {
253
253
  logger.error('Error accepting baseline:', error);
@@ -368,19 +368,19 @@ export const createHttpServer = (port, screenshotHandler) => {
368
368
  try {
369
369
  const body = await parseRequestBody(req);
370
370
  const {
371
- name
371
+ id
372
372
  } = body;
373
- if (!name) {
373
+ if (!id) {
374
374
  res.statusCode = 400;
375
375
  res.end(JSON.stringify({
376
- error: 'screenshot name is required'
376
+ error: 'comparison ID is required'
377
377
  }));
378
378
  return;
379
379
  }
380
380
 
381
381
  // Call the screenshot handler's accept baseline method if it exists
382
382
  if (screenshotHandler.acceptBaseline) {
383
- const result = await screenshotHandler.acceptBaseline(name);
383
+ const result = await screenshotHandler.acceptBaseline(id);
384
384
  res.statusCode = 200;
385
385
  res.end(JSON.stringify({
386
386
  success: true,