@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
@@ -12,30 +12,10 @@ import { existsSync } from 'node:fs';
12
12
  import { writeFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { cosmiconfigSync } from 'cosmiconfig';
15
+ import { CONFIG_DEFAULTS } from '../config/core.js';
15
16
  import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
16
17
  import * as output from '../utils/output.js';
17
18
 
18
- /**
19
- * Default configuration values
20
- */
21
- let DEFAULT_CONFIG = {
22
- comparison: {
23
- threshold: 2.0,
24
- minClusterSize: 2
25
- },
26
- server: {
27
- port: 47392,
28
- timeout: 30000
29
- },
30
- build: {
31
- name: 'Build {timestamp}',
32
- environment: 'test'
33
- },
34
- tdd: {
35
- openReport: false
36
- }
37
- };
38
-
39
19
  /**
40
20
  * Create a config service instance
41
21
  * @param {Object} options
@@ -76,9 +56,7 @@ export function createConfigService({
76
56
  * Get merged configuration with source tracking
77
57
  */
78
58
  async function getMergedConfig() {
79
- let config = {
80
- ...DEFAULT_CONFIG
81
- };
59
+ let config = cloneConfigValue(CONFIG_DEFAULTS);
82
60
  let sources = {};
83
61
 
84
62
  // Layer 1: Global config
@@ -100,15 +78,15 @@ export function createConfigService({
100
78
 
101
79
  // Layer 3: Environment variables
102
80
  if (process.env.VIZZLY_THRESHOLD) {
103
- config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
81
+ config.comparison.threshold = Number(process.env.VIZZLY_THRESHOLD);
104
82
  sources.comparison = 'env';
105
83
  }
106
84
  if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
107
- config.comparison.minClusterSize = parseInt(process.env.VIZZLY_MIN_CLUSTER_SIZE, 10);
85
+ config.comparison.minClusterSize = Number(process.env.VIZZLY_MIN_CLUSTER_SIZE);
108
86
  sources.comparison = 'env';
109
87
  }
110
88
  if (process.env.VIZZLY_PORT) {
111
- config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
89
+ config.server.port = Number(process.env.VIZZLY_PORT);
112
90
  sources.server = 'env';
113
91
  }
114
92
  return {
@@ -241,7 +219,7 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
241
219
  // Validate threshold
242
220
  if (config.comparison?.threshold !== undefined) {
243
221
  let threshold = config.comparison.threshold;
244
- if (typeof threshold !== 'number' || threshold < 0) {
222
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold) || threshold < 0) {
245
223
  errors.push('comparison.threshold must be a non-negative number');
246
224
  } else if (threshold > 100) {
247
225
  warnings.push('comparison.threshold above 100 may cause all comparisons to pass');
@@ -292,30 +270,44 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
292
270
  * Deep merge two objects
293
271
  */
294
272
  function mergeDeep(target, source) {
295
- let result = {
296
- ...target
297
- };
273
+ let result = cloneConfigValue(target);
298
274
  for (let key in source) {
299
275
  if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
300
276
  result[key] = mergeDeep(result[key] || {}, source[key]);
277
+ } else if (Array.isArray(source[key])) {
278
+ result[key] = [...source[key]];
301
279
  } else {
302
280
  result[key] = source[key];
303
281
  }
304
282
  }
305
283
  return result;
306
284
  }
285
+ function cloneConfigValue(value) {
286
+ if (Array.isArray(value)) {
287
+ return value.map(cloneConfigValue);
288
+ }
289
+ if (value && typeof value === 'object') {
290
+ let clone = {};
291
+ for (let key in value) {
292
+ clone[key] = cloneConfigValue(value[key]);
293
+ }
294
+ return clone;
295
+ }
296
+ return value;
297
+ }
307
298
 
308
299
  /**
309
300
  * Merge config with source tracking
310
301
  */
311
- function mergeWithTracking(target, source, sources, sourceName) {
302
+ function mergeWithTracking(target, source, sources, sourceName, sectionName) {
312
303
  for (let key in source) {
304
+ let sourceSection = sectionName || key;
313
305
  if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
314
306
  if (!target[key]) target[key] = {};
315
- mergeWithTracking(target[key], source[key], sources, sourceName);
307
+ mergeWithTracking(target[key], source[key], sources, sourceName, sourceSection);
316
308
  } else {
317
309
  target[key] = source[key];
318
- sources[key] = sourceName;
310
+ sources[sourceSection] = sourceName;
319
311
  }
320
312
  }
321
313
  }
@@ -9,7 +9,7 @@
9
9
  * This factory is only used by cli.js to provide services to plugins.
10
10
  */
11
11
 
12
- import { ServerManager } from './server-manager.js';
12
+ import { createServerManager } from '../server-manager/index.js';
13
13
  import { TestRunner } from './test-runner.js';
14
14
 
15
15
  /**
@@ -26,9 +26,7 @@ import { TestRunner } from './test-runner.js';
26
26
  * @returns {Object} Services object for plugins
27
27
  */
28
28
  export function createServices(config) {
29
- let serverManager = new ServerManager(config, {
30
- services: {}
31
- });
29
+ let serverManager = createServerManager(config, {});
32
30
  let testRunner = new TestRunner(config, serverManager);
33
31
  return {
34
32
  serverManager,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
  let __dirname = dirname(fileURLToPath(import.meta.url));
12
12
 
13
13
  /**
@@ -119,6 +119,13 @@ ${css}
119
119
  </body>
120
120
  </html>`;
121
121
  }
122
+ export async function loadStaticReportRenderer(ssrModulePath = getSSRModulePath()) {
123
+ let moduleUrl = pathToFileURL(ssrModulePath).href;
124
+ let {
125
+ renderStaticReport
126
+ } = await import(moduleUrl);
127
+ return renderStaticReport;
128
+ }
122
129
 
123
130
  /**
124
131
  * Generate a static report from the current TDD results
@@ -155,17 +162,9 @@ export async function generateStaticReport(workingDir, options = {}) {
155
162
 
156
163
  // Transform image URLs to relative paths
157
164
  let transformedData = transformImageUrls(reportData);
158
-
159
- // Load and use the SSR module
160
- let ssrModulePath = getSSRModulePath();
161
- let {
162
- renderStaticReport
163
- } = await import(ssrModulePath);
165
+ let renderStaticReport = options.renderStaticReport || (await loadStaticReportRenderer());
164
166
  let renderedContent = renderStaticReport(transformedData);
165
-
166
- // Get CSS
167
- let reporterDistPath = getReporterDistPath();
168
- let css = readFileSync(join(reporterDistPath, 'reporter-bundle.css'), 'utf8');
167
+ let css = options.css ?? readFileSync(join(getReporterDistPath(), 'reporter-bundle.css'), 'utf8');
169
168
 
170
169
  // Create output directory
171
170
  mkdirSync(outputDir, {
@@ -201,5 +200,5 @@ export async function generateStaticReport(workingDir, options = {}) {
201
200
  * Get the file:// URL for a report path
202
201
  */
203
202
  export function getReportFileUrl(reportPath) {
204
- return `file://${reportPath}`;
203
+ return pathToFileURL(reportPath).href;
205
204
  }
@@ -2,16 +2,15 @@
2
2
  * Test Runner Service
3
3
  * Orchestrates the test execution flow
4
4
  *
5
- * This class is a thin wrapper around the functional operations in
6
- * src/test-runner/. It maintains backwards compatibility while
7
- * delegating to pure functions for testability.
5
+ * This EventEmitter adapter keeps the stable plugin-facing runner contract
6
+ * while delegating execution to the functional operations in src/test-runner/.
8
7
  */
9
8
 
10
9
  import { spawn } from 'node:child_process';
11
10
  import { EventEmitter } from 'node:events';
12
11
  import { createBuild as createApiBuild, createApiClient, finalizeBuild as finalizeApiBuild, getBuild } from '../api/index.js';
13
12
  import { VizzlyError } from '../errors/vizzly-error.js';
14
- import { cancelTests, createBuild, finalizeBuild, initializeDaemon, runTests } from '../test-runner/index.js';
13
+ import { cancelTests, createBuild, fetchBuildUrl, finalizeBuild, initializeDaemon, runTests } from '../test-runner/index.js';
15
14
  import * as output from '../utils/output.js';
16
15
  import { writeSession as defaultWriteSession } from '../utils/session.js';
17
16
  import { createBuildObject } from './build-manager.js';
@@ -81,6 +80,7 @@ export class TestRunner extends EventEmitter {
81
80
  return result;
82
81
  }
83
82
  async createBuild(options, tdd) {
83
+ let createdApiBuild = null;
84
84
  let buildId = await createBuild({
85
85
  runOptions: options,
86
86
  tdd,
@@ -88,11 +88,23 @@ export class TestRunner extends EventEmitter {
88
88
  deps: {
89
89
  buildManager: this.buildManager,
90
90
  createApiClient: this.deps.createApiClient,
91
- createApiBuild: this.deps.createApiBuild,
91
+ createApiBuild: async (client, payload) => {
92
+ createdApiBuild = await this.deps.createApiBuild(client, payload);
93
+ return createdApiBuild;
94
+ },
92
95
  output: this.deps.output
93
96
  }
94
97
  });
95
98
  if (!tdd && buildId) {
99
+ let buildUrl = createdApiBuild?.url || (await fetchBuildUrl({
100
+ buildId,
101
+ config: this.config,
102
+ deps: {
103
+ createApiClient: this.deps.createApiClient,
104
+ getBuild: this.deps.getBuild,
105
+ output: this.deps.output
106
+ }
107
+ }));
96
108
  let writeSession = this.deps.writeSession || defaultWriteSession;
97
109
  writeSession({
98
110
  buildId,
@@ -100,6 +112,10 @@ export class TestRunner extends EventEmitter {
100
112
  commit: options?.commit,
101
113
  parallelId: options?.parallelId
102
114
  });
115
+ this.emit('build-created', {
116
+ buildId,
117
+ url: buildUrl
118
+ });
103
119
  }
104
120
  return buildId;
105
121
  }
@@ -119,28 +135,6 @@ export class TestRunner extends EventEmitter {
119
135
  }
120
136
  });
121
137
  }
122
- async executeTestCommand(testCommand, env) {
123
- return new Promise((resolve, reject) => {
124
- let proc = this.deps.spawn(testCommand, {
125
- env,
126
- stdio: 'inherit',
127
- shell: true
128
- });
129
- this.testProcess = proc;
130
- proc.on('error', error => {
131
- reject(this.deps.createError(`Failed to run test command: ${error.message}`, 'TEST_COMMAND_FAILED'));
132
- });
133
- proc.on('exit', (code, signal) => {
134
- if (signal === 'SIGINT') {
135
- reject(this.deps.createError('Test command was interrupted', 'TEST_COMMAND_INTERRUPTED'));
136
- } else if (code !== 0) {
137
- reject(this.deps.createError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
138
- } else {
139
- resolve();
140
- }
141
- });
142
- });
143
- }
144
138
  async cancel() {
145
139
  await cancelTests({
146
140
  testProcess: this.testProcess,
@@ -9,6 +9,8 @@
9
9
  * 2D boxes that users have manually confirmed via the cloud UI.
10
10
  */
11
11
 
12
+ let REGION_CENTER_TOLERANCE = 10;
13
+
12
14
  /**
13
15
  * Check if a diff cluster intersects with a region (2D box intersection)
14
16
  *
@@ -41,13 +43,77 @@ export function clusterIntersectsRegion(cluster, region) {
41
43
  let noOverlap = clusterX2 < region.x1 || clusterX1 > region.x2 || clusterY2 < region.y1 || clusterY1 > region.y2;
42
44
  return !noOverlap;
43
45
  }
46
+ function normalizeBoundingBox(cluster) {
47
+ let box = cluster?.boundingBox || cluster;
48
+ if (!box) {
49
+ return null;
50
+ }
51
+ if (Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width) && Number.isFinite(box.height)) {
52
+ return {
53
+ x1: box.x,
54
+ y1: box.y,
55
+ x2: box.x + box.width,
56
+ y2: box.y + box.height,
57
+ width: Math.abs(box.width),
58
+ height: Math.abs(box.height)
59
+ };
60
+ }
61
+ if (Number.isFinite(box.x1) && Number.isFinite(box.y1) && Number.isFinite(box.x2) && Number.isFinite(box.y2)) {
62
+ let width = Math.abs(box.x2 - box.x1);
63
+ let height = Math.abs(box.y2 - box.y1);
64
+ return {
65
+ x1: box.x1,
66
+ y1: box.y1,
67
+ x2: box.x2,
68
+ y2: box.y2,
69
+ width,
70
+ height
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+ function getBoundingBoxCenter(box) {
76
+ return {
77
+ x: (box.x1 + box.x2) / 2,
78
+ y: (box.y1 + box.y2) / 2
79
+ };
80
+ }
81
+ function getRegionLabel(region) {
82
+ return region.id || region.label || null;
83
+ }
84
+ function clusterMatchesRegion(cluster, region) {
85
+ let clusterBox = normalizeBoundingBox(cluster);
86
+ let regionBox = normalizeBoundingBox(region);
87
+ if (!clusterBox || !regionBox) {
88
+ return false;
89
+ }
90
+ let clusterCenter = getBoundingBoxCenter(clusterBox);
91
+ let regionCenter = getBoundingBoxCenter(regionBox);
92
+ let distance = Math.sqrt((clusterCenter.x - regionCenter.x) ** 2 + (clusterCenter.y - regionCenter.y) ** 2);
93
+ return distance <= REGION_CENTER_TOLERANCE;
94
+ }
95
+ function estimateClusterPixels(cluster) {
96
+ if (Number.isFinite(cluster?.pixelCount)) {
97
+ return cluster.pixelCount;
98
+ }
99
+ let box = normalizeBoundingBox(cluster);
100
+ if (!box) {
101
+ return 0;
102
+ }
103
+ return box.width * box.height * 0.5;
104
+ }
44
105
 
45
106
  /**
46
- * Calculate what percentage of diff clusters fall within region boxes
107
+ * Calculate what percentage of changed pixels match confirmed regions.
108
+ *
109
+ * This mirrors cloud dynamic-region approval: clusters match confirmed regions
110
+ * by center proximity, then coverage is weighted by changed pixels. A huge
111
+ * unmatched change should not be hidden just because several tiny clusters
112
+ * match confirmed regions.
47
113
  *
48
114
  * @param {Array} diffClusters - Array of diff clusters from honeydiff
49
115
  * @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
50
- * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
116
+ * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[], pixelsInRegions: number, totalPixels: number }}
51
117
  */
52
118
  export function calculateRegionCoverage(diffClusters, regions) {
53
119
  if (!diffClusters || diffClusters.length === 0) {
@@ -55,7 +121,9 @@ export function calculateRegionCoverage(diffClusters, regions) {
55
121
  coverage: 0,
56
122
  clustersInRegions: 0,
57
123
  totalClusters: 0,
58
- matchedRegions: []
124
+ matchedRegions: [],
125
+ pixelsInRegions: 0,
126
+ totalPixels: 0
59
127
  };
60
128
  }
61
129
  if (!regions || regions.length === 0) {
@@ -63,35 +131,41 @@ export function calculateRegionCoverage(diffClusters, regions) {
63
131
  coverage: 0,
64
132
  clustersInRegions: 0,
65
133
  totalClusters: diffClusters.length,
66
- matchedRegions: []
134
+ matchedRegions: [],
135
+ pixelsInRegions: 0,
136
+ totalPixels: 0
67
137
  };
68
138
  }
69
139
  let clustersInRegions = 0;
70
140
  let matchedRegionIds = new Set();
141
+ let pixelsInRegions = 0;
142
+ let totalPixels = 0;
71
143
  for (let cluster of diffClusters) {
72
- // Check if this cluster intersects any region
73
- let intersectsAnyRegion = false;
144
+ let pixelCount = estimateClusterPixels(cluster);
145
+ totalPixels += pixelCount;
146
+ let matchedAnyRegion = false;
74
147
  for (let region of regions) {
75
- if (clusterIntersectsRegion(cluster, region)) {
76
- intersectsAnyRegion = true;
77
- // Track which regions were matched (for debugging/display)
78
- if (region.id) {
79
- matchedRegionIds.add(region.id);
80
- } else if (region.label) {
81
- matchedRegionIds.add(region.label);
148
+ if (clusterMatchesRegion(cluster, region)) {
149
+ matchedAnyRegion = true;
150
+ let regionLabel = getRegionLabel(region);
151
+ if (regionLabel) {
152
+ matchedRegionIds.add(regionLabel);
82
153
  }
83
154
  }
84
155
  }
85
- if (intersectsAnyRegion) {
156
+ if (matchedAnyRegion) {
86
157
  clustersInRegions++;
158
+ pixelsInRegions += pixelCount;
87
159
  }
88
160
  }
89
- let coverage = clustersInRegions / diffClusters.length;
161
+ let coverage = totalPixels > 0 ? pixelsInRegions / totalPixels : 0;
90
162
  return {
91
163
  coverage,
92
164
  clustersInRegions,
93
165
  totalClusters: diffClusters.length,
94
- matchedRegions: [...matchedRegionIds]
166
+ matchedRegions: [...matchedRegionIds],
167
+ pixelsInRegions,
168
+ totalPixels
95
169
  };
96
170
  }
97
171
 
@@ -99,17 +173,31 @@ export function calculateRegionCoverage(diffClusters, regions) {
99
173
  * Determine if a comparison should auto-pass based on region coverage
100
174
  *
101
175
  * Unlike hotspots which require confidence scoring, user-defined regions
102
- * are already confirmed by humans, so we only need the 80% threshold.
176
+ * are already confirmed by humans. Cloud still requires strong structural
177
+ * similarity so a layout shift cannot hide inside a confirmed box.
103
178
  *
104
179
  * @param {Array} regions - Confirmed regions (already filtered to confirmed status)
105
180
  * @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
181
+ * @param {Object} options - Approval thresholds
182
+ * @param {number|null} options.ssimScore - Honeydiff SSIM/perceptual score
183
+ * @param {number} options.coverageThreshold - Required region coverage
184
+ * @param {number} options.ssimThreshold - Required structural similarity
106
185
  * @returns {boolean} True if diff should auto-pass as region-filtered
107
186
  */
108
- export function shouldAutoApproveFromRegions(regions, coverageResult) {
187
+ export function shouldAutoApproveFromRegions(regions, coverageResult, options = {}) {
109
188
  if (!regions || regions.length === 0 || !coverageResult) {
110
189
  return false;
111
190
  }
112
-
113
- // Need at least 80% of diff clusters in confirmed regions
114
- return coverageResult.coverage >= 0.8;
191
+ let {
192
+ ssimScore = null,
193
+ coverageThreshold = 0.9,
194
+ ssimThreshold = 0.95
195
+ } = options;
196
+ if (coverageResult.coverage < coverageThreshold) {
197
+ return false;
198
+ }
199
+ if (ssimScore === null || ssimScore === undefined) {
200
+ return false;
201
+ }
202
+ return ssimScore >= ssimThreshold;
115
203
  }
package/dist/tdd/index.js CHANGED
@@ -15,5 +15,5 @@ export { baselineMatchesSha, buildBaselineMetadataEntry, downloadBaselineImage,
15
15
  // Services
16
16
  export { baselineExists, clearBaselineData, getBaselinePath, getCurrentPath, getDiffPath, initializeDirectories, promoteCurrentToBaseline, readBaseline, readCurrent, saveBaseline, saveCurrent } from './services/baseline-manager.js';
17
17
  export { buildErrorComparison, buildFailedComparison, buildNewComparison, buildPassedComparison, compareImages, isDimensionMismatchError } from './services/comparison-service.js';
18
- export { downloadHotspots, extractScreenshotNames } from './services/hotspot-service.js';
18
+ export { downloadHotspots } from './services/hotspot-service.js';
19
19
  export { buildResults, calculateSummary, findComparison, findComparisonById, getErrorComparisons, getFailedComparisons, getNewComparisons, isSuccessful } from './services/result-service.js';
@@ -53,41 +53,4 @@ export function saveRegionMetadata(workingDir, regionData, summary = {}) {
53
53
  regions: regionData
54
54
  };
55
55
  writeFileSync(regionsPath, JSON.stringify(content, null, 2));
56
- }
57
-
58
- /**
59
- * Get regions for a specific screenshot with caching support
60
- *
61
- * This is a pure function that takes a cache object as parameter
62
- * for stateless operation. The cache is mutated if data needs to be loaded.
63
- *
64
- * @param {Object} cache - Cache object { data: Object|null, loaded: boolean }
65
- * @param {string} workingDir - Working directory
66
- * @param {string} screenshotName - Name of the screenshot
67
- * @returns {Object|null} Region data or null if not available
68
- */
69
- export function getRegionsForScreenshot(cache, workingDir, screenshotName) {
70
- // Check cache first
71
- if (cache.data?.[screenshotName]) {
72
- return cache.data[screenshotName];
73
- }
74
-
75
- // Load from disk if not yet loaded
76
- if (!cache.loaded) {
77
- cache.data = loadRegionMetadata(workingDir);
78
- cache.loaded = true;
79
- }
80
- return cache.data?.[screenshotName] || null;
81
- }
82
-
83
- /**
84
- * Create an empty region cache object
85
- *
86
- * @returns {{ data: null, loaded: boolean }}
87
- */
88
- export function createRegionCache() {
89
- return {
90
- data: null,
91
- loaded: false
92
- };
93
56
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Runtime context assembly for TddService constructor.
3
+ * Keeps dependency merging and path bootstrap logic isolated and testable.
4
+ */
5
+
6
+ export function buildTddDependencyOps(deps = {}, defaults = {}) {
7
+ let {
8
+ output = defaults.output,
9
+ colors = defaults.colors,
10
+ validatePathSecurity = defaults.validatePathSecurity,
11
+ initializeDirectories = defaults.initializeDirectories,
12
+ calculateHotspotCoverage = defaults.calculateHotspotCoverage,
13
+ fs = {},
14
+ api = {},
15
+ metadata = {},
16
+ baseline = {},
17
+ comparison = {},
18
+ signature = {},
19
+ results = {}
20
+ } = deps;
21
+ let fsOps = {
22
+ ...defaults.fs,
23
+ ...fs
24
+ };
25
+ let apiOps = {
26
+ ...defaults.api,
27
+ ...api
28
+ };
29
+ let metadataOps = {
30
+ ...defaults.metadata,
31
+ ...metadata
32
+ };
33
+ let baselineOps = {
34
+ ...defaults.baseline,
35
+ ...baseline
36
+ };
37
+ let comparisonOps = {
38
+ ...defaults.comparison,
39
+ ...comparison
40
+ };
41
+ let signatureOps = {
42
+ ...defaults.signature,
43
+ ...signature
44
+ };
45
+ let resultsOps = {
46
+ ...defaults.results,
47
+ ...results
48
+ };
49
+ let runtimeDeps = {
50
+ output,
51
+ colors,
52
+ validatePathSecurity,
53
+ initializeDirectories,
54
+ calculateHotspotCoverage,
55
+ ...fsOps,
56
+ ...apiOps,
57
+ ...metadataOps,
58
+ ...baselineOps,
59
+ ...comparisonOps,
60
+ ...signatureOps,
61
+ ...resultsOps
62
+ };
63
+ return {
64
+ runtimeDeps,
65
+ apiOps
66
+ };
67
+ }
68
+ export function resolveTddWorkingDirectory(workingDir, validatePathSecurity, output) {
69
+ try {
70
+ return validatePathSecurity(workingDir, workingDir);
71
+ } catch (error) {
72
+ output.error(`Invalid working directory: ${error.message}`);
73
+ throw new Error(`Working directory validation failed: ${error.message}`);
74
+ }
75
+ }
76
+ export function resolveTddPaths(workingDir, initializeDirectories) {
77
+ return initializeDirectories(workingDir);
78
+ }