@vizzly-testing/cli 0.20.0 → 0.20.1-beta.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.
Files changed (72) 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 +1 -1
  10. package/dist/commands/doctor.js +3 -3
  11. package/dist/commands/finalize.js +41 -15
  12. package/dist/commands/login.js +7 -6
  13. package/dist/commands/logout.js +4 -4
  14. package/dist/commands/project.js +5 -4
  15. package/dist/commands/run.js +158 -90
  16. package/dist/commands/status.js +22 -18
  17. package/dist/commands/tdd.js +105 -78
  18. package/dist/commands/upload.js +61 -26
  19. package/dist/commands/whoami.js +4 -4
  20. package/dist/config/core.js +438 -0
  21. package/dist/config/index.js +13 -0
  22. package/dist/config/operations.js +327 -0
  23. package/dist/index.js +1 -1
  24. package/dist/project/core.js +295 -0
  25. package/dist/project/index.js +13 -0
  26. package/dist/project/operations.js +393 -0
  27. package/dist/report-generator/core.js +315 -0
  28. package/dist/report-generator/index.js +8 -0
  29. package/dist/report-generator/operations.js +196 -0
  30. package/dist/reporter/reporter-bundle.iife.js +16 -16
  31. package/dist/screenshot-server/core.js +157 -0
  32. package/dist/screenshot-server/index.js +11 -0
  33. package/dist/screenshot-server/operations.js +183 -0
  34. package/dist/sdk/index.js +3 -2
  35. package/dist/server/handlers/api-handler.js +14 -5
  36. package/dist/server/handlers/tdd-handler.js +80 -48
  37. package/dist/server-manager/core.js +183 -0
  38. package/dist/server-manager/index.js +81 -0
  39. package/dist/server-manager/operations.js +208 -0
  40. package/dist/services/build-manager.js +2 -69
  41. package/dist/services/index.js +21 -48
  42. package/dist/services/screenshot-server.js +40 -74
  43. package/dist/services/server-manager.js +45 -80
  44. package/dist/services/static-report-generator.js +21 -163
  45. package/dist/services/test-runner.js +90 -250
  46. package/dist/services/uploader.js +56 -358
  47. package/dist/tdd/core/hotspot-coverage.js +112 -0
  48. package/dist/tdd/core/signature.js +101 -0
  49. package/dist/tdd/index.js +19 -0
  50. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  51. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  52. package/dist/tdd/services/baseline-downloader.js +151 -0
  53. package/dist/tdd/services/baseline-manager.js +166 -0
  54. package/dist/tdd/services/comparison-service.js +230 -0
  55. package/dist/tdd/services/hotspot-service.js +71 -0
  56. package/dist/tdd/services/result-service.js +123 -0
  57. package/dist/tdd/tdd-service.js +1081 -0
  58. package/dist/test-runner/core.js +255 -0
  59. package/dist/test-runner/index.js +13 -0
  60. package/dist/test-runner/operations.js +483 -0
  61. package/dist/uploader/core.js +396 -0
  62. package/dist/uploader/index.js +11 -0
  63. package/dist/uploader/operations.js +412 -0
  64. package/package.json +7 -12
  65. package/dist/services/api-service.js +0 -412
  66. package/dist/services/auth-service.js +0 -226
  67. package/dist/services/config-service.js +0 -369
  68. package/dist/services/html-report-generator.js +0 -455
  69. package/dist/services/project-service.js +0 -326
  70. package/dist/services/report-generator/report.css +0 -411
  71. package/dist/services/report-generator/viewer.js +0 -102
  72. package/dist/services/tdd-service.js +0 -1437
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Screenshot Server Core - Pure functions for screenshot server logic
3
+ *
4
+ * No I/O, no side effects - just data transformations.
5
+ */
6
+
7
+ // ============================================================================
8
+ // Request Validation
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Validate screenshot request body
13
+ * @param {Object} body - Request body
14
+ * @returns {{ valid: boolean, error: string|null }}
15
+ */
16
+ export function validateScreenshotRequest(body) {
17
+ if (!body?.name || !body?.image) {
18
+ return {
19
+ valid: false,
20
+ error: 'name and image are required'
21
+ };
22
+ }
23
+ return {
24
+ valid: true,
25
+ error: null
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Check if request is for the screenshot endpoint
31
+ * @param {string} method - HTTP method
32
+ * @param {string} url - Request URL
33
+ * @returns {boolean}
34
+ */
35
+ export function isScreenshotEndpoint(method, url) {
36
+ return method === 'POST' && url === '/screenshot';
37
+ }
38
+
39
+ // ============================================================================
40
+ // Build ID Handling
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Get effective build ID (falls back to 'default')
45
+ * @param {string|null|undefined} buildId - Build ID from request
46
+ * @returns {string}
47
+ */
48
+ export function getEffectiveBuildId(buildId) {
49
+ return buildId || 'default';
50
+ }
51
+
52
+ // ============================================================================
53
+ // Response Building
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Build success response object
58
+ * @returns {{ status: number, body: Object }}
59
+ */
60
+ export function buildSuccessResponse() {
61
+ return {
62
+ status: 200,
63
+ body: {
64
+ success: true
65
+ }
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build error response object
71
+ * @param {number} status - HTTP status code
72
+ * @param {string} message - Error message
73
+ * @returns {{ status: number, body: Object }}
74
+ */
75
+ export function buildErrorResponse(status, message) {
76
+ return {
77
+ status,
78
+ body: {
79
+ error: message
80
+ }
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Build not found response
86
+ * @returns {{ status: number, body: Object }}
87
+ */
88
+ export function buildNotFoundResponse() {
89
+ return buildErrorResponse(404, 'Not found');
90
+ }
91
+
92
+ /**
93
+ * Build bad request response
94
+ * @param {string} message - Error message
95
+ * @returns {{ status: number, body: Object }}
96
+ */
97
+ export function buildBadRequestResponse(message) {
98
+ return buildErrorResponse(400, message);
99
+ }
100
+
101
+ /**
102
+ * Build internal error response
103
+ * @returns {{ status: number, body: Object }}
104
+ */
105
+ export function buildInternalErrorResponse() {
106
+ return buildErrorResponse(500, 'Internal server error');
107
+ }
108
+
109
+ // ============================================================================
110
+ // Server Configuration
111
+ // ============================================================================
112
+
113
+ /**
114
+ * Build server listen options
115
+ * @param {Object} config - Server configuration
116
+ * @returns {{ port: number, host: string }}
117
+ */
118
+ export function buildServerListenOptions(config) {
119
+ return {
120
+ port: config?.server?.port || 3000,
121
+ host: '127.0.0.1'
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Build server started message
127
+ * @param {number} port - Server port
128
+ * @returns {string}
129
+ */
130
+ export function buildServerStartedMessage(port) {
131
+ return `Screenshot server listening on http://127.0.0.1:${port}`;
132
+ }
133
+
134
+ /**
135
+ * Build server stopped message
136
+ * @returns {string}
137
+ */
138
+ export function buildServerStoppedMessage() {
139
+ return 'Screenshot server stopped';
140
+ }
141
+
142
+ // ============================================================================
143
+ // Screenshot Data Extraction
144
+ // ============================================================================
145
+
146
+ /**
147
+ * Extract screenshot data from request body
148
+ * @param {Object} body - Request body
149
+ * @returns {{ name: string, image: string, properties?: Object }}
150
+ */
151
+ export function extractScreenshotData(body) {
152
+ return {
153
+ name: body.name,
154
+ image: body.image,
155
+ properties: body.properties
156
+ };
157
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Screenshot Server Module
3
+ *
4
+ * Exports pure functions (core) and I/O operations for screenshot server functionality.
5
+ */
6
+
7
+ // Core - pure functions
8
+ export { buildBadRequestResponse, buildErrorResponse, buildInternalErrorResponse, buildNotFoundResponse, buildServerListenOptions, buildServerStartedMessage, buildServerStoppedMessage, buildSuccessResponse, extractScreenshotData, getEffectiveBuildId, isScreenshotEndpoint, validateScreenshotRequest } from './core.js';
9
+
10
+ // Operations - I/O with dependency injection
11
+ export { handleRequest, parseRequestBody, startServer, stopServer } from './operations.js';
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Screenshot Server Operations - I/O operations with dependency injection
3
+ *
4
+ * Each operation takes its dependencies as parameters for testability.
5
+ */
6
+
7
+ import { buildBadRequestResponse, buildInternalErrorResponse, buildNotFoundResponse, buildServerListenOptions, buildServerStartedMessage, buildServerStoppedMessage, buildSuccessResponse, extractScreenshotData, getEffectiveBuildId, isScreenshotEndpoint, validateScreenshotRequest } from './core.js';
8
+
9
+ // ============================================================================
10
+ // Request Body Parsing
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Parse request body as JSON
15
+ * @param {Object} options - Options
16
+ * @param {Object} options.req - HTTP request
17
+ * @param {Object} options.deps - Dependencies
18
+ * @param {Function} options.deps.createError - Error factory
19
+ * @returns {Promise<Object>} Parsed body
20
+ */
21
+ export function parseRequestBody({
22
+ req,
23
+ deps
24
+ }) {
25
+ let {
26
+ createError
27
+ } = deps;
28
+ return new Promise((resolve, reject) => {
29
+ let body = '';
30
+ req.on('data', chunk => {
31
+ body += chunk.toString();
32
+ });
33
+ req.on('end', () => {
34
+ try {
35
+ resolve(JSON.parse(body));
36
+ } catch {
37
+ reject(createError('Invalid JSON in request body', 'INVALID_JSON'));
38
+ }
39
+ });
40
+ req.on('error', error => {
41
+ reject(createError(`Request error: ${error.message}`, 'REQUEST_ERROR'));
42
+ });
43
+ });
44
+ }
45
+
46
+ // ============================================================================
47
+ // Request Handling
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Handle incoming HTTP request
52
+ * @param {Object} options - Options
53
+ * @param {Object} options.req - HTTP request
54
+ * @param {Object} options.res - HTTP response
55
+ * @param {Object} options.deps - Dependencies
56
+ * @param {Object} options.deps.buildManager - Build manager for screenshot storage
57
+ * @param {Function} options.deps.createError - Error factory
58
+ * @param {Object} options.deps.output - Output utilities
59
+ */
60
+ export async function handleRequest({
61
+ req,
62
+ res,
63
+ deps
64
+ }) {
65
+ let {
66
+ buildManager,
67
+ createError,
68
+ output
69
+ } = deps;
70
+
71
+ // Check if this is a screenshot endpoint
72
+ if (!isScreenshotEndpoint(req.method, req.url)) {
73
+ let response = buildNotFoundResponse();
74
+ sendResponse(res, response);
75
+ return;
76
+ }
77
+ try {
78
+ // Parse request body
79
+ let body = await parseRequestBody({
80
+ req,
81
+ deps: {
82
+ createError
83
+ }
84
+ });
85
+
86
+ // Validate required fields
87
+ let validation = validateScreenshotRequest(body);
88
+ if (!validation.valid) {
89
+ let response = buildBadRequestResponse(validation.error);
90
+ sendResponse(res, response);
91
+ return;
92
+ }
93
+
94
+ // Process screenshot
95
+ let effectiveBuildId = getEffectiveBuildId(body.buildId);
96
+ let screenshotData = extractScreenshotData(body);
97
+ await buildManager.addScreenshot(effectiveBuildId, screenshotData);
98
+ let response = buildSuccessResponse();
99
+ sendResponse(res, response);
100
+ } catch (error) {
101
+ output.error('Failed to process screenshot:', error);
102
+ let response = buildInternalErrorResponse();
103
+ sendResponse(res, response);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Send response to client
109
+ * @param {Object} res - HTTP response
110
+ * @param {{ status: number, body: Object }} response - Response data
111
+ */
112
+ function sendResponse(res, response) {
113
+ res.statusCode = response.status;
114
+ res.end(JSON.stringify(response.body));
115
+ }
116
+
117
+ // ============================================================================
118
+ // Server Lifecycle
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Start the HTTP server
123
+ * @param {Object} options - Options
124
+ * @param {Object} options.config - Server configuration
125
+ * @param {Function} options.requestHandler - Request handler function
126
+ * @param {Object} options.deps - Dependencies
127
+ * @param {Function} options.deps.createHttpServer - HTTP server factory (http.createServer)
128
+ * @param {Function} options.deps.createError - Error factory
129
+ * @param {Object} options.deps.output - Output utilities
130
+ * @returns {Promise<Object>} HTTP server instance
131
+ */
132
+ export function startServer({
133
+ config,
134
+ requestHandler,
135
+ deps
136
+ }) {
137
+ let {
138
+ createHttpServer,
139
+ createError,
140
+ output
141
+ } = deps;
142
+ return new Promise((resolve, reject) => {
143
+ let server = createHttpServer(requestHandler);
144
+ let {
145
+ port,
146
+ host
147
+ } = buildServerListenOptions(config);
148
+ server.listen(port, host, error => {
149
+ if (error) {
150
+ reject(createError(`Failed to start screenshot server: ${error.message}`, 'SERVER_ERROR'));
151
+ } else {
152
+ output.info(buildServerStartedMessage(port));
153
+ resolve(server);
154
+ }
155
+ });
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Stop the HTTP server
161
+ * @param {Object} options - Options
162
+ * @param {Object|null} options.server - HTTP server instance
163
+ * @param {Object} options.deps - Dependencies
164
+ * @param {Object} options.deps.output - Output utilities
165
+ * @returns {Promise<void>}
166
+ */
167
+ export function stopServer({
168
+ server,
169
+ deps
170
+ }) {
171
+ let {
172
+ output
173
+ } = deps;
174
+ if (!server) {
175
+ return Promise.resolve();
176
+ }
177
+ return new Promise(resolve => {
178
+ server.close(() => {
179
+ output.info(buildServerStoppedMessage());
180
+ resolve();
181
+ });
182
+ });
183
+ }
package/dist/sdk/index.js CHANGED
@@ -13,8 +13,8 @@
13
13
  import { EventEmitter } from 'node:events';
14
14
  import { VizzlyError } from '../errors/vizzly-error.js';
15
15
  import { ScreenshotServer } from '../services/screenshot-server.js';
16
- import { createTDDService } from '../services/tdd-service.js';
17
16
  import { createUploader } from '../services/uploader.js';
17
+ import { createTDDService } from '../tdd/tdd-service.js';
18
18
  import { loadConfig } from '../utils/config-loader.js';
19
19
  import { resolveImageBuffer } from '../utils/file-helpers.js';
20
20
  import * as output from '../utils/output.js';
@@ -357,9 +357,10 @@ export class VizzlySDK extends EventEmitter {
357
357
  }
358
358
  }
359
359
  }
360
- export { createTDDService } from '../services/tdd-service.js';
360
+
361
361
  // Export service creators for advanced usage
362
362
  export { createUploader } from '../services/uploader.js';
363
+ export { createTDDService } from '../tdd/tdd-service.js';
363
364
  // Re-export key utilities and errors
364
365
  export { loadConfig } from '../utils/config-loader.js';
365
366
  export * as output from '../utils/output.js';
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
+ import { uploadScreenshot as defaultUploadScreenshot } from '../../api/index.js';
4
5
  import { detectImageInputType } from '../../utils/image-input-detector.js';
5
6
  import * as output from '../../utils/output.js';
6
7
 
@@ -34,7 +35,15 @@ import * as output from '../../utils/output.js';
34
35
  * └─────────────────────────────────────────────────────────────┘
35
36
  */
36
37
 
37
- export const createApiHandler = apiService => {
38
+ /**
39
+ * Create an API handler for screenshot uploads.
40
+ * @param {Object} client - API client with request method
41
+ * @param {Object} options - Optional dependencies for testing
42
+ * @param {Function} options.uploadScreenshot - Upload function (defaults to API uploadScreenshot)
43
+ */
44
+ export const createApiHandler = (client, {
45
+ uploadScreenshot = defaultUploadScreenshot
46
+ } = {}) => {
38
47
  let vizzlyDisabled = false;
39
48
  let screenshotCount = 0;
40
49
  let uploadPromises = [];
@@ -52,13 +61,13 @@ export const createApiHandler = apiService => {
52
61
  };
53
62
  }
54
63
 
55
- // buildId is optional - API service will handle it appropriately
64
+ // buildId is optional - API will handle it appropriately
56
65
 
57
- if (!apiService) {
66
+ if (!client) {
58
67
  return {
59
68
  statusCode: 500,
60
69
  body: {
61
- error: 'API service not available'
70
+ error: 'API client not available'
62
71
  }
63
72
  };
64
73
  }
@@ -114,7 +123,7 @@ export const createApiHandler = apiService => {
114
123
  screenshotCount++;
115
124
 
116
125
  // Fire upload in background - DON'T AWAIT!
117
- const uploadPromise = apiService.uploadScreenshot(buildId, name, imageBuffer, properties ?? {}).then(result => {
126
+ let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}).then(result => {
118
127
  if (!result.skipped) {
119
128
  output.debug('upload', name);
120
129
  }
@@ -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, 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 { 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,22 @@ 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
+ writeFileSync = defaultWriteFileSync,
146
+ join = defaultJoin,
147
+ resolve = defaultResolve,
148
+ Buffer = defaultBuffer,
149
+ getDimensionsSync = defaultGetDimensionsSync,
150
+ detectImageInputType = defaultDetectImageInputType,
151
+ sanitizeScreenshotName = defaultSanitizeScreenshotName,
152
+ validateScreenshotProperties = defaultValidateScreenshotProperties,
153
+ output = defaultOutput
154
+ } = deps;
91
155
  const tddService = new TddService(config, workingDir, setBaseline);
92
156
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
93
157
  const readReportData = () => {
@@ -217,18 +281,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
217
281
  }
218
282
 
219
283
  // 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
- }
284
+ let unwrappedProperties = unwrapProperties(properties);
232
285
 
233
286
  // Validate and sanitize properties
234
287
  let validatedProperties;
@@ -246,19 +299,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
246
299
  }
247
300
 
248
301
  // 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
- };
302
+ const extractedProperties = extractProperties(validatedProperties);
262
303
 
263
304
  // Support both base64 encoded images and file paths
264
305
  // Vitest browser mode returns file paths, so we need to handle both
@@ -336,16 +377,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
336
377
  // Comparison tracked by tdd.js event handler
337
378
 
338
379
  // 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
- };
380
+ const vizzlyDir = join(workingDir, '.vizzly');
349
381
 
350
382
  // Record the comparison for the dashboard
351
383
  const newComparison = {
@@ -354,9 +386,9 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
354
386
  name: comparison.name,
355
387
  originalName: name,
356
388
  status: comparison.status,
357
- baseline: convertPathToUrl(comparison.baseline),
358
- current: convertPathToUrl(comparison.current),
359
- diff: convertPathToUrl(comparison.diff),
389
+ baseline: convertPathToUrl(comparison.baseline, vizzlyDir),
390
+ current: convertPathToUrl(comparison.current, vizzlyDir),
391
+ diff: convertPathToUrl(comparison.diff, vizzlyDir),
360
392
  diffPercentage: comparison.diffPercentage,
361
393
  threshold: comparison.threshold,
362
394
  properties: extractedProperties,