@vizzly-testing/cli 0.30.0 → 0.32.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 (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
@@ -10,9 +10,13 @@ import { join } from 'node:path';
10
10
  * Enables the menubar app to discover and manage multiple concurrent servers.
11
11
  */
12
12
  export class ServerRegistry {
13
- constructor() {
14
- this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly');
13
+ constructor({
14
+ vizzlyHome,
15
+ logger = console
16
+ } = {}) {
17
+ this.vizzlyHome = vizzlyHome || process.env.VIZZLY_HOME || join(homedir(), '.vizzly');
15
18
  this.registryPath = join(this.vizzlyHome, 'servers.json');
19
+ this.logger = logger;
16
20
  }
17
21
 
18
22
  /**
@@ -40,7 +44,7 @@ export class ServerRegistry {
40
44
  }
41
45
  } catch (_err) {
42
46
  // Corrupted file, start fresh
43
- console.warn('Warning: Could not read server registry, starting fresh');
47
+ this.logger.warn('Warning: Could not read server registry, starting fresh');
44
48
  }
45
49
  return {
46
50
  version: 1,
@@ -64,10 +68,10 @@ export class ServerRegistry {
64
68
  if (!serverInfo.pid || !serverInfo.port || !serverInfo.directory) {
65
69
  throw new Error('Missing required fields: pid, port, directory');
66
70
  }
67
- let port = Number(serverInfo.port);
68
- let pid = Number(serverInfo.pid);
69
- if (Number.isNaN(port) || Number.isNaN(pid)) {
70
- throw new Error('Invalid port or pid - must be numbers');
71
+ let port = parsePositiveInteger(serverInfo.port);
72
+ let pid = parsePositiveInteger(serverInfo.pid);
73
+ if (port === null || pid === null) {
74
+ throw new Error('Invalid port or pid - must be positive integers');
71
75
  }
72
76
  let registry = this.read();
73
77
 
@@ -171,15 +175,7 @@ export class ServerRegistry {
171
175
  * The menubar app listens for this in addition to file watching.
172
176
  */
173
177
  notifyMenubar() {
174
- if (process.platform !== 'darwin') return;
175
- try {
176
- execSync('notifyutil -p dev.vizzly.serverChanged', {
177
- stdio: 'ignore',
178
- timeout: 500
179
- });
180
- } catch {
181
- // Non-fatal - menubar will still see changes via file watching
182
- }
178
+ notifyServerRegistryChanged();
183
179
  }
184
180
 
185
181
  /**
@@ -218,6 +214,31 @@ export class ServerRegistry {
218
214
  return startPort;
219
215
  }
220
216
  }
217
+ function parsePositiveInteger(value) {
218
+ if (typeof value === 'number') {
219
+ return Number.isInteger(value) && value > 0 ? value : null;
220
+ }
221
+ if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) {
222
+ return Number(value);
223
+ }
224
+ return null;
225
+ }
226
+ export function notifyServerRegistryChanged({
227
+ platform = process.platform,
228
+ execFile = execFileSync
229
+ } = {}) {
230
+ if (platform !== 'darwin') return false;
231
+ try {
232
+ execFile('notifyutil', ['-p', 'dev.vizzly.serverChanged'], {
233
+ stdio: 'ignore',
234
+ timeout: 500
235
+ });
236
+ return true;
237
+ } catch {
238
+ // Non-fatal - menubar will still see changes via file watching
239
+ return false;
240
+ }
241
+ }
221
242
 
222
243
  /**
223
244
  * Check if a port is free (not in use by any process)
@@ -57,6 +57,29 @@ export function clearBaselineData(paths) {
57
57
  }
58
58
  }
59
59
 
60
+ /**
61
+ * Clear per-run screenshot artifacts while preserving approved baselines.
62
+ *
63
+ * @param {{ currentPath: string, diffPath: string }} paths
64
+ */
65
+ export function clearRunData(paths) {
66
+ let {
67
+ currentPath,
68
+ diffPath
69
+ } = paths;
70
+ for (let dir of [currentPath, diffPath]) {
71
+ if (existsSync(dir)) {
72
+ rmSync(dir, {
73
+ recursive: true,
74
+ force: true
75
+ });
76
+ }
77
+ mkdirSync(dir, {
78
+ recursive: true
79
+ });
80
+ }
81
+ }
82
+
60
83
  /**
61
84
  * Save an image as baseline
62
85
  *
@@ -32,6 +32,9 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
32
32
  diffPath,
33
33
  overwrite: true,
34
34
  includeClusters: true,
35
+ includeSSIM: true,
36
+ includeGMSD: true,
37
+ clusterMerge: true,
35
38
  minClusterSize
36
39
  });
37
40
  }
@@ -141,6 +144,7 @@ export function buildFailedComparison(params) {
141
144
  regionData
142
145
  } = params;
143
146
  let diffClusters = honeydiffResult.diffClusters || [];
147
+ let ssimScore = honeydiffResult.perceptualScore ?? honeydiffResult.ssimScore ?? honeydiffResult.ssim_score ?? null;
144
148
  let isFiltered = false;
145
149
  let filterReason = 'pixel-diff';
146
150
 
@@ -150,7 +154,9 @@ export function buildFailedComparison(params) {
150
154
  let confirmedRegions = regionData?.confirmed || [];
151
155
  if (confirmedRegions.length > 0 && diffClusters.length > 0) {
152
156
  regionCoverage = calculateRegionCoverage(diffClusters, confirmedRegions);
153
- if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage)) {
157
+ if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage, {
158
+ ssimScore
159
+ })) {
154
160
  isRegionFiltered = true;
155
161
  isFiltered = true;
156
162
  filterReason = 'region-filtered';
@@ -198,8 +204,11 @@ export function buildFailedComparison(params) {
198
204
  coverage: regionCoverage.coverage,
199
205
  clustersInRegions: regionCoverage.clustersInRegions,
200
206
  totalClusters: regionCoverage.totalClusters,
207
+ pixelsInRegions: regionCoverage.pixelsInRegions,
208
+ totalPixels: regionCoverage.totalPixels,
201
209
  matchedRegions: regionCoverage.matchedRegions,
202
210
  confirmedCount: confirmedRegions.length,
211
+ ssimScore,
203
212
  isFiltered: isRegionFiltered
204
213
  } : null,
205
214
  // Include confirmed regions for visualization in UI
@@ -55,17 +55,4 @@ export async function downloadHotspots(options) {
55
55
  error: error.message
56
56
  };
57
57
  }
58
- }
59
-
60
- /**
61
- * Extract screenshot names from a list of screenshots
62
- *
63
- * @param {Array} screenshots - Screenshots with name property
64
- * @returns {string[]}
65
- */
66
- export function extractScreenshotNames(screenshots) {
67
- if (!screenshots || !Array.isArray(screenshots)) {
68
- return [];
69
- }
70
- return screenshots.map(s => s.name).filter(Boolean);
71
58
  }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
12
- import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
12
+ import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getComparisonContext as defaultGetComparisonContext, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
13
13
  import { NetworkError } from '../errors/vizzly-error.js';
14
14
  import { colors as defaultColors } from '../utils/colors.js';
15
15
  import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
@@ -22,7 +22,8 @@ import { generateBaselineFilename as defaultGenerateBaselineFilename, generateCo
22
22
  import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
23
23
  import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
24
24
  import { loadRegionMetadata as defaultLoadRegionMetadata, saveRegionMetadata as defaultSaveRegionMetadata } from './metadata/region-metadata.js';
25
- import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
25
+ import { buildTddDependencyOps, resolveTddPaths, resolveTddWorkingDirectory } from './runtime-context.js';
26
+ import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, clearRunData as defaultClearRunData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
26
27
  import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
27
28
  import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
28
29
 
@@ -40,110 +41,80 @@ export function createTDDService(config, options = {}, deps = {}) {
40
41
  }
41
42
  export class TddService {
42
43
  constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null, deps = {}) {
43
- // Grouped dependencies with defaults
44
- let {
45
- // Core utilities
46
- output = defaultOutput,
47
- colors = defaultColors,
48
- validatePathSecurity = defaultValidatePathSecurity,
49
- initializeDirectories = defaultInitializeDirectories,
50
- // File system operations
51
- fs = {},
52
- // API operations
53
- api = {},
54
- // Baseline metadata operations
55
- metadata = {},
56
- // Baseline file management
57
- baseline = {},
58
- // Screenshot comparison
59
- comparison = {},
60
- // Signature generation and security
61
- signature = {},
62
- // Result building
63
- results = {},
64
- // Other
65
- calculateHotspotCoverage = defaultCalculateHotspotCoverage
66
- } = deps;
67
-
68
- // Merge grouped deps with defaults
69
- let fsOps = {
70
- existsSync: defaultExistsSync,
71
- mkdirSync: defaultMkdirSync,
72
- readFileSync: defaultReadFileSync,
73
- writeFileSync: defaultWriteFileSync,
74
- ...fs
75
- };
76
- let apiOps = {
77
- createApiClient: defaultCreateApiClient,
78
- getTddBaselines: defaultGetTddBaselines,
79
- getBuilds: defaultGetBuilds,
80
- getComparison: defaultGetComparison,
81
- getBatchHotspots: defaultGetBatchHotspots,
82
- fetchWithTimeout: defaultFetchWithTimeout,
83
- getDefaultBranch: defaultGetDefaultBranch,
84
- ...api
85
- };
86
- let metadataOps = {
87
- loadBaselineMetadata: defaultLoadBaselineMetadata,
88
- saveBaselineMetadata: defaultSaveBaselineMetadata,
89
- createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
90
- upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
91
- loadHotspotMetadata: defaultLoadHotspotMetadata,
92
- saveHotspotMetadata: defaultSaveHotspotMetadata,
93
- loadRegionMetadata: defaultLoadRegionMetadata,
94
- saveRegionMetadata: defaultSaveRegionMetadata,
95
- ...metadata
96
- };
97
- let baselineOps = {
98
- baselineExists: defaultBaselineExists,
99
- clearBaselineData: defaultClearBaselineData,
100
- getBaselinePath: defaultGetBaselinePath,
101
- getCurrentPath: defaultGetCurrentPath,
102
- getDiffPath: defaultGetDiffPath,
103
- saveBaseline: defaultSaveBaseline,
104
- saveCurrent: defaultSaveCurrent,
105
- ...baseline
106
- };
107
- let comparisonOps = {
108
- compareImages: defaultCompareImages,
109
- buildPassedComparison: defaultBuildPassedComparison,
110
- buildNewComparison: defaultBuildNewComparison,
111
- buildFailedComparison: defaultBuildFailedComparison,
112
- buildErrorComparison: defaultBuildErrorComparison,
113
- isDimensionMismatchError: defaultIsDimensionMismatchError,
114
- ...comparison
115
- };
116
- let signatureOps = {
117
- generateScreenshotSignature: defaultGenerateScreenshotSignature,
118
- generateBaselineFilename: defaultGenerateBaselineFilename,
119
- generateComparisonId: defaultGenerateComparisonId,
120
- sanitizeScreenshotName: defaultSanitizeScreenshotName,
121
- validateScreenshotProperties: defaultValidateScreenshotProperties,
122
- safePath: defaultSafePath,
123
- ...signature
124
- };
125
- let resultsOps = {
126
- buildResults: defaultBuildResults,
127
- getFailedComparisons: defaultGetFailedComparisons,
128
- getNewComparisons: defaultGetNewComparisons,
129
- ...results
44
+ let defaults = {
45
+ output: defaultOutput,
46
+ colors: defaultColors,
47
+ validatePathSecurity: defaultValidatePathSecurity,
48
+ initializeDirectories: defaultInitializeDirectories,
49
+ calculateHotspotCoverage: defaultCalculateHotspotCoverage,
50
+ fs: {
51
+ existsSync: defaultExistsSync,
52
+ mkdirSync: defaultMkdirSync,
53
+ readFileSync: defaultReadFileSync,
54
+ writeFileSync: defaultWriteFileSync
55
+ },
56
+ api: {
57
+ createApiClient: defaultCreateApiClient,
58
+ getTddBaselines: defaultGetTddBaselines,
59
+ getBuilds: defaultGetBuilds,
60
+ getComparison: defaultGetComparison,
61
+ getComparisonContext: defaultGetComparisonContext,
62
+ getBatchHotspots: defaultGetBatchHotspots,
63
+ fetchWithTimeout: defaultFetchWithTimeout,
64
+ getDefaultBranch: defaultGetDefaultBranch
65
+ },
66
+ metadata: {
67
+ loadBaselineMetadata: defaultLoadBaselineMetadata,
68
+ saveBaselineMetadata: defaultSaveBaselineMetadata,
69
+ createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
70
+ upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
71
+ loadHotspotMetadata: defaultLoadHotspotMetadata,
72
+ saveHotspotMetadata: defaultSaveHotspotMetadata,
73
+ loadRegionMetadata: defaultLoadRegionMetadata,
74
+ saveRegionMetadata: defaultSaveRegionMetadata
75
+ },
76
+ baseline: {
77
+ baselineExists: defaultBaselineExists,
78
+ clearBaselineData: defaultClearBaselineData,
79
+ clearRunData: defaultClearRunData,
80
+ getBaselinePath: defaultGetBaselinePath,
81
+ getCurrentPath: defaultGetCurrentPath,
82
+ getDiffPath: defaultGetDiffPath,
83
+ saveBaseline: defaultSaveBaseline,
84
+ saveCurrent: defaultSaveCurrent
85
+ },
86
+ comparison: {
87
+ compareImages: defaultCompareImages,
88
+ buildPassedComparison: defaultBuildPassedComparison,
89
+ buildNewComparison: defaultBuildNewComparison,
90
+ buildFailedComparison: defaultBuildFailedComparison,
91
+ buildErrorComparison: defaultBuildErrorComparison,
92
+ isDimensionMismatchError: defaultIsDimensionMismatchError
93
+ },
94
+ signature: {
95
+ generateScreenshotSignature: defaultGenerateScreenshotSignature,
96
+ generateBaselineFilename: defaultGenerateBaselineFilename,
97
+ generateComparisonId: defaultGenerateComparisonId,
98
+ sanitizeScreenshotName: defaultSanitizeScreenshotName,
99
+ validateScreenshotProperties: defaultValidateScreenshotProperties,
100
+ safePath: defaultSafePath
101
+ },
102
+ results: {
103
+ buildResults: defaultBuildResults,
104
+ getFailedComparisons: defaultGetFailedComparisons,
105
+ getNewComparisons: defaultGetNewComparisons
106
+ }
130
107
  };
131
-
132
- // Store flattened dependencies for use in methods
133
- this._deps = {
108
+ let {
109
+ runtimeDeps,
110
+ apiOps
111
+ } = buildTddDependencyOps(deps, defaults);
112
+ let {
134
113
  output,
135
- colors,
136
114
  validatePathSecurity,
137
- initializeDirectories,
138
- calculateHotspotCoverage,
139
- ...fsOps,
140
- ...apiOps,
141
- ...metadataOps,
142
- ...baselineOps,
143
- ...comparisonOps,
144
- ...signatureOps,
145
- ...resultsOps
146
- };
115
+ initializeDirectories
116
+ } = runtimeDeps;
117
+ this._deps = runtimeDeps;
147
118
  this.config = config;
148
119
  this.setBaseline = setBaseline;
149
120
  this.authService = authService;
@@ -155,15 +126,8 @@ export class TddService {
155
126
  });
156
127
 
157
128
  // Validate and secure the working directory
158
- try {
159
- this.workingDir = validatePathSecurity(workingDir, workingDir);
160
- } catch (error) {
161
- output.error(`Invalid working directory: ${error.message}`);
162
- throw new Error(`Working directory validation failed: ${error.message}`);
163
- }
164
-
165
- // Initialize directories using extracted module
166
- let paths = initializeDirectories(this.workingDir);
129
+ this.workingDir = resolveTddWorkingDirectory(workingDir, validatePathSecurity, output);
130
+ let paths = resolveTddPaths(this.workingDir, initializeDirectories);
167
131
  this.baselinePath = paths.baselinePath;
168
132
  this.currentPath = paths.currentPath;
169
133
  this.diffPath = paths.diffPath;
@@ -171,7 +135,7 @@ export class TddService {
171
135
  // State
172
136
  this.baselineData = null;
173
137
  this.comparisons = [];
174
- this.threshold = config.comparison?.threshold || 2.0;
138
+ this.threshold = config.comparison?.threshold ?? 2.0;
175
139
  this.minClusterSize = config.comparison?.minClusterSize ?? 2;
176
140
  this.signatureProperties = config.signatureProperties ?? [];
177
141
 
@@ -187,6 +151,12 @@ export class TddService {
187
151
  output.info('[vizzly] Baseline update mode - will overwrite existing baselines with new ones');
188
152
  }
189
153
  }
154
+ clearRunData() {
155
+ this._deps.clearRunData({
156
+ currentPath: this.currentPath,
157
+ diffPath: this.diffPath
158
+ });
159
+ }
190
160
 
191
161
  /**
192
162
  * Download baselines from cloud
@@ -200,6 +170,7 @@ export class TddService {
200
170
  getTddBaselines,
201
171
  getBuilds,
202
172
  getComparison,
173
+ getComparisonContext,
203
174
  clearBaselineData,
204
175
  generateScreenshotSignature,
205
176
  generateBaselineFilename,
@@ -252,10 +223,19 @@ export class TddService {
252
223
  output.warn(`Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
253
224
  }
254
225
  baselineBuild.screenshots = apiResponse.screenshots;
226
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
255
227
  } else if (comparisonId) {
256
228
  // Handle specific comparison download
257
229
  output.info(`Using comparison: ${comparisonId}`);
258
230
  let comparison = await getComparison(this.client, comparisonId);
231
+ let comparisonContext = null;
232
+ if (!comparison.dynamic_content && getComparisonContext) {
233
+ try {
234
+ comparisonContext = await getComparisonContext(this.client, comparisonId);
235
+ } catch (error) {
236
+ output.debug('tdd', `comparison context unavailable for ${comparisonId}: ${error.message}`);
237
+ }
238
+ }
259
239
  if (!comparison.baseline_screenshot) {
260
240
  throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot.`);
261
241
  }
@@ -286,6 +266,11 @@ export class TddService {
286
266
  }
287
267
  }
288
268
  let screenshotName = comparison.baseline_name || comparison.current_name;
269
+ let dynamicContent = comparison.dynamic_content || {
270
+ hotspot_analysis: comparisonContext?.history?.hotspot_analysis,
271
+ confirmed_regions: comparisonContext?.history?.confirmed_regions
272
+ };
273
+ this.saveDynamicMetadataForScreenshot(screenshotName, dynamicContent);
289
274
  let signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
290
275
  let filename = generateBaselineFilename(screenshotName, signature);
291
276
  baselineBuild = {
@@ -325,16 +310,7 @@ export class TddService {
325
310
  }
326
311
  baselineBuild = apiResponse.build;
327
312
  baselineBuild.screenshots = apiResponse.screenshots;
328
-
329
- // Store bundled hotspots and regions from API response
330
- if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
331
- this.hotspotData = apiResponse.hotspots;
332
- saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
333
- }
334
- if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
335
- this.regionData = apiResponse.regions;
336
- saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
337
- }
313
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
338
314
  }
339
315
  let buildDetails = baselineBuild;
340
316
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
@@ -519,6 +495,49 @@ export class TddService {
519
495
  throw error;
520
496
  }
521
497
  }
498
+ saveDynamicMetadataFromBaselineResponse(apiResponse = {}) {
499
+ let {
500
+ saveHotspotMetadata,
501
+ saveRegionMetadata
502
+ } = this._deps;
503
+ let hasHotspots = Object.keys(apiResponse.hotspots || {}).length > 0;
504
+ let hasRegions = Object.keys(apiResponse.regions || {}).length > 0;
505
+ if (hasHotspots) {
506
+ this.hotspotData = apiResponse.hotspots;
507
+ saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
508
+ }
509
+ if (hasRegions) {
510
+ this.regionData = apiResponse.regions;
511
+ saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
512
+ }
513
+ }
514
+ saveDynamicMetadataForScreenshot(screenshotName, dynamicContent = {}) {
515
+ if (!screenshotName) {
516
+ return;
517
+ }
518
+ let hotspotAnalysis = dynamicContent.hotspot_analysis;
519
+ let confirmedRegions = dynamicContent.confirmed_regions || [];
520
+ let hasHotspotAnalysis = hotspotAnalysis?.total_builds_analyzed > 0;
521
+ let hasConfirmedRegions = confirmedRegions.length > 0;
522
+ let summary = {
523
+ screenshots: 1,
524
+ hotspots: hasHotspotAnalysis ? 1 : 0,
525
+ regions: hasConfirmedRegions ? 1 : 0,
526
+ total_regions: confirmedRegions.length
527
+ };
528
+ this.saveDynamicMetadataFromBaselineResponse({
529
+ hotspots: hasHotspotAnalysis ? {
530
+ [screenshotName]: hotspotAnalysis
531
+ } : {},
532
+ regions: hasConfirmedRegions ? {
533
+ [screenshotName]: {
534
+ confirmed: confirmedRegions,
535
+ candidates: []
536
+ }
537
+ } : {},
538
+ summary
539
+ });
540
+ }
522
541
 
523
542
  /**
524
543
  * Process already-fetched baseline data (for use when caller handles auth)
@@ -557,20 +576,7 @@ export class TddService {
557
576
  }
558
577
  }
559
578
  let baselineBuild = apiResponse.build;
560
-
561
- // Store bundled hotspots and regions from API response
562
- let {
563
- saveHotspotMetadata,
564
- saveRegionMetadata
565
- } = this._deps;
566
- if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
567
- this.hotspotData = apiResponse.hotspots;
568
- saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
569
- }
570
- if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
571
- this.regionData = apiResponse.regions;
572
- saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
573
- }
579
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
574
580
  if (baselineBuild.status === 'failed') {
575
581
  output.warn(`Build ${buildId} is marked as FAILED - falling back to local baselines`);
576
582
  return await this.handleLocalBaselines();
@@ -915,7 +921,7 @@ export class TddService {
915
921
  return null;
916
922
  }
917
923
  this.baselineData = metadata;
918
- this.threshold = metadata.threshold || this.threshold;
924
+ this.threshold = metadata.threshold ?? this.threshold;
919
925
  this.signatureProperties = metadata.signatureProperties || this.signatureProperties;
920
926
  if (this.signatureProperties.length > 0) {
921
927
  output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
@@ -4,6 +4,40 @@
4
4
  * @module @vizzly-testing/cli/client
5
5
  */
6
6
 
7
+ export type ClientLogLevel = 'debug' | 'info' | 'warn' | 'error';
8
+
9
+ export const LOG_LEVELS: Readonly<Record<ClientLogLevel, number>>;
10
+
11
+ /**
12
+ * Check whether client SDK output should log at the requested level.
13
+ */
14
+ export function shouldLogClient(
15
+ level: string,
16
+ configuredLevel?: string
17
+ ): boolean;
18
+
19
+ /**
20
+ * Auto-discover a local TDD server by searching for `.vizzly/server.json`.
21
+ */
22
+ export function autoDiscoverTddServer(
23
+ startDir?: string,
24
+ deps?: {
25
+ exists?: (path: string) => boolean;
26
+ readFile?: (path: string, encoding: BufferEncoding) => string | Buffer;
27
+ }
28
+ ): string | null;
29
+
30
+ /**
31
+ * Result returned by a successful screenshot capture.
32
+ */
33
+ export interface ScreenshotResult {
34
+ success: boolean;
35
+ status?: 'passed' | 'failed' | 'new';
36
+ name?: string;
37
+ diffPercentage?: number;
38
+ [key: string]: unknown;
39
+ }
40
+
7
41
  /**
8
42
  * Take a screenshot for visual regression testing
9
43
  *
@@ -38,8 +72,9 @@ export function vizzlyScreenshot(
38
72
  threshold?: number;
39
73
  minClusterSize?: number;
40
74
  fullPage?: boolean;
75
+ [key: string]: unknown;
41
76
  }
42
- ): Promise<void>;
77
+ ): Promise<ScreenshotResult | null>;
43
78
 
44
79
  /**
45
80
  * Flush result summary returned by vizzlyFlush