@vizzly-testing/cli 0.19.2 → 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 (76) 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/client/index.js +0 -1
  11. package/dist/commands/doctor.js +3 -3
  12. package/dist/commands/finalize.js +41 -15
  13. package/dist/commands/login.js +7 -6
  14. package/dist/commands/logout.js +4 -4
  15. package/dist/commands/project.js +5 -4
  16. package/dist/commands/run.js +158 -90
  17. package/dist/commands/status.js +22 -18
  18. package/dist/commands/tdd.js +105 -78
  19. package/dist/commands/upload.js +61 -26
  20. package/dist/commands/whoami.js +4 -4
  21. package/dist/config/core.js +438 -0
  22. package/dist/config/index.js +13 -0
  23. package/dist/config/operations.js +327 -0
  24. package/dist/index.js +1 -1
  25. package/dist/project/core.js +295 -0
  26. package/dist/project/index.js +13 -0
  27. package/dist/project/operations.js +393 -0
  28. package/dist/report-generator/core.js +315 -0
  29. package/dist/report-generator/index.js +8 -0
  30. package/dist/report-generator/operations.js +196 -0
  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 +80 -48
  38. package/dist/server-manager/core.js +183 -0
  39. package/dist/server-manager/index.js +81 -0
  40. package/dist/server-manager/operations.js +208 -0
  41. package/dist/services/build-manager.js +2 -69
  42. package/dist/services/index.js +21 -48
  43. package/dist/services/screenshot-server.js +40 -74
  44. package/dist/services/server-manager.js +45 -80
  45. package/dist/services/static-report-generator.js +21 -163
  46. package/dist/services/test-runner.js +90 -249
  47. package/dist/services/uploader.js +56 -358
  48. package/dist/tdd/core/hotspot-coverage.js +112 -0
  49. package/dist/tdd/core/signature.js +101 -0
  50. package/dist/tdd/index.js +19 -0
  51. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  52. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  53. package/dist/tdd/services/baseline-downloader.js +151 -0
  54. package/dist/tdd/services/baseline-manager.js +166 -0
  55. package/dist/tdd/services/comparison-service.js +230 -0
  56. package/dist/tdd/services/hotspot-service.js +71 -0
  57. package/dist/tdd/services/result-service.js +123 -0
  58. package/dist/tdd/tdd-service.js +1081 -0
  59. package/dist/test-runner/core.js +255 -0
  60. package/dist/test-runner/index.js +13 -0
  61. package/dist/test-runner/operations.js +483 -0
  62. package/dist/types/client.d.ts +4 -2
  63. package/dist/types/index.d.ts +5 -0
  64. package/dist/uploader/core.js +396 -0
  65. package/dist/uploader/index.js +11 -0
  66. package/dist/uploader/operations.js +412 -0
  67. package/dist/utils/config-schema.js +8 -3
  68. package/package.json +7 -12
  69. package/dist/services/api-service.js +0 -412
  70. package/dist/services/auth-service.js +0 -226
  71. package/dist/services/config-service.js +0 -369
  72. package/dist/services/html-report-generator.js +0 -455
  73. package/dist/services/project-service.js +0 -326
  74. package/dist/services/report-generator/report.css +0 -411
  75. package/dist/services/report-generator/viewer.js +0 -102
  76. package/dist/services/tdd-service.js +0 -1429
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * Vizzly Screenshot Uploader
3
3
  * Handles screenshot uploads to the Vizzly platform
4
+ *
5
+ * This module is a thin wrapper around the functional operations in
6
+ * src/uploader/. It maintains backwards compatibility while
7
+ * delegating to pure functions for testability.
4
8
  */
5
9
 
6
- import crypto from 'node:crypto';
7
10
  import { readFile, stat } from 'node:fs/promises';
8
- import { basename } from 'node:path';
9
11
  import { glob } from 'glob';
12
+ import { checkShas, createApiClient, createBuild } from '../api/index.js';
10
13
  import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
14
+ import { resolveBatchSize, resolveTimeout } from '../uploader/index.js';
15
+ import { upload as uploadOperation, waitForBuild as waitForBuildOperation } from '../uploader/operations.js';
11
16
  import { getDefaultBranch } from '../utils/git.js';
12
17
  import * as output from '../utils/output.js';
13
- import { ApiService } from './api-service.js';
14
- const DEFAULT_BATCH_SIZE = 50;
15
- const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
16
- const DEFAULT_TIMEOUT = 30000; // 30 seconds
17
18
 
18
19
  /**
19
20
  * Create a new uploader instance
@@ -25,378 +26,75 @@ export function createUploader({
25
26
  command,
26
27
  upload: uploadConfig = {}
27
28
  } = {}, options = {}) {
28
- const signal = options.signal || new AbortController().signal;
29
- const api = new ApiService({
29
+ let signal = options.signal || new AbortController().signal;
30
+ let client = createApiClient({
30
31
  baseUrl: apiUrl,
31
32
  token: apiKey,
32
33
  command: command || 'upload',
33
- userAgent,
34
+ sdkUserAgent: userAgent,
34
35
  allowNoToken: true
35
36
  });
36
37
 
37
- // Resolve tunable parameters from options or config
38
- const batchSize = Number(options.batchSize ?? uploadConfig?.batchSize ?? DEFAULT_BATCH_SIZE);
39
- const TIMEOUT_MS = Number(options.timeout ?? uploadConfig?.timeout ?? DEFAULT_TIMEOUT);
38
+ // Resolve tunable parameters
39
+ let batchSize = resolveBatchSize(options, uploadConfig);
40
+ let timeout = resolveTimeout(options, uploadConfig);
41
+
42
+ // Dependency injection for testing
43
+ let deps = options.deps || {
44
+ client,
45
+ createBuild,
46
+ getDefaultBranch,
47
+ glob,
48
+ readFile,
49
+ stat,
50
+ checkShas,
51
+ createError: (message, code, context) => {
52
+ let error = new UploadError(message, context);
53
+ error.code = code;
54
+ return error;
55
+ },
56
+ createValidationError: (message, context) => new ValidationError(message, context),
57
+ createUploadError: (message, context) => new UploadError(message, context),
58
+ createTimeoutError: (message, context) => new TimeoutError(message, context),
59
+ output
60
+ };
40
61
 
41
62
  /**
42
63
  * Upload screenshots to Vizzly
43
64
  */
44
- async function upload({
45
- screenshotsDir,
46
- buildName,
47
- branch,
48
- commit,
49
- message,
50
- environment = 'production',
51
- threshold,
52
- pullRequestNumber,
53
- parallelId,
54
- onProgress = () => {}
55
- }) {
56
- try {
57
- // Validate required config
58
- if (!apiKey) {
59
- throw new ValidationError('API key is required', {
60
- config: {
61
- apiKey,
62
- apiUrl
63
- }
64
- });
65
- }
66
- if (!screenshotsDir) {
67
- throw new ValidationError('Screenshots directory is required');
68
- }
69
- const stats = await stat(screenshotsDir);
70
- if (!stats.isDirectory()) {
71
- throw new ValidationError(`${screenshotsDir} is not a directory`);
72
- }
73
-
74
- // Find screenshots
75
- const files = await findScreenshots(screenshotsDir);
76
- if (files.length === 0) {
77
- throw new UploadError('No screenshot files found', {
78
- directory: screenshotsDir,
79
- pattern: '**/*.png'
80
- });
65
+ async function upload(uploadOptions) {
66
+ return uploadOperation({
67
+ uploadOptions,
68
+ config: {
69
+ apiKey,
70
+ apiUrl
71
+ },
72
+ signal,
73
+ batchSize,
74
+ deps: {
75
+ ...deps,
76
+ client: deps.client || client
81
77
  }
82
- onProgress({
83
- phase: 'scanning',
84
- message: `Found ${files.length} screenshots`,
85
- total: files.length
86
- });
87
-
88
- // Process files to get metadata
89
- const fileMetadata = await processFiles(files, signal, current => onProgress({
90
- phase: 'processing',
91
- message: `Processing files`,
92
- current,
93
- total: files.length
94
- }));
95
-
96
- // Create build first to get buildId for SHA checking
97
- const buildInfo = {
98
- name: buildName || `Upload ${new Date().toISOString()}`,
99
- branch: branch || (await getDefaultBranch()) || 'main',
100
- commit_sha: commit,
101
- commit_message: message,
102
- environment,
103
- threshold,
104
- github_pull_request_number: pullRequestNumber,
105
- parallel_id: parallelId
106
- };
107
- const build = await api.createBuild(buildInfo);
108
- const buildId = build.id;
109
-
110
- // Check which files need uploading (now with buildId)
111
- const {
112
- toUpload,
113
- existing,
114
- screenshots
115
- } = await checkExistingFiles(fileMetadata, api, signal, buildId);
116
- onProgress({
117
- phase: 'deduplication',
118
- message: `Checking for duplicates (${toUpload.length} to upload, ${existing.length} existing)`,
119
- toUpload: toUpload.length,
120
- existing: existing.length,
121
- total: files.length
122
- });
123
-
124
- // Upload remaining files
125
- const result = await uploadFiles({
126
- toUpload,
127
- existing,
128
- screenshots,
129
- buildId,
130
- buildInfo,
131
- api,
132
- signal,
133
- batchSize: batchSize,
134
- onProgress: current => onProgress({
135
- phase: 'uploading',
136
- message: `Uploading screenshots`,
137
- current,
138
- total: toUpload.length
139
- })
140
- });
141
- onProgress({
142
- phase: 'completed',
143
- message: `Upload completed`,
144
- buildId: result.buildId,
145
- url: result.url
146
- });
147
- return {
148
- success: true,
149
- buildId: result.buildId,
150
- url: result.url,
151
- stats: {
152
- total: files.length,
153
- uploaded: toUpload.length,
154
- skipped: existing.length
155
- }
156
- };
157
- } catch (error) {
158
- output.debug('upload', 'failed', {
159
- error: error.message
160
- });
161
-
162
- // Re-throw if already a VizzlyError
163
- if (error.name?.includes('Error') && error.code) {
164
- throw error;
165
- }
166
-
167
- // Wrap unknown errors
168
- throw new UploadError(`Upload failed: ${error.message}`, {
169
- originalError: error.message,
170
- stack: error.stack
171
- });
172
- }
78
+ });
173
79
  }
174
80
 
175
81
  /**
176
82
  * Wait for a build to complete
177
83
  */
178
- async function waitForBuild(buildId, timeout = TIMEOUT_MS) {
179
- const startTime = Date.now();
180
- while (Date.now() - startTime < timeout) {
181
- if (signal.aborted) {
182
- throw new UploadError('Operation cancelled', {
183
- buildId
184
- });
185
- }
186
- let resp;
187
- try {
188
- resp = await api.request(`/api/sdk/builds/${buildId}`, {
189
- signal
190
- });
191
- } catch (err) {
192
- const match = String(err?.message || '').match(/API request failed: (\d+)/);
193
- const code = match ? match[1] : 'unknown';
194
- throw new UploadError(`Failed to check build status: ${code}`);
195
- }
196
- const build = resp?.build ?? resp;
197
- if (build.status === 'completed') {
198
- // Extract comparison data for the response
199
- const result = {
200
- status: 'completed',
201
- build
202
- };
203
-
204
- // Add comparison summary if available
205
- if (typeof build.comparisonsTotal === 'number') {
206
- result.comparisons = build.comparisonsTotal;
207
- result.passedComparisons = build.comparisonsPassed || 0;
208
- result.failedComparisons = build.comparisonsFailed || 0;
209
- } else {
210
- // Ensure failedComparisons is always a number, even when comparison data is missing
211
- // This prevents the run command exit code check from failing
212
- result.passedComparisons = 0;
213
- result.failedComparisons = 0;
214
- }
215
-
216
- // Add build URL if available
217
- if (build.url) {
218
- result.url = build.url;
219
- }
220
- return result;
221
- }
222
- if (build.status === 'failed') {
223
- throw new UploadError(`Build failed: ${build.error || 'Unknown error'}`);
224
- }
225
- }
226
- throw new TimeoutError(`Build timed out after ${timeout}ms`, {
84
+ async function waitForBuild(buildId, waitTimeout = timeout) {
85
+ return waitForBuildOperation({
227
86
  buildId,
228
- timeout,
229
- elapsed: Date.now() - startTime
87
+ timeout: waitTimeout,
88
+ signal,
89
+ client: deps.client || client,
90
+ deps: {
91
+ createError: deps.createError,
92
+ createTimeoutError: deps.createTimeoutError
93
+ }
230
94
  });
231
95
  }
232
96
  return {
233
97
  upload,
234
98
  waitForBuild
235
99
  };
236
- }
237
-
238
- /**
239
- * Find all PNG screenshots in a directory
240
- */
241
- async function findScreenshots(directory) {
242
- const pattern = `${directory}/**/*.png`;
243
- return glob(pattern, {
244
- absolute: true
245
- });
246
- }
247
-
248
- /**
249
- * Process files to extract metadata and compute hashes
250
- */
251
- async function* processFilesGenerator(files, signal) {
252
- for (const filePath of files) {
253
- if (signal.aborted) throw new UploadError('Operation cancelled');
254
- const buffer = await readFile(filePath);
255
- const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
256
- yield {
257
- path: filePath,
258
- filename: basename(filePath),
259
- buffer,
260
- sha256
261
- };
262
- }
263
- }
264
- async function processFiles(files, signal, onProgress) {
265
- const results = [];
266
- let count = 0;
267
- for await (const file of processFilesGenerator(files, signal)) {
268
- results.push(file);
269
- count++;
270
- if (count % 10 === 0 || count === files.length) {
271
- onProgress(count);
272
- }
273
- }
274
- return results;
275
- }
276
-
277
- /**
278
- * Check which files already exist on the server using signature-based deduplication
279
- */
280
- async function checkExistingFiles(fileMetadata, api, signal, buildId) {
281
- const existingShas = new Set();
282
- const allScreenshots = [];
283
-
284
- // Check in batches using the new signature-based format
285
- for (let i = 0; i < fileMetadata.length; i += DEFAULT_SHA_CHECK_BATCH_SIZE) {
286
- if (signal.aborted) throw new UploadError('Operation cancelled');
287
- const batch = fileMetadata.slice(i, i + DEFAULT_SHA_CHECK_BATCH_SIZE);
288
-
289
- // Convert file metadata to screenshot objects with signature data
290
- const screenshotBatch = batch.map(file => ({
291
- sha256: file.sha256,
292
- name: file.filename.replace(/\.png$/, ''),
293
- // Remove .png extension for name
294
- // Extract browser from filename if available (e.g., "homepage-chrome.png" -> "chrome")
295
- browser: extractBrowserFromFilename(file.filename) || 'chrome',
296
- // Default to chrome
297
- // Default viewport dimensions (these could be extracted from filename or metadata if available)
298
- viewport_width: 1920,
299
- viewport_height: 1080
300
- }));
301
- try {
302
- const res = await api.checkShas(screenshotBatch, buildId);
303
- const {
304
- existing = [],
305
- screenshots = []
306
- } = res || {};
307
- for (let sha of existing) {
308
- existingShas.add(sha);
309
- }
310
- allScreenshots.push(...screenshots);
311
- } catch (error) {
312
- // Continue without deduplication on error
313
- console.debug('SHA check failed, continuing without deduplication:', error.message);
314
- }
315
- }
316
- return {
317
- toUpload: fileMetadata.filter(f => !existingShas.has(f.sha256)),
318
- existing: fileMetadata.filter(f => existingShas.has(f.sha256)),
319
- screenshots: allScreenshots
320
- };
321
- }
322
-
323
- /**
324
- * Extract browser name from filename
325
- * @param {string} filename - The screenshot filename
326
- * @returns {string|null} Browser name or null if not found
327
- */
328
- function extractBrowserFromFilename(filename) {
329
- const browsers = ['chrome', 'firefox', 'safari', 'edge', 'webkit'];
330
- const lowerFilename = filename.toLowerCase();
331
- for (const browser of browsers) {
332
- if (lowerFilename.includes(browser)) {
333
- return browser;
334
- }
335
- }
336
- return null;
337
- }
338
-
339
- /**
340
- * Upload files to Vizzly
341
- */
342
- async function uploadFiles({
343
- toUpload,
344
- buildId,
345
- api,
346
- signal,
347
- batchSize,
348
- onProgress
349
- }) {
350
- let result = null;
351
-
352
- // If all files exist, screenshot records were already created during SHA check
353
- if (toUpload.length === 0) {
354
- return {
355
- buildId,
356
- url: null
357
- }; // Build was already created
358
- }
359
-
360
- // Upload in batches
361
- for (let i = 0; i < toUpload.length; i += batchSize) {
362
- if (signal.aborted) throw new UploadError('Operation cancelled');
363
- const batch = toUpload.slice(i, i + batchSize);
364
- const form = new FormData();
365
-
366
- // All batches add to existing build (build was created earlier)
367
- form.append('build_id', buildId);
368
-
369
- // Add files
370
- for (const file of batch) {
371
- const blob = new Blob([file.buffer], {
372
- type: 'image/png'
373
- });
374
- form.append('screenshots', blob, file.filename);
375
- }
376
- try {
377
- result = await api.request('/api/sdk/upload', {
378
- method: 'POST',
379
- body: form,
380
- signal,
381
- headers: {}
382
- });
383
- } catch (err) {
384
- throw new UploadError(`Upload failed: ${err.message}`, {
385
- batch: i / batchSize + 1
386
- });
387
- }
388
- onProgress(i + batch.length);
389
- }
390
- return {
391
- buildId,
392
- url: result?.build?.url || result?.url
393
- };
394
- }
395
-
396
- // createBuildWithExisting function removed - no longer needed since
397
- // builds are created first and /check-shas automatically creates screenshot records
398
-
399
- /**
400
- * Uploader class for handling screenshot uploads
401
- */
402
- // Legacy Uploader class removed — all functionality lives in createUploader.
100
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Hotspot Coverage Calculation
3
+ *
4
+ * Pure functions for calculating how much of a visual diff falls within
5
+ * "hotspot" regions - areas of the UI that frequently change due to dynamic
6
+ * content (timestamps, animations, etc.).
7
+ *
8
+ * Uses 1D Y-coordinate matching (same algorithm as cloud).
9
+ */
10
+
11
+ /**
12
+ * Calculate what percentage of diff falls within hotspot regions
13
+ *
14
+ * @param {Array} diffClusters - Array of diff clusters from honeydiff
15
+ * @param {Object} hotspotAnalysis - Hotspot data with regions array
16
+ * @returns {{ coverage: number, linesInHotspots: number, totalLines: number }}
17
+ */
18
+ export function calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
19
+ if (!diffClusters || diffClusters.length === 0) {
20
+ return {
21
+ coverage: 0,
22
+ linesInHotspots: 0,
23
+ totalLines: 0
24
+ };
25
+ }
26
+ if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
27
+ return {
28
+ coverage: 0,
29
+ linesInHotspots: 0,
30
+ totalLines: 0
31
+ };
32
+ }
33
+
34
+ // Extract Y-coordinates (diff lines) from clusters
35
+ // Each cluster has a boundingBox with y and height
36
+ let diffLines = [];
37
+ for (let cluster of diffClusters) {
38
+ if (cluster.boundingBox) {
39
+ let {
40
+ y,
41
+ height
42
+ } = cluster.boundingBox;
43
+ // Add all Y lines covered by this cluster
44
+ for (let line = y; line < y + height; line++) {
45
+ diffLines.push(line);
46
+ }
47
+ }
48
+ }
49
+ if (diffLines.length === 0) {
50
+ return {
51
+ coverage: 0,
52
+ linesInHotspots: 0,
53
+ totalLines: 0
54
+ };
55
+ }
56
+
57
+ // Remove duplicates and sort
58
+ diffLines = [...new Set(diffLines)].sort((a, b) => a - b);
59
+
60
+ // Check how many diff lines fall within hotspot regions
61
+ let linesInHotspots = 0;
62
+ for (let line of diffLines) {
63
+ let inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
64
+ if (inHotspot) {
65
+ linesInHotspots++;
66
+ }
67
+ }
68
+ let coverage = linesInHotspots / diffLines.length;
69
+ return {
70
+ coverage,
71
+ linesInHotspots,
72
+ totalLines: diffLines.length
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Determine if a comparison should be filtered as "passed" based on hotspot coverage
78
+ *
79
+ * A diff is filtered when:
80
+ * 1. Coverage is >= 80% (most diff in hotspot regions)
81
+ * 2. Confidence is "high" or confidence score > 0.7
82
+ *
83
+ * @param {Object} hotspotAnalysis - Hotspot data with confidence info
84
+ * @param {{ coverage: number }} coverageResult - Result from calculateHotspotCoverage
85
+ * @returns {boolean} True if diff should be filtered as hotspot noise
86
+ */
87
+ export function shouldFilterAsHotspot(hotspotAnalysis, coverageResult) {
88
+ if (!hotspotAnalysis || !coverageResult) {
89
+ return false;
90
+ }
91
+ let {
92
+ coverage
93
+ } = coverageResult;
94
+
95
+ // Need at least 80% of diff in hotspot regions
96
+ if (coverage < 0.8) {
97
+ return false;
98
+ }
99
+
100
+ // Need high confidence in the hotspot analysis
101
+ let {
102
+ confidence,
103
+ confidenceScore
104
+ } = hotspotAnalysis;
105
+ if (confidence === 'high') {
106
+ return true;
107
+ }
108
+ if (confidenceScore !== undefined && confidenceScore > 0.7) {
109
+ return true;
110
+ }
111
+ return false;
112
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Screenshot Identity - Signature and Filename Generation
3
+ *
4
+ * CRITICAL: These functions MUST stay in sync with the cloud!
5
+ *
6
+ * Cloud counterpart: vizzly/src/utils/screenshot-identity.js
7
+ * - generateScreenshotSignature()
8
+ * - generateBaselineFilename()
9
+ *
10
+ * Contract tests: Both repos have golden tests that must produce identical values:
11
+ * - Cloud: tests/contracts/signature-parity.test.js
12
+ * - CLI: tests/contracts/signature-parity.spec.js
13
+ *
14
+ * If you modify signature or filename generation here, you MUST:
15
+ * 1. Make the same change in the cloud repo
16
+ * 2. Update golden test values in BOTH repos
17
+ * 3. Run contract tests in both repos to verify parity
18
+ *
19
+ * The signature format is: name|viewport_width|browser|custom1|custom2|...
20
+ * The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
21
+ */
22
+
23
+ import crypto from 'node:crypto';
24
+
25
+ /**
26
+ * Generate a screenshot signature for baseline matching
27
+ *
28
+ * SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
29
+ *
30
+ * Uses same logic as cloud: name + viewport_width + browser + custom properties
31
+ *
32
+ * @param {string} name - Screenshot name
33
+ * @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
34
+ * @param {Array<string>} customProperties - Custom property names from project settings
35
+ * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
36
+ */
37
+ export function generateScreenshotSignature(name, properties = {}, customProperties = []) {
38
+ // Match cloud screenshot-identity.js behavior exactly:
39
+ // Always include all default properties (name, viewport_width, browser)
40
+ // even if null/undefined, using empty string as placeholder
41
+ let defaultProperties = ['name', 'viewport_width', 'browser'];
42
+ let allProperties = [...defaultProperties, ...customProperties];
43
+ let parts = allProperties.map(propName => {
44
+ let value;
45
+ if (propName === 'name') {
46
+ value = name;
47
+ } else if (propName === 'viewport_width') {
48
+ // Check for viewport_width as top-level property first (backend format)
49
+ value = properties.viewport_width;
50
+ // Fallback to nested viewport.width (SDK format)
51
+ if (value === null || value === undefined) {
52
+ value = properties.viewport?.width;
53
+ }
54
+ } else if (propName === 'browser') {
55
+ value = properties.browser;
56
+ } else {
57
+ // Custom property - check multiple locations
58
+ value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
59
+ }
60
+
61
+ // Handle null/undefined values consistently (match cloud behavior)
62
+ if (value === null || value === undefined) {
63
+ return '';
64
+ }
65
+
66
+ // Convert to string and normalize
67
+ return String(value).trim();
68
+ });
69
+ return parts.join('|');
70
+ }
71
+
72
+ /**
73
+ * Generate a stable, filesystem-safe filename for a screenshot baseline
74
+ * Uses a hash of the signature to avoid character encoding issues
75
+ * Matches the cloud's generateBaselineFilename implementation exactly
76
+ *
77
+ * @param {string} name - Screenshot name
78
+ * @param {string} signature - Full signature string
79
+ * @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
80
+ */
81
+ export function generateBaselineFilename(name, signature) {
82
+ let hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
83
+
84
+ // Sanitize the name for filesystem safety
85
+ let safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
86
+ .replace(/\s+/g, '-') // Spaces to hyphens
87
+ .slice(0, 50); // Limit length
88
+
89
+ return `${safeName}_${hash}.png`;
90
+ }
91
+
92
+ /**
93
+ * Generate a stable unique ID from signature for TDD comparisons
94
+ * This allows UI to reference specific variants without database IDs
95
+ *
96
+ * @param {string} signature - Full signature string
97
+ * @returns {string} 16-char hex hash
98
+ */
99
+ export function generateComparisonId(signature) {
100
+ return crypto.createHash('sha256').update(signature).digest('hex').slice(0, 16);
101
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * TDD Module Exports
3
+ *
4
+ * Re-exports all TDD functionality for clean imports.
5
+ */
6
+
7
+ export { calculateHotspotCoverage, shouldFilterAsHotspot } from './core/hotspot-coverage.js';
8
+ // Core pure functions
9
+ export { generateBaselineFilename, generateComparisonId, generateScreenshotSignature } from './core/signature.js';
10
+
11
+ // Metadata I/O
12
+ export { createEmptyBaselineMetadata, findScreenshotBySignature, loadBaselineMetadata, saveBaselineMetadata, upsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
13
+ export { createHotspotCache, getHotspotForScreenshot, loadHotspotMetadata, saveHotspotMetadata } from './metadata/hotspot-metadata.js';
14
+ export { baselineMatchesSha, buildBaselineMetadataEntry, downloadBaselineImage, downloadBaselinesInBatches } from './services/baseline-downloader.js';
15
+ // Services
16
+ export { baselineExists, clearBaselineData, getBaselinePath, getCurrentPath, getDiffPath, initializeDirectories, promoteCurrentToBaseline, readBaseline, readCurrent, saveBaseline, saveCurrent } from './services/baseline-manager.js';
17
+ export { buildErrorComparison, buildFailedComparison, buildNewComparison, buildPassedComparison, compareImages, isDimensionMismatchError } from './services/comparison-service.js';
18
+ export { downloadHotspots, extractScreenshotNames } from './services/hotspot-service.js';
19
+ export { buildResults, calculateSummary, findComparison, findComparisonById, getErrorComparisons, getFailedComparisons, getNewComparisons, isSuccessful } from './services/result-service.js';