@vizzly-testing/cli 0.30.0 → 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 (77) 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/index.js +1 -1
  47. package/dist/tdd/metadata/region-metadata.js +0 -37
  48. package/dist/tdd/runtime-context.js +78 -0
  49. package/dist/tdd/server-registry.js +38 -17
  50. package/dist/tdd/services/baseline-manager.js +23 -0
  51. package/dist/tdd/services/hotspot-service.js +0 -13
  52. package/dist/tdd/tdd-service.js +82 -113
  53. package/dist/types/client.d.ts +36 -1
  54. package/dist/types/index.d.ts +114 -22
  55. package/dist/types/sdk.d.ts +40 -0
  56. package/dist/uploader/core.js +5 -4
  57. package/dist/uploader/index.js +79 -2
  58. package/dist/uploader/operations.js +40 -5
  59. package/dist/utils/api-url.js +14 -0
  60. package/dist/utils/async-utils.js +26 -0
  61. package/dist/utils/ci-env.js +19 -12
  62. package/dist/utils/config-loader.js +39 -65
  63. package/dist/utils/config-schema.js +19 -55
  64. package/dist/utils/fetch-utils.js +12 -5
  65. package/dist/utils/git.js +35 -72
  66. package/dist/utils/global-config.js +20 -1
  67. package/dist/utils/output.js +19 -7
  68. package/dist/utils/patterns.js +14 -0
  69. package/dist/utils/project-link-store.js +105 -0
  70. package/dist/utils/screenshot-options.js +28 -0
  71. package/dist/utils/secret-store.js +65 -0
  72. package/package.json +11 -8
  73. package/dist/services/screenshot-server.js +0 -65
  74. package/dist/services/server-manager.js +0 -81
  75. package/dist/services/uploader.js +0 -100
  76. package/dist/tdd/services/region-service.js +0 -75
  77. package/dist/utils/build-history.js +0 -103
@@ -1,11 +1,88 @@
1
1
  /**
2
2
  * Uploader Module
3
3
  *
4
- * Exports pure functions (core) and I/O operations for screenshot uploading.
4
+ * Exports pure functions (core), I/O operations, and the public uploader
5
+ * factory for screenshot uploading.
5
6
  */
6
7
 
8
+ import { readFile, stat } from 'node:fs/promises';
9
+ import { glob } from 'glob';
10
+ import { checkShas, createApiClient, createBuild } from '../api/index.js';
11
+ import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
12
+ import { getDefaultBranch } from '../utils/git.js';
13
+ import * as output from '../utils/output.js';
14
+ import { resolveBatchSize, resolveTimeout } from './core.js';
15
+ import { upload as uploadOperation, waitForBuild as waitForBuildOperation } from './operations.js';
16
+ export function createUploader({
17
+ apiKey,
18
+ apiUrl,
19
+ userAgent,
20
+ command,
21
+ upload: uploadConfig = {}
22
+ } = {}, options = {}) {
23
+ let signal = options.signal || new AbortController().signal;
24
+ let client = createApiClient({
25
+ baseUrl: apiUrl,
26
+ token: apiKey,
27
+ command: command || 'upload',
28
+ sdkUserAgent: userAgent,
29
+ allowNoToken: true
30
+ });
31
+ let batchSize = resolveBatchSize(options, uploadConfig);
32
+ let timeout = resolveTimeout(options, uploadConfig);
33
+ let deps = options.deps || {
34
+ client,
35
+ createBuild,
36
+ getDefaultBranch,
37
+ glob,
38
+ readFile,
39
+ stat,
40
+ checkShas,
41
+ createError: (message, code, context) => {
42
+ let error = new UploadError(message, context);
43
+ error.code = code;
44
+ return error;
45
+ },
46
+ createValidationError: (message, context) => new ValidationError(message, context),
47
+ createUploadError: (message, context) => new UploadError(message, context),
48
+ createTimeoutError: (message, context) => new TimeoutError(message, context),
49
+ output
50
+ };
51
+ async function upload(uploadOptions) {
52
+ return uploadOperation({
53
+ uploadOptions,
54
+ config: {
55
+ apiKey,
56
+ apiUrl
57
+ },
58
+ signal,
59
+ batchSize,
60
+ deps: {
61
+ ...deps,
62
+ client: deps.client || client
63
+ }
64
+ });
65
+ }
66
+ async function waitForBuild(buildId, waitTimeout = timeout) {
67
+ return waitForBuildOperation({
68
+ buildId,
69
+ timeout: waitTimeout,
70
+ signal,
71
+ client: deps.client || client,
72
+ deps: {
73
+ createError: deps.createError,
74
+ createTimeoutError: deps.createTimeoutError
75
+ }
76
+ });
77
+ }
78
+ return {
79
+ upload,
80
+ waitForBuild
81
+ };
82
+ }
83
+
7
84
  // Core - pure functions
8
- export { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, computeSha256, DEFAULT_BATCH_SIZE, DEFAULT_SHA_CHECK_BATCH_SIZE, DEFAULT_TIMEOUT, extractBrowserFromFilename, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, resolveBatchSize, resolveTimeout, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
85
+ export { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, computeSha256, DEFAULT_BATCH_SIZE, DEFAULT_BUILD_POLL_INTERVAL, DEFAULT_SHA_CHECK_BATCH_SIZE, DEFAULT_TIMEOUT, extractBrowserFromFilename, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, resolveBatchSize, resolveTimeout, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
9
86
 
10
87
  // Operations - I/O with dependency injection
11
88
  export { checkExistingFiles, findScreenshots, processFiles, upload, uploadFiles, waitForBuild } from './operations.js';
@@ -4,7 +4,34 @@
4
4
  * Each operation takes its dependencies as parameters for testability.
5
5
  */
6
6
 
7
- import { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, DEFAULT_SHA_CHECK_BATCH_SIZE, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
7
+ import { buildBuildInfo, buildCompletedProgress, buildDeduplicationProgress, buildFileMetadata, buildProcessingProgress, buildScanningProgress, buildScreenshotPattern, buildUploadingProgress, buildUploadResult, buildWaitResult, DEFAULT_BUILD_POLL_INTERVAL, DEFAULT_SHA_CHECK_BATCH_SIZE, extractStatusCodeFromError, fileToScreenshotFormat, getElapsedTime, isTimedOut, partitionFilesByExistence, validateApiKey, validateDirectoryStats, validateFilesFound, validateScreenshotsDir } from './core.js';
8
+ function delay(ms, signal) {
9
+ if (ms <= 0) {
10
+ return Promise.resolve();
11
+ }
12
+ return new Promise((resolve, reject) => {
13
+ let timeoutId;
14
+ function cleanup() {
15
+ clearTimeout(timeoutId);
16
+ signal?.removeEventListener?.('abort', handleAbort);
17
+ }
18
+ function handleAbort() {
19
+ cleanup();
20
+ reject(new Error('Operation cancelled'));
21
+ }
22
+ timeoutId = setTimeout(() => {
23
+ cleanup();
24
+ resolve();
25
+ }, ms);
26
+ if (signal?.aborted) {
27
+ handleAbort();
28
+ return;
29
+ }
30
+ signal?.addEventListener?.('abort', handleAbort, {
31
+ once: true
32
+ });
33
+ });
34
+ }
8
35
 
9
36
  // ============================================================================
10
37
  // File Discovery
@@ -228,10 +255,13 @@ export async function waitForBuild({
228
255
  }) {
229
256
  let {
230
257
  createError,
231
- createTimeoutError
258
+ createTimeoutError,
259
+ now = () => Date.now(),
260
+ pollInterval = DEFAULT_BUILD_POLL_INTERVAL,
261
+ wait = delay
232
262
  } = deps;
233
- let startTime = Date.now();
234
- while (!isTimedOut(startTime, timeout)) {
263
+ let startTime = now();
264
+ while (!isTimedOut(startTime, timeout, now())) {
235
265
  if (signal.aborted) {
236
266
  throw createError('Operation cancelled', 'UPLOAD_CANCELLED', {
237
267
  buildId
@@ -253,11 +283,16 @@ export async function waitForBuild({
253
283
  if (build.status === 'failed') {
254
284
  throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED');
255
285
  }
286
+ let remaining = timeout - getElapsedTime(startTime, now());
287
+ if (remaining <= 0) {
288
+ break;
289
+ }
290
+ await wait(Math.min(pollInterval, remaining), signal);
256
291
  }
257
292
  throw createTimeoutError(`Build timed out after ${timeout}ms`, {
258
293
  buildId,
259
294
  timeout,
260
- elapsed: getElapsedTime(startTime)
295
+ elapsed: getElapsedTime(startTime, now())
261
296
  });
262
297
  }
263
298
 
@@ -0,0 +1,14 @@
1
+ export function getAppBaseUrl(apiUrl) {
2
+ try {
3
+ let url = new URL(apiUrl);
4
+ let apiPathIndex = url.pathname.indexOf('/api');
5
+ if (apiPathIndex !== -1) {
6
+ url.pathname = url.pathname.slice(0, apiPathIndex) || '/';
7
+ url.search = '';
8
+ url.hash = '';
9
+ }
10
+ return url.toString().replace(/\/$/, '');
11
+ } catch {
12
+ return apiUrl.replace(/\/api(?:\/.*)?$/, '');
13
+ }
14
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Race an operation against a timeout and always clear the timer when either
3
+ * side settles.
4
+ *
5
+ * @param {Promise<unknown>} operation - Operation promise to wait for.
6
+ * @param {number} ms - Timeout in milliseconds.
7
+ * @param {string} message - Error message when the timeout wins.
8
+ * @param {Object} [timers] - Timer implementation for deterministic tests.
9
+ * @returns {Promise<unknown>} The operation result.
10
+ */
11
+ export async function withTimeout(operation, ms, message, timers = {
12
+ setTimeout,
13
+ clearTimeout
14
+ }) {
15
+ let timeoutId;
16
+ let timeoutPromise = new Promise((_, reject) => {
17
+ timeoutId = timers.setTimeout(() => reject(new Error(message)), ms);
18
+ });
19
+ try {
20
+ return await Promise.race([operation, timeoutPromise]);
21
+ } finally {
22
+ if (timeoutId !== undefined) {
23
+ timers.clearTimeout(timeoutId);
24
+ }
25
+ }
26
+ }
@@ -176,6 +176,13 @@ export function getCommitMessage() {
176
176
  // Generic
177
177
  null;
178
178
  }
179
+ function parsePullRequestNumber(value) {
180
+ if (!/^\d+$/.test(value)) {
181
+ return null;
182
+ }
183
+ let number = Number(value);
184
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
185
+ }
179
186
 
180
187
  /**
181
188
  * Get the pull request number from CI environment variables
@@ -184,54 +191,54 @@ export function getCommitMessage() {
184
191
  export function getPullRequestNumber() {
185
192
  // Check VIZZLY override first
186
193
  if (process.env.VIZZLY_PR_NUMBER) {
187
- return parseInt(process.env.VIZZLY_PR_NUMBER, 10);
194
+ return parsePullRequestNumber(process.env.VIZZLY_PR_NUMBER);
188
195
  }
189
196
 
190
197
  // GitHub Actions - extract from GITHUB_REF
191
198
  if (process.env.GITHUB_ACTIONS && process.env.GITHUB_EVENT_NAME === 'pull_request') {
192
- const prMatch = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/);
193
- if (prMatch?.[1]) return parseInt(prMatch[1], 10);
199
+ let prMatch = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/);
200
+ if (prMatch?.[1]) return parsePullRequestNumber(prMatch[1]);
194
201
  }
195
202
 
196
203
  // GitLab CI
197
204
  if (process.env.CI_MERGE_REQUEST_ID) {
198
- return parseInt(process.env.CI_MERGE_REQUEST_ID, 10);
205
+ return parsePullRequestNumber(process.env.CI_MERGE_REQUEST_ID);
199
206
  }
200
207
 
201
208
  // CircleCI - extract from PR URL
202
209
  if (process.env.CIRCLE_PULL_REQUEST) {
203
- const prMatch = process.env.CIRCLE_PULL_REQUEST.match(/\/pull\/(\d+)$/);
204
- if (prMatch?.[1]) return parseInt(prMatch[1], 10);
210
+ let prMatch = process.env.CIRCLE_PULL_REQUEST.match(/\/pull\/(\d+)$/);
211
+ if (prMatch?.[1]) return parsePullRequestNumber(prMatch[1]);
205
212
  }
206
213
 
207
214
  // Travis CI
208
215
  if (process.env.TRAVIS_PULL_REQUEST && process.env.TRAVIS_PULL_REQUEST !== 'false') {
209
- return parseInt(process.env.TRAVIS_PULL_REQUEST, 10);
216
+ return parsePullRequestNumber(process.env.TRAVIS_PULL_REQUEST);
210
217
  }
211
218
 
212
219
  // Buildkite
213
220
  if (process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false') {
214
- return parseInt(process.env.BUILDKITE_PULL_REQUEST, 10);
221
+ return parsePullRequestNumber(process.env.BUILDKITE_PULL_REQUEST);
215
222
  }
216
223
 
217
224
  // Drone CI
218
225
  if (process.env.DRONE_PULL_REQUEST) {
219
- return parseInt(process.env.DRONE_PULL_REQUEST, 10);
226
+ return parsePullRequestNumber(process.env.DRONE_PULL_REQUEST);
220
227
  }
221
228
 
222
229
  // Jenkins (GitHub Pull Request Builder plugin)
223
230
  if (process.env.ghprbPullId) {
224
- return parseInt(process.env.ghprbPullId, 10);
231
+ return parsePullRequestNumber(process.env.ghprbPullId);
225
232
  }
226
233
 
227
234
  // Azure DevOps
228
235
  if (process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
229
- return parseInt(process.env.SYSTEM_PULLREQUEST_PULLREQUESTID, 10);
236
+ return parsePullRequestNumber(process.env.SYSTEM_PULLREQUEST_PULLREQUESTID);
230
237
  }
231
238
 
232
239
  // AppVeyor
233
240
  if (process.env.APPVEYOR_PULL_REQUEST_NUMBER) {
234
- return parseInt(process.env.APPVEYOR_PULL_REQUEST_NUMBER, 10);
241
+ return parsePullRequestNumber(process.env.APPVEYOR_PULL_REQUEST_NUMBER);
235
242
  }
236
243
  return null;
237
244
  }
@@ -1,45 +1,15 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { cosmiconfigSync } from 'cosmiconfig';
3
+ import { CONFIG_DEFAULTS, deepMerge } from '../config/core.js';
3
4
  import { validateVizzlyConfigWithDefaults } from './config-schema.js';
4
5
  import { getApiToken, getApiUrl, getBuildName, getParallelId } from './environment-config.js';
5
6
  import { getAccessToken } from './global-config.js';
6
7
  import * as output from './output.js';
7
- const DEFAULT_CONFIG = {
8
- // API Configuration
9
- apiKey: undefined,
10
- // Will be set from env, global config, or CLI overrides
11
- apiUrl: getApiUrl(),
12
- // Server Configuration (for run command)
13
- server: {
14
- port: 47392,
15
- timeout: 30000
16
- },
17
- // Build Configuration
18
- build: {
19
- name: 'Build {timestamp}',
20
- environment: 'test'
21
- },
22
- // Upload Configuration (for upload command)
23
- upload: {
24
- screenshotsDir: './screenshots',
25
- batchSize: 10,
26
- timeout: 30000
27
- },
28
- // Comparison Configuration (CIEDE2000 Delta E: 0=exact, 1=JND, 2=recommended)
29
- comparison: {
30
- threshold: 2.0
31
- },
32
- // TDD Configuration
33
- tdd: {
34
- openReport: false // Whether to auto-open HTML report in browser
35
- },
36
- // Plugins
37
- plugins: []
38
- };
8
+ import { getActiveProjectLink } from './project-link-store.js';
39
9
  export async function loadConfig(configPath = null, cliOverrides = {}) {
40
10
  // 1. Load from config file using cosmiconfig
41
- const explorer = cosmiconfigSync('vizzly');
42
- const result = configPath ? explorer.load(configPath) : explorer.search();
11
+ let explorer = cosmiconfigSync('vizzly');
12
+ let result = configPath ? explorer.load(configPath) : explorer.search();
43
13
  let fileConfig = {};
44
14
  if (result?.config) {
45
15
  // Handle ESM default export (cosmiconfig wraps it in { default: {...} })
@@ -47,28 +17,10 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
47
17
  }
48
18
 
49
19
  // 2. Validate config file using Zod schema
50
- const validatedFileConfig = validateVizzlyConfigWithDefaults(fileConfig);
20
+ let validatedFileConfig = validateVizzlyConfigWithDefaults(fileConfig);
51
21
 
52
22
  // Create a proper clone of the default config to avoid shared object references
53
- const config = {
54
- ...DEFAULT_CONFIG,
55
- server: {
56
- ...DEFAULT_CONFIG.server
57
- },
58
- build: {
59
- ...DEFAULT_CONFIG.build
60
- },
61
- upload: {
62
- ...DEFAULT_CONFIG.upload
63
- },
64
- comparison: {
65
- ...DEFAULT_CONFIG.comparison
66
- },
67
- tdd: {
68
- ...DEFAULT_CONFIG.tdd
69
- },
70
- plugins: [...DEFAULT_CONFIG.plugins]
71
- };
23
+ let config = deepMerge(CONFIG_DEFAULTS, {});
72
24
 
73
25
  // Merge validated file config
74
26
  mergeConfig(config, validatedFileConfig);
@@ -95,16 +47,35 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
95
47
  }
96
48
  applyCLIOverrides(config, cliOverrides);
97
49
 
98
- // 5. Fall back to user auth token if no other token found
99
- // This enables interactive commands (builds, comparisons, approve, etc.)
100
- // to work without a project token when the user is logged in
50
+ // 5. Fall back to a linked project token for cloud upload commands.
101
51
  if (!config.apiKey) {
102
- let userToken = await getAccessToken();
103
- if (userToken) {
104
- config.apiKey = userToken;
105
- output.debug('config', 'using token from user login');
52
+ let linkedProject = await getActiveProjectLink({
53
+ apiUrl: config.apiUrl
54
+ });
55
+ if (linkedProject?.token) {
56
+ config.apiKey = linkedProject.token;
57
+ config.linkedProject = {
58
+ account: linkedProject.account,
59
+ apiUrl: linkedProject.apiUrl,
60
+ organizationSlug: linkedProject.organizationSlug,
61
+ organizationName: linkedProject.organizationName,
62
+ projectSlug: linkedProject.projectSlug,
63
+ projectName: linkedProject.projectName,
64
+ tokenId: linkedProject.tokenId,
65
+ tokenPrefix: linkedProject.tokenPrefix,
66
+ expiresAt: linkedProject.expiresAt,
67
+ storage: linkedProject.storage
68
+ };
69
+ output.debug('config', 'using linked project token');
106
70
  }
107
71
  }
72
+
73
+ // 6. Keep user auth separate from upload credentials.
74
+ let userToken = await getAccessToken();
75
+ if (userToken) {
76
+ config.userToken = userToken;
77
+ output.debug('config', 'using user login for user-authenticated commands');
78
+ }
108
79
  return config;
109
80
  }
110
81
 
@@ -126,19 +97,22 @@ function applyCLIOverrides(config, cliOverrides = {}) {
126
97
  if (cliOverrides.parallelId) config.parallelId = cliOverrides.parallelId;
127
98
 
128
99
  // Server overrides
129
- if (cliOverrides.port) config.server.port = parseInt(cliOverrides.port, 10);
130
- if (cliOverrides.timeout) config.server.timeout = parseInt(cliOverrides.timeout, 10);
100
+ if (cliOverrides.port) config.server.port = Number(cliOverrides.port);
101
+ if (cliOverrides.timeout) config.server.timeout = Number(cliOverrides.timeout);
131
102
 
132
103
  // Upload overrides
133
104
  if (cliOverrides.batchSize !== undefined) {
134
- config.upload.batchSize = parseInt(cliOverrides.batchSize, 10);
105
+ config.upload.batchSize = Number(cliOverrides.batchSize);
135
106
  }
136
107
  if (cliOverrides.uploadTimeout !== undefined) {
137
- config.upload.timeout = parseInt(cliOverrides.uploadTimeout, 10);
108
+ config.upload.timeout = Number(cliOverrides.uploadTimeout);
138
109
  }
139
110
 
140
111
  // Comparison overrides
141
112
  if (cliOverrides.threshold !== undefined) config.comparison.threshold = cliOverrides.threshold;
113
+ if (cliOverrides.minClusterSize !== undefined) {
114
+ config.comparison.minClusterSize = Number(cliOverrides.minClusterSize);
115
+ }
142
116
 
143
117
  // Baseline overrides
144
118
  if (cliOverrides.baselineBuild) config.baselineBuildId = cliOverrides.baselineBuild;
@@ -4,21 +4,22 @@
4
4
  */
5
5
 
6
6
  import { z } from 'zod';
7
+ import { CONFIG_DEFAULTS } from '../config/core.js';
7
8
 
8
9
  /**
9
10
  * Server configuration schema
10
11
  */
11
12
  const serverSchema = z.object({
12
- port: z.number().int().positive().default(47392),
13
- timeout: z.number().int().positive().default(30000)
13
+ port: z.number().int().positive().default(CONFIG_DEFAULTS.server.port),
14
+ timeout: z.number().int().positive().default(CONFIG_DEFAULTS.server.timeout)
14
15
  });
15
16
 
16
17
  /**
17
18
  * Build configuration schema
18
19
  */
19
20
  const buildSchema = z.object({
20
- name: z.string().default('Build {timestamp}'),
21
- environment: z.string().default('test'),
21
+ name: z.string().default(CONFIG_DEFAULTS.build.name),
22
+ environment: z.string().default(CONFIG_DEFAULTS.build.environment),
22
23
  branch: z.string().optional(),
23
24
  commit: z.string().optional(),
24
25
  message: z.string().optional()
@@ -28,9 +29,9 @@ const buildSchema = z.object({
28
29
  * Upload configuration schema
29
30
  */
30
31
  const uploadSchema = z.object({
31
- screenshotsDir: z.union([z.string(), z.array(z.string())]).default('./screenshots'),
32
- batchSize: z.number().int().positive().default(10),
33
- timeout: z.number().int().positive().default(30000)
32
+ screenshotsDir: z.union([z.string(), z.array(z.string())]).default(CONFIG_DEFAULTS.upload.screenshotsDir),
33
+ batchSize: z.number().int().positive().default(CONFIG_DEFAULTS.upload.batchSize),
34
+ timeout: z.number().int().positive().default(CONFIG_DEFAULTS.upload.timeout)
34
35
  });
35
36
 
36
37
  /**
@@ -39,15 +40,15 @@ const uploadSchema = z.object({
39
40
  * minClusterSize: pixels (1 = exact)
40
41
  */
41
42
  const comparisonSchema = z.object({
42
- threshold: z.number().min(0).default(2.0),
43
- minClusterSize: z.int().min(1).default(2)
43
+ threshold: z.number().min(0).default(CONFIG_DEFAULTS.comparison.threshold),
44
+ minClusterSize: z.int().min(1).default(CONFIG_DEFAULTS.comparison.minClusterSize)
44
45
  });
45
46
 
46
47
  /**
47
48
  * TDD configuration schema
48
49
  */
49
50
  const tddSchema = z.object({
50
- openReport: z.boolean().default(false)
51
+ openReport: z.boolean().default(CONFIG_DEFAULTS.tdd.openReport)
51
52
  });
52
53
 
53
54
  /**
@@ -57,29 +58,14 @@ const tddSchema = z.object({
57
58
  export const vizzlyConfigSchema = z.object({
58
59
  // Core Vizzly config
59
60
  apiKey: z.string().optional(),
60
- apiUrl: z.string().url().optional(),
61
- server: serverSchema.default({
62
- port: 47392,
63
- timeout: 30000
64
- }),
65
- build: buildSchema.default({
66
- name: 'Build {timestamp}',
67
- environment: 'test'
68
- }),
69
- upload: uploadSchema.default({
70
- screenshotsDir: './screenshots',
71
- batchSize: 10,
72
- timeout: 30000
73
- }),
74
- comparison: comparisonSchema.default({
75
- threshold: 2.0,
76
- minClusterSize: 2
77
- }),
78
- tdd: tddSchema.default({
79
- openReport: false
80
- }),
61
+ apiUrl: z.string().url().default(CONFIG_DEFAULTS.apiUrl),
62
+ server: serverSchema.default(CONFIG_DEFAULTS.server),
63
+ build: buildSchema.default(CONFIG_DEFAULTS.build),
64
+ upload: uploadSchema.default(CONFIG_DEFAULTS.upload),
65
+ comparison: comparisonSchema.default(CONFIG_DEFAULTS.comparison),
66
+ tdd: tddSchema.default(CONFIG_DEFAULTS.tdd),
81
67
  signatureProperties: z.array(z.string()).default([]),
82
- plugins: z.array(z.string()).default([]),
68
+ plugins: z.array(z.string()).default(CONFIG_DEFAULTS.plugins),
83
69
  // Additional optional fields
84
70
  parallelId: z.string().optional(),
85
71
  baselineBuildId: z.string().optional(),
@@ -88,29 +74,7 @@ export const vizzlyConfigSchema = z.object({
88
74
  wait: z.boolean().optional(),
89
75
  allowNoToken: z.boolean().optional()
90
76
  }).passthrough() // Allow plugin-specific keys like `staticSite`, `storybook`, etc.
91
- .default({
92
- server: {
93
- port: 47392,
94
- timeout: 30000
95
- },
96
- build: {
97
- name: 'Build {timestamp}',
98
- environment: 'test'
99
- },
100
- upload: {
101
- screenshotsDir: './screenshots',
102
- batchSize: 10,
103
- timeout: 30000
104
- },
105
- comparison: {
106
- threshold: 2.0,
107
- minClusterSize: 2
108
- },
109
- tdd: {
110
- openReport: false
111
- },
112
- plugins: []
113
- });
77
+ .default(CONFIG_DEFAULTS);
114
78
 
115
79
  /**
116
80
  * Validate Vizzly configuration
@@ -1,9 +1,16 @@
1
- function fetchWithTimeout(url, opts = {}, ms = 300000) {
2
- const ctrl = new AbortController();
3
- const id = setTimeout(() => ctrl.abort(), ms);
4
- return fetch(url, {
1
+ let defaultTimers = {
2
+ setTimeout,
3
+ clearTimeout
4
+ };
5
+ function fetchWithTimeout(url, opts = {}, ms = 300000, deps = {}) {
6
+ let fetchFn = deps.fetch || fetch;
7
+ let AbortControllerClass = deps.AbortController || AbortController;
8
+ let timers = deps.timers || defaultTimers;
9
+ let ctrl = new AbortControllerClass();
10
+ let timeoutId = timers.setTimeout(() => ctrl.abort(), ms);
11
+ return fetchFn(url, {
5
12
  ...opts,
6
13
  signal: ctrl.signal
7
- }).finally(() => clearTimeout(id));
14
+ }).finally(() => timers.clearTimeout(timeoutId));
8
15
  }
9
16
  export { fetchWithTimeout };