@vizzly-testing/cli 0.12.0 → 0.13.0

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,134 @@
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
4
  import { createServiceLogger } from '../../utils/logger-factory.js';
5
5
  import { TddService } from '../../services/tdd-service.js';
6
6
  import { sanitizeScreenshotName, validateScreenshotProperties } from '../../utils/security.js';
7
+ import { detectImageInputType } from '../../utils/image-input-detector.js';
7
8
  const logger = createServiceLogger('TDD-HANDLER');
9
+
10
+ /**
11
+ * Detect PNG dimensions by reading the IHDR chunk header
12
+ * PNG spec (ISO/IEC 15948:2004) guarantees width/height at bytes 16-23
13
+ * @param {Buffer} buffer - PNG image buffer
14
+ * @returns {{ width: number, height: number } | null} Dimensions or null if not a valid PNG
15
+ */
16
+ const detectPNGDimensions = buffer => {
17
+ // Full PNG signature (8 bytes): 89 50 4E 47 0D 0A 1A 0A
18
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
19
+
20
+ // Need at least 24 bytes (8 signature + 4 length + 4 type + 8 width/height)
21
+ if (!buffer || buffer.length < 24) {
22
+ return null;
23
+ }
24
+
25
+ // Validate full 8-byte PNG signature
26
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
27
+ if (buffer[i] !== PNG_SIGNATURE[i]) {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // Validate IHDR chunk type at bytes 12-15 (should be 'IHDR')
33
+ // 0x49484452 = 'IHDR' in ASCII
34
+ if (buffer[12] !== 0x49 || buffer[13] !== 0x48 || buffer[14] !== 0x44 || buffer[15] !== 0x52) {
35
+ return null;
36
+ }
37
+
38
+ // Read width and height from IHDR chunk (guaranteed positions per PNG spec)
39
+ const width = buffer.readUInt32BE(16); // Bytes 16-19
40
+ const height = buffer.readUInt32BE(20); // Bytes 20-23
41
+
42
+ // Sanity check: dimensions should be positive and reasonable
43
+ if (width <= 0 || height <= 0 || width > 65535 || height > 65535) {
44
+ return null;
45
+ }
46
+ return {
47
+ width,
48
+ height
49
+ };
50
+ };
51
+
52
+ /**
53
+ * Group comparisons by screenshot name with variant structure
54
+ * Matches cloud product's grouping logic from comparison.js
55
+ */
56
+ const groupComparisons = comparisons => {
57
+ const groups = new Map();
58
+
59
+ // Group by screenshot name
60
+ for (const comp of comparisons) {
61
+ if (!groups.has(comp.name)) {
62
+ groups.set(comp.name, {
63
+ name: comp.name,
64
+ comparisons: [],
65
+ browsers: new Set(),
66
+ viewports: new Set(),
67
+ devices: new Set(),
68
+ totalVariants: 0
69
+ });
70
+ }
71
+ const group = groups.get(comp.name);
72
+ group.comparisons.push(comp);
73
+ group.totalVariants++;
74
+
75
+ // Track unique browsers, viewports, devices
76
+ if (comp.properties?.browser) {
77
+ group.browsers.add(comp.properties.browser);
78
+ }
79
+ if (comp.properties?.viewport_width && comp.properties?.viewport_height) {
80
+ group.viewports.add(`${comp.properties.viewport_width}x${comp.properties.viewport_height}`);
81
+ }
82
+ if (comp.properties?.device) {
83
+ group.devices.add(comp.properties.device);
84
+ }
85
+ }
86
+
87
+ // Convert to final structure
88
+ return Array.from(groups.values()).map(group => {
89
+ const browsers = Array.from(group.browsers);
90
+ const viewports = Array.from(group.viewports);
91
+ const devices = Array.from(group.devices);
92
+
93
+ // Build variants structure (browser -> viewport -> comparisons)
94
+ const variants = {};
95
+ group.comparisons.forEach(comp => {
96
+ const browser = comp.properties?.browser || null;
97
+ const viewport = comp.properties?.viewport_width && comp.properties?.viewport_height ? `${comp.properties.viewport_width}x${comp.properties.viewport_height}` : null;
98
+ if (!variants[browser]) variants[browser] = {};
99
+ if (!variants[browser][viewport]) variants[browser][viewport] = [];
100
+ variants[browser][viewport].push(comp);
101
+ });
102
+
103
+ // Determine grouping strategy
104
+ let groupingStrategy = 'flat';
105
+ if (browsers.length > 1) groupingStrategy = 'browser';else if (viewports.length > 1) groupingStrategy = 'viewport';
106
+
107
+ // Sort comparisons by viewport area (largest first)
108
+ group.comparisons.sort((a, b) => {
109
+ const aArea = (a.properties?.viewport_width || 0) * (a.properties?.viewport_height || 0);
110
+ const bArea = (b.properties?.viewport_width || 0) * (b.properties?.viewport_height || 0);
111
+ if (bArea !== aArea) return bArea - aArea;
112
+ return (b.properties?.viewport_width || 0) - (a.properties?.viewport_width || 0);
113
+ });
114
+ return {
115
+ ...group,
116
+ browsers,
117
+ viewports,
118
+ devices: Array.from(devices),
119
+ variants,
120
+ groupingStrategy
121
+ };
122
+ }).sort((a, b) => {
123
+ // Sort groups: multi-variant first (by variant count), then singles alphabetically
124
+ if (a.totalVariants > 1 && b.totalVariants === 1) return -1;
125
+ if (a.totalVariants === 1 && b.totalVariants > 1) return 1;
126
+ if (a.totalVariants > 1 && b.totalVariants > 1) {
127
+ return b.totalVariants - a.totalVariants;
128
+ }
129
+ return a.name.localeCompare(b.name);
130
+ });
131
+ };
8
132
  export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison, setBaseline = false) => {
9
133
  const tddService = new TddService(config, workingDir, setBaseline);
10
134
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
@@ -14,8 +138,12 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
14
138
  return {
15
139
  timestamp: Date.now(),
16
140
  comparisons: [],
141
+ // Internal flat list for easy updates
142
+ groups: [],
143
+ // Grouped structure for UI
17
144
  summary: {
18
145
  total: 0,
146
+ groups: 0,
19
147
  passed: 0,
20
148
  failed: 0,
21
149
  errors: 0
@@ -29,8 +157,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
29
157
  return {
30
158
  timestamp: Date.now(),
31
159
  comparisons: [],
160
+ groups: [],
32
161
  summary: {
33
162
  total: 0,
163
+ groups: 0,
34
164
  passed: 0,
35
165
  failed: 0,
36
166
  errors: 0
@@ -42,26 +172,36 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
42
172
  try {
43
173
  const reportData = readReportData();
44
174
 
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);
175
+ // Ensure comparisons array exists (backward compatibility)
176
+ if (!reportData.comparisons) {
177
+ reportData.comparisons = [];
178
+ }
179
+
180
+ // Find existing comparison by unique ID
181
+ // This ensures we update the correct variant even with same name
182
+ const existingIndex = reportData.comparisons.findIndex(c => c.id === newComparison.id);
47
183
  if (existingIndex >= 0) {
48
184
  reportData.comparisons[existingIndex] = newComparison;
49
- logger.debug(`Updated comparison for ${newComparison.name}`);
185
+ logger.debug(`Updated comparison for ${newComparison.name} (${newComparison.properties?.viewport_width}x${newComparison.properties?.viewport_height})`);
50
186
  } else {
51
187
  reportData.comparisons.push(newComparison);
52
- logger.debug(`Added new comparison for ${newComparison.name}`);
188
+ logger.debug(`Added new comparison for ${newComparison.name} (${newComparison.properties?.viewport_width}x${newComparison.properties?.viewport_height})`);
53
189
  }
54
190
 
191
+ // Generate grouped structure from flat comparisons
192
+ reportData.groups = groupComparisons(reportData.comparisons);
193
+
55
194
  // Update summary
56
195
  reportData.timestamp = Date.now();
57
196
  reportData.summary = {
58
197
  total: reportData.comparisons.length,
59
- passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created').length,
198
+ groups: reportData.groups.length,
199
+ passed: reportData.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'new').length,
60
200
  failed: reportData.comparisons.filter(c => c.status === 'failed').length,
61
201
  errors: reportData.comparisons.filter(c => c.status === 'error').length
62
202
  };
63
203
  writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
64
- logger.debug('Report data saved to report-data.json');
204
+ logger.debug('Report data saved with grouped structure');
65
205
  } catch (error) {
66
206
  logger.error('Failed to update comparison:', error);
67
207
  }
@@ -111,10 +251,24 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
111
251
  };
112
252
  }
113
253
 
254
+ // Unwrap double-nested properties if needed (client SDK wraps options in properties field)
255
+ // This happens when test helper passes { properties: {...}, threshold: 0.1 }
256
+ // and client SDK wraps it as { properties: options }
257
+ let unwrappedProperties = properties;
258
+ if (properties.properties && typeof properties.properties === 'object') {
259
+ // Merge top-level properties with nested properties
260
+ unwrappedProperties = {
261
+ ...properties,
262
+ ...properties.properties
263
+ };
264
+ // Remove the nested properties field to avoid confusion
265
+ delete unwrappedProperties.properties;
266
+ }
267
+
114
268
  // Validate and sanitize properties
115
269
  let validatedProperties;
116
270
  try {
117
- validatedProperties = validateScreenshotProperties(properties);
271
+ validatedProperties = validateScreenshotProperties(unwrappedProperties);
118
272
  } catch (error) {
119
273
  return {
120
274
  statusCode: 400,
@@ -125,14 +279,94 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
125
279
  }
126
280
  };
127
281
  }
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)}`);
282
+
283
+ // Extract viewport/browser to top-level properties (matching cloud API behavior)
284
+ // This ensures signature generation works correctly with: name|viewport_width|browser
285
+ const extractedProperties = {
286
+ viewport_width: validatedProperties.viewport?.width || null,
287
+ viewport_height: validatedProperties.viewport?.height || null,
288
+ browser: validatedProperties.browser || null,
289
+ device: validatedProperties.device || null,
290
+ url: validatedProperties.url || null,
291
+ selector: validatedProperties.selector || null,
292
+ threshold: validatedProperties.threshold,
293
+ // Preserve full nested structure in metadata for compatibility
294
+ metadata: validatedProperties
295
+ };
296
+
297
+ // Support both base64 encoded images and file paths
298
+ // Vitest browser mode returns file paths, so we need to handle both
299
+ let imageBuffer;
300
+ const inputType = detectImageInputType(image);
301
+ if (inputType === 'file-path') {
302
+ // It's a file path - resolve and read the file
303
+ const filePath = resolve(image.replace('file://', ''));
304
+ if (!existsSync(filePath)) {
305
+ return {
306
+ statusCode: 400,
307
+ body: {
308
+ error: `Screenshot file not found: ${filePath}`,
309
+ originalPath: image,
310
+ tddMode: true
311
+ }
312
+ };
313
+ }
314
+ try {
315
+ imageBuffer = readFileSync(filePath);
316
+ logger.debug(`Loaded screenshot from file: ${filePath}`);
317
+ } catch (error) {
318
+ return {
319
+ statusCode: 500,
320
+ body: {
321
+ error: `Failed to read screenshot file: ${error.message}`,
322
+ filePath,
323
+ tddMode: true
324
+ }
325
+ };
326
+ }
327
+ } else if (inputType === 'base64') {
328
+ // It's base64 encoded
329
+ try {
330
+ imageBuffer = Buffer.from(image, 'base64');
331
+ } catch (error) {
332
+ return {
333
+ statusCode: 400,
334
+ body: {
335
+ error: `Invalid base64 image data: ${error.message}`,
336
+ tddMode: true
337
+ }
338
+ };
339
+ }
340
+ } else {
341
+ // Unknown input type
342
+ return {
343
+ statusCode: 400,
344
+ body: {
345
+ error: 'Invalid image input: must be a file path or base64 encoded image data',
346
+ receivedType: typeof image,
347
+ tddMode: true
348
+ }
349
+ };
350
+ }
351
+
352
+ // Auto-detect image dimensions from PNG header if viewport not provided
353
+ // This matches cloud API behavior but without requiring Sharp
354
+ if (!extractedProperties.viewport_width || !extractedProperties.viewport_height) {
355
+ const dimensions = detectPNGDimensions(imageBuffer);
356
+ if (dimensions) {
357
+ if (!extractedProperties.viewport_width) {
358
+ extractedProperties.viewport_width = dimensions.width;
359
+ }
360
+ if (!extractedProperties.viewport_height) {
361
+ extractedProperties.viewport_height = dimensions.height;
362
+ }
363
+ logger.debug(`Auto-detected dimensions from PNG: ${dimensions.width}x${dimensions.height}`);
364
+ }
365
+ }
132
366
 
133
367
  // Use the sanitized name as-is (no modification with browser/viewport)
134
368
  // Baseline matching uses signature logic (name + viewport_width + browser)
135
- const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, validatedProperties);
369
+ const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties);
136
370
  logger.debug(`Comparison result: ${comparison.status}`);
137
371
 
138
372
  // Convert absolute file paths to web-accessible URLs
@@ -149,6 +383,8 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
149
383
 
150
384
  // Record the comparison for the dashboard
151
385
  const newComparison = {
386
+ id: comparison.id,
387
+ // Include unique ID for variant identification
152
388
  name: comparison.name,
153
389
  originalName: name,
154
390
  status: comparison.status,
@@ -157,7 +393,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
157
393
  diff: convertPathToUrl(comparison.diff),
158
394
  diffPercentage: comparison.diffPercentage,
159
395
  threshold: comparison.threshold,
160
- properties: validatedProperties,
396
+ properties: extractedProperties,
397
+ // Use extracted properties with top-level viewport_width/browser
398
+ signature: comparison.signature,
399
+ // Include signature for debugging
161
400
  timestamp: Date.now()
162
401
  };
163
402
 
@@ -223,16 +462,14 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
223
462
  const getResults = async () => {
224
463
  return await tddService.printResults();
225
464
  };
226
- const acceptBaseline = async screenshotName => {
465
+ const acceptBaseline = async comparisonId => {
227
466
  try {
228
- logger.debug(`Accepting baseline for screenshot: ${screenshotName}`);
229
-
230
467
  // Use TDD service to accept the baseline
231
- const result = await tddService.acceptBaseline(screenshotName);
468
+ const result = await tddService.acceptBaseline(comparisonId);
232
469
 
233
470
  // Read current report data and update the comparison status
234
471
  const reportData = readReportData();
235
- const comparison = reportData.comparisons.find(c => c.name === screenshotName);
472
+ const comparison = reportData.comparisons.find(c => c.id === comparisonId);
236
473
  if (comparison) {
237
474
  // Update the comparison to passed status
238
475
  const updatedComparison = {
@@ -242,14 +479,13 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
242
479
  diff: null
243
480
  };
244
481
  updateComparison(updatedComparison);
245
- logger.debug('Comparison updated in report-data.json');
246
482
  } else {
247
- logger.error(`Comparison not found in report data for: ${screenshotName}`);
483
+ logger.error(`Comparison not found in report data for ID: ${comparisonId}`);
248
484
  }
249
- logger.info(`Baseline accepted for ${screenshotName}`);
485
+ logger.info(`Baseline accepted for comparison ${comparisonId}`);
250
486
  return result;
251
487
  } catch (error) {
252
- logger.error(`Failed to accept baseline for ${screenshotName}:`, error);
488
+ logger.error(`Failed to accept baseline for ${comparisonId}:`, error);
253
489
  throw error;
254
490
  }
255
491
  };
@@ -262,7 +498,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
262
498
  // Accept all failed or new comparisons
263
499
  for (const comparison of reportData.comparisons) {
264
500
  if (comparison.status === 'failed' || comparison.status === 'new') {
265
- await tddService.acceptBaseline(comparison.name);
501
+ await tddService.acceptBaseline(comparison.id);
266
502
 
267
503
  // Update the comparison to passed status
268
504
  updateComparison({
@@ -363,8 +599,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
363
599
  const freshReportData = {
364
600
  timestamp: Date.now(),
365
601
  comparisons: [],
602
+ groups: [],
366
603
  summary: {
367
604
  total: 0,
605
+ groups: 0,
368
606
  passed: 0,
369
607
  failed: 0,
370
608
  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,