@vizzly-testing/cli 0.29.6 → 0.31.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 (87) hide show
  1. package/README.md +34 -6
  2. package/dist/api/client.js +6 -3
  3. package/dist/api/endpoints.js +59 -0
  4. package/dist/api/index.js +1 -1
  5. package/dist/auth/core.js +1 -1
  6. package/dist/auth/index.js +2 -1
  7. package/dist/cli.js +206 -51
  8. package/dist/client/index.js +21 -29
  9. package/dist/commands/api.js +145 -90
  10. package/dist/commands/baselines.js +78 -47
  11. package/dist/commands/builds.js +9 -8
  12. package/dist/commands/comparisons.js +7 -8
  13. package/dist/commands/config-cmd.js +15 -7
  14. package/dist/commands/context.js +870 -0
  15. package/dist/commands/doctor.js +116 -81
  16. package/dist/commands/init.js +202 -201
  17. package/dist/commands/login.js +134 -65
  18. package/dist/commands/logout.js +18 -7
  19. package/dist/commands/preview.js +29 -57
  20. package/dist/commands/project.js +120 -0
  21. package/dist/commands/projects.js +9 -2
  22. package/dist/commands/review.js +236 -202
  23. package/dist/commands/run.js +75 -72
  24. package/dist/commands/status.js +228 -150
  25. package/dist/commands/tdd-daemon-process.js +98 -0
  26. package/dist/commands/tdd-daemon.js +318 -212
  27. package/dist/commands/tdd.js +23 -8
  28. package/dist/commands/upload.js +10 -9
  29. package/dist/commands/whoami.js +64 -26
  30. package/dist/config/core.js +37 -36
  31. package/dist/context/local-workspace-provider.js +532 -0
  32. package/dist/context/provider-resolver.js +36 -0
  33. package/dist/index.js +5 -4
  34. package/dist/plugin-loader.js +55 -49
  35. package/dist/project/core.js +2 -2
  36. package/dist/project/operations.js +6 -3
  37. package/dist/reporter/reporter-bundle.css +2 -1
  38. package/dist/reporter/reporter-bundle.iife.js +119 -119
  39. package/dist/reporter-ssr/ssr-entry.js +605 -509
  40. package/dist/sdk/index.js +162 -107
  41. package/dist/server/handlers/tdd-handler.js +30 -54
  42. package/dist/server/routers/assets.js +4 -0
  43. package/dist/server/routers/baseline.js +195 -178
  44. package/dist/server/routers/events.js +226 -148
  45. package/dist/server/routers/projects.js +14 -5
  46. package/dist/server/routers/screenshot.js +1 -31
  47. package/dist/services/build-manager.js +19 -97
  48. package/dist/services/config-service.js +26 -34
  49. package/dist/services/index.js +2 -4
  50. package/dist/services/static-report-generator.js +11 -12
  51. package/dist/services/test-runner.js +21 -27
  52. package/dist/tdd/core/hotspot-coverage.js +1 -1
  53. package/dist/tdd/index.js +1 -1
  54. package/dist/tdd/metadata/region-metadata.js +0 -37
  55. package/dist/tdd/runtime-context.js +78 -0
  56. package/dist/tdd/server-registry.js +38 -17
  57. package/dist/tdd/services/baseline-manager.js +23 -0
  58. package/dist/tdd/services/hotspot-service.js +1 -14
  59. package/dist/tdd/tdd-service.js +82 -113
  60. package/dist/types/client.d.ts +36 -1
  61. package/dist/types/index.d.ts +114 -22
  62. package/dist/types/sdk.d.ts +47 -7
  63. package/dist/uploader/core.js +5 -4
  64. package/dist/uploader/index.js +79 -2
  65. package/dist/uploader/operations.js +40 -5
  66. package/dist/utils/api-url.js +14 -0
  67. package/dist/utils/async-utils.js +26 -0
  68. package/dist/utils/ci-env.js +19 -12
  69. package/dist/utils/colors.js +46 -44
  70. package/dist/utils/config-loader.js +39 -65
  71. package/dist/utils/config-schema.js +19 -55
  72. package/dist/utils/fetch-utils.js +12 -5
  73. package/dist/utils/git.js +35 -72
  74. package/dist/utils/global-config.js +22 -3
  75. package/dist/utils/output.js +19 -7
  76. package/dist/utils/patterns.js +14 -0
  77. package/dist/utils/project-link-store.js +105 -0
  78. package/dist/utils/report-data.js +31 -0
  79. package/dist/utils/screenshot-options.js +28 -0
  80. package/dist/utils/secret-store.js +65 -0
  81. package/dist/utils/security.js +3 -2
  82. package/package.json +14 -11
  83. package/dist/services/screenshot-server.js +0 -65
  84. package/dist/services/server-manager.js +0 -81
  85. package/dist/services/uploader.js +0 -100
  86. package/dist/tdd/services/region-service.js +0 -75
  87. 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,
@@ -23,7 +23,7 @@ export function calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
23
23
  totalLines: 0
24
24
  };
25
25
  }
26
- if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
26
+ if (!hotspotAnalysis?.regions || hotspotAnalysis.regions.length === 0) {
27
27
  return {
28
28
  coverage: 0,
29
29
  linesInHotspots: 0,
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
+ }
@@ -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
  *
@@ -31,7 +31,7 @@ export async function downloadHotspots(options) {
31
31
  }
32
32
  try {
33
33
  let response = await api.getHotspots(screenshotNames);
34
- if (!response || !response.hotspots) {
34
+ if (!response?.hotspots) {
35
35
  return {
36
36
  success: false,
37
37
  error: 'API returned no hotspot data'
@@ -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
  }