@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
@@ -15,6 +15,9 @@ import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTest
15
15
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
16
16
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit } from '../utils/git.js';
17
17
  import * as defaultOutput from '../utils/output.js';
18
+ function buildLocalContextCommand() {
19
+ return 'vizzly context build current --source local --agent';
20
+ }
18
21
 
19
22
  /**
20
23
  * TDD command implementation
@@ -91,7 +94,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
91
94
  output.header('tdd', mode);
92
95
 
93
96
  // Show config in verbose mode
94
- output.debug('config', `port=${config.server.port} threshold=${config.comparison.threshold}`);
97
+ output.debug('config', `port=${config.server.port} threshold=${config.comparison.threshold} minClusterSize=${config.comparison.minClusterSize}`);
95
98
  }
96
99
 
97
100
  // Create functional dependencies
@@ -133,6 +136,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
133
136
  commit,
134
137
  environment: config.build.environment,
135
138
  threshold: config.comparison.threshold,
139
+ minClusterSize: config.comparison.minClusterSize,
136
140
  allowNoToken: config.allowNoToken || false,
137
141
  baselineBuildId: config.baselineBuildId,
138
142
  baselineComparisonId: config.baselineComparisonId,
@@ -231,10 +235,15 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
231
235
  exitCode,
232
236
  comparisons,
233
237
  summary,
234
- reportPath: runResult.reportPath || '.vizzly/report/index.html'
238
+ reportPath: runResult.reportPath || '.vizzly/report/index.html',
239
+ contextCommand: buildLocalContextCommand()
235
240
  });
236
241
  output.cleanup();
237
242
  }
243
+ if (!globalOptions.json && !options.daemon) {
244
+ output.blank?.();
245
+ output.print(` Context ${buildLocalContextCommand()}`);
246
+ }
238
247
  return {
239
248
  result: {
240
249
  success: !hasFailures,
@@ -287,22 +296,28 @@ export function validateTddOptions(testCommand, options) {
287
296
  errors.push('Test command is required');
288
297
  }
289
298
  if (options.port) {
290
- let port = parseInt(options.port, 10);
291
- if (Number.isNaN(port) || port < 1 || port > 65535) {
299
+ let port = Number(options.port);
300
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
292
301
  errors.push('Port must be a valid number between 1 and 65535');
293
302
  }
294
303
  }
295
304
  if (options.timeout) {
296
- let timeout = parseInt(options.timeout, 10);
297
- if (Number.isNaN(timeout) || timeout < 1000) {
305
+ let timeout = Number(options.timeout);
306
+ if (!Number.isInteger(timeout) || timeout < 1000) {
298
307
  errors.push('Timeout must be at least 1000 milliseconds');
299
308
  }
300
309
  }
301
310
  if (options.threshold !== undefined) {
302
- let threshold = parseFloat(options.threshold);
303
- if (Number.isNaN(threshold) || threshold < 0) {
311
+ let threshold = Number(options.threshold);
312
+ if (!Number.isFinite(threshold) || threshold < 0) {
304
313
  errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
305
314
  }
306
315
  }
316
+ if (options.minClusterSize !== undefined) {
317
+ let minClusterSize = Number(options.minClusterSize);
318
+ if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
319
+ errors.push('Min cluster size must be a positive integer');
320
+ }
321
+ }
307
322
  return errors;
308
323
  }
@@ -1,5 +1,6 @@
1
1
  import { createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
2
- import { createUploader as defaultCreateUploader } from '../services/uploader.js';
2
+ import { createUploader as defaultCreateUploader } from '../uploader/index.js';
3
+ import { getAppBaseUrl } from '../utils/api-url.js';
3
4
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
4
5
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
5
6
  import * as defaultOutput from '../utils/output.js';
@@ -26,7 +27,7 @@ export async function constructBuildUrl(buildId, apiUrl, apiToken, deps = {}) {
26
27
  command: 'upload'
27
28
  });
28
29
  let tokenContext = await getTokenContext(client);
29
- let baseUrl = apiUrl.replace(/\/api.*$/, '');
30
+ let baseUrl = getAppBaseUrl(apiUrl);
30
31
  if (tokenContext.organization?.slug && tokenContext.project?.slug) {
31
32
  return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${buildId}`;
32
33
  }
@@ -38,7 +39,7 @@ export async function constructBuildUrl(buildId, apiUrl, apiToken, deps = {}) {
38
39
  }
39
40
 
40
41
  // Fallback URL construction
41
- let baseUrl = apiUrl.replace(/\/api.*$/, '');
42
+ let baseUrl = getAppBaseUrl(apiUrl);
42
43
  return `${baseUrl}/builds/${buildId}`;
43
44
  }
44
45
 
@@ -375,20 +376,20 @@ export function validateUploadOptions(screenshotsPath, options) {
375
376
  }
376
377
  }
377
378
  if (options.threshold !== undefined) {
378
- const threshold = parseFloat(options.threshold);
379
- if (Number.isNaN(threshold) || threshold < 0) {
379
+ let threshold = Number(options.threshold);
380
+ if (!Number.isFinite(threshold) || threshold < 0) {
380
381
  errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
381
382
  }
382
383
  }
383
384
  if (options.batchSize !== undefined) {
384
- const n = parseInt(options.batchSize, 10);
385
- if (!Number.isFinite(n) || n <= 0) {
385
+ let n = Number(options.batchSize);
386
+ if (!Number.isInteger(n) || n <= 0) {
386
387
  errors.push('Batch size must be a positive integer');
387
388
  }
388
389
  }
389
390
  if (options.uploadTimeout !== undefined) {
390
- const n = parseInt(options.uploadTimeout, 10);
391
- if (!Number.isFinite(n) || n <= 0) {
391
+ let n = Number(options.uploadTimeout);
392
+ if (!Number.isInteger(n) || n <= 0) {
392
393
  errors.push('Upload timeout must be a positive integer (milliseconds)');
393
394
  }
394
395
  }
@@ -3,16 +3,63 @@
3
3
  * Shows current user and authentication status
4
4
  */
5
5
 
6
- import { createAuthClient, createTokenStore, getAuthTokens, whoami } from '../auth/index.js';
7
- import { getApiUrl } from '../utils/environment-config.js';
8
- import * as output from '../utils/output.js';
6
+ import { createAuthClient as defaultCreateAuthClient, createTokenStore as defaultCreateTokenStore, getAuthTokens as defaultGetAuthTokens, whoami as defaultWhoami } from '../auth/index.js';
7
+ import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
8
+ import * as defaultOutput from '../utils/output.js';
9
+ export function getTokenExpiryStatus(expiresAt, now = new Date()) {
10
+ let expiresDate = new Date(expiresAt);
11
+ let msUntilExpiry = expiresDate.getTime() - now.getTime();
12
+ let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
13
+ let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
14
+ let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
15
+ if (msUntilExpiry <= 0) {
16
+ return {
17
+ level: 'warn',
18
+ message: 'Token has expired',
19
+ refreshHint: 'Run "vizzly login" to refresh your authentication'
20
+ };
21
+ }
22
+ if (daysUntilExpiry > 0) {
23
+ return {
24
+ level: 'hint',
25
+ message: `Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresDate.toLocaleDateString()})`
26
+ };
27
+ }
28
+ if (hoursUntilExpiry > 0) {
29
+ return {
30
+ level: 'hint',
31
+ message: `Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`
32
+ };
33
+ }
34
+ if (minutesUntilExpiry > 0) {
35
+ return {
36
+ level: 'hint',
37
+ message: `Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`
38
+ };
39
+ }
40
+ return {
41
+ level: 'warn',
42
+ message: 'Token expires in less than a minute',
43
+ refreshHint: 'Run "vizzly login" to refresh your authentication'
44
+ };
45
+ }
9
46
 
10
47
  /**
11
48
  * Whoami command implementation
12
49
  * @param {Object} options - Command options
13
50
  * @param {Object} globalOptions - Global CLI options
51
+ * @param {Object} deps - Dependencies for testing
14
52
  */
15
- export async function whoamiCommand(options = {}, globalOptions = {}) {
53
+ export async function whoamiCommand(options = {}, globalOptions = {}, deps = {}) {
54
+ let {
55
+ createAuthClient = defaultCreateAuthClient,
56
+ createTokenStore = defaultCreateTokenStore,
57
+ getApiUrl = defaultGetApiUrl,
58
+ getAuthTokens = defaultGetAuthTokens,
59
+ output = defaultOutput,
60
+ whoami = defaultWhoami,
61
+ exit = code => process.exit(code)
62
+ } = deps;
16
63
  output.configure({
17
64
  json: globalOptions.json,
18
65
  verbose: globalOptions.verbose,
@@ -21,7 +68,7 @@ export async function whoamiCommand(options = {}, globalOptions = {}) {
21
68
  try {
22
69
  // Check if user is logged in
23
70
  let auth = await getAuthTokens();
24
- if (!auth || !auth.accessToken) {
71
+ if (!auth?.accessToken) {
25
72
  if (globalOptions.json) {
26
73
  output.data({
27
74
  authenticated: false
@@ -98,27 +145,17 @@ export async function whoamiCommand(options = {}, globalOptions = {}) {
98
145
  // Show token expiry info
99
146
  if (auth.expiresAt) {
100
147
  output.blank();
101
- let expiresAt = new Date(auth.expiresAt);
102
- let now = new Date();
103
- let msUntilExpiry = expiresAt.getTime() - now.getTime();
104
- let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
105
- let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
106
- let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
107
- if (msUntilExpiry <= 0) {
108
- output.warn('Token has expired');
109
- output.hint('Run "vizzly login" to refresh your authentication');
110
- } else if (daysUntilExpiry > 0) {
111
- output.hint(`Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})`);
112
- } else if (hoursUntilExpiry > 0) {
113
- output.hint(`Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`);
114
- } else if (minutesUntilExpiry > 0) {
115
- output.hint(`Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`);
148
+ let expiry = getTokenExpiryStatus(auth.expiresAt);
149
+ if (expiry.level === 'warn') {
150
+ output.warn(expiry.message);
116
151
  } else {
117
- output.warn('Token expires in less than a minute');
118
- output.hint('Run "vizzly login" to refresh your authentication');
152
+ output.hint(expiry.message);
153
+ }
154
+ if (expiry.refreshHint) {
155
+ output.hint(expiry.refreshHint);
119
156
  }
120
157
  if (globalOptions.verbose) {
121
- output.hint(`Token expires at: ${expiresAt.toISOString()}`);
158
+ output.hint(`Token expires at: ${new Date(auth.expiresAt).toISOString()}`);
122
159
  }
123
160
  }
124
161
  output.cleanup();
@@ -138,10 +175,11 @@ export async function whoamiCommand(options = {}, globalOptions = {}) {
138
175
  output.hint('Run "vizzly login" to authenticate again');
139
176
  }
140
177
  output.cleanup();
141
- process.exit(1);
178
+ exit(1);
142
179
  } else {
143
180
  output.error('Failed to get user information', error);
144
- process.exit(1);
181
+ output.cleanup();
182
+ exit(1);
145
183
  }
146
184
  }
147
185
  }
@@ -151,7 +189,7 @@ export async function whoamiCommand(options = {}, globalOptions = {}) {
151
189
  * @param {Object} options - Command options
152
190
  */
153
191
  export function validateWhoamiOptions() {
154
- const errors = [];
192
+ let errors = [];
155
193
 
156
194
  // No specific validation needed for whoami command
157
195
 
@@ -29,7 +29,8 @@ export const CONFIG_DEFAULTS = {
29
29
  timeout: 30000
30
30
  },
31
31
  comparison: {
32
- threshold: 2.0
32
+ threshold: 2.0,
33
+ minClusterSize: 2
33
34
  },
34
35
  tdd: {
35
36
  openReport: false
@@ -91,6 +92,23 @@ export function validateWriteScope(scope) {
91
92
  // Deep Merge
92
93
  // ============================================================================
93
94
 
95
+ function isPlainConfigObject(value) {
96
+ return value && typeof value === 'object' && !Array.isArray(value);
97
+ }
98
+ function cloneConfigValue(value) {
99
+ if (Array.isArray(value)) {
100
+ return value.map(cloneConfigValue);
101
+ }
102
+ if (isPlainConfigObject(value)) {
103
+ let cloned = {};
104
+ for (let key of Object.keys(value)) {
105
+ cloned[key] = cloneConfigValue(value[key]);
106
+ }
107
+ return cloned;
108
+ }
109
+ return value;
110
+ }
111
+
94
112
  /**
95
113
  * Deep merge two objects
96
114
  * @param {Object} target - Target object
@@ -98,18 +116,16 @@ export function validateWriteScope(scope) {
98
116
  * @returns {Object} Merged object (new object, inputs not mutated)
99
117
  */
100
118
  export function deepMerge(target, source) {
101
- let output = {
102
- ...target
103
- };
119
+ let output = cloneConfigValue(target);
104
120
  for (let key of Object.keys(source)) {
105
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
106
- if (target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
121
+ if (isPlainConfigObject(source[key])) {
122
+ if (isPlainConfigObject(target[key])) {
107
123
  output[key] = deepMerge(target[key], source[key]);
108
124
  } else {
109
- output[key] = source[key];
125
+ output[key] = cloneConfigValue(source[key]);
110
126
  }
111
127
  } else {
112
- output[key] = source[key];
128
+ output[key] = cloneConfigValue(source[key]);
113
129
  }
114
130
  }
115
131
  return output;
@@ -125,11 +141,18 @@ export function deepMerge(target, source) {
125
141
  * @returns {Object} The value if it's an object, empty object otherwise
126
142
  */
127
143
  function ensureObject(value) {
128
- if (value && typeof value === 'object' && !Array.isArray(value)) {
144
+ if (isPlainConfigObject(value)) {
129
145
  return value;
130
146
  }
131
147
  return {};
132
148
  }
149
+ function applyConfigLayer(config, sources, layer, sourceName) {
150
+ let merged = deepMerge(config, layer);
151
+ for (let key of Object.keys(layer)) {
152
+ sources[key] = sourceName;
153
+ }
154
+ return merged;
155
+ }
133
156
 
134
157
  /**
135
158
  * Build merged config from layers with source tracking
@@ -148,36 +171,14 @@ export function buildMergedConfig({
148
171
  let safeProjectConfig = ensureObject(projectConfig);
149
172
  let safeGlobalConfig = ensureObject(globalConfig);
150
173
  let safeEnvOverrides = ensureObject(envOverrides);
151
- let mergedConfig = {};
174
+ let mergedConfig = cloneConfigValue(CONFIG_DEFAULTS);
152
175
  let sources = {};
153
-
154
- // Layer 1: Defaults
155
176
  for (let key of Object.keys(CONFIG_DEFAULTS)) {
156
- mergedConfig[key] = CONFIG_DEFAULTS[key];
157
177
  sources[key] = 'default';
158
178
  }
159
-
160
- // Layer 2: Global config (auth, project mappings, user preferences)
161
- if (safeGlobalConfig.auth) {
162
- mergedConfig.auth = safeGlobalConfig.auth;
163
- sources.auth = 'global';
164
- }
165
- if (safeGlobalConfig.projects) {
166
- mergedConfig.projects = safeGlobalConfig.projects;
167
- sources.projects = 'global';
168
- }
169
-
170
- // Layer 3: Project config file
171
- for (let key of Object.keys(safeProjectConfig)) {
172
- mergedConfig[key] = safeProjectConfig[key];
173
- sources[key] = 'project';
174
- }
175
-
176
- // Layer 4: Environment variables
177
- for (let key of Object.keys(safeEnvOverrides)) {
178
- mergedConfig[key] = safeEnvOverrides[key];
179
- sources[key] = 'env';
180
- }
179
+ mergedConfig = applyConfigLayer(mergedConfig, sources, safeGlobalConfig, 'global');
180
+ mergedConfig = applyConfigLayer(mergedConfig, sources, safeProjectConfig, 'project');
181
+ mergedConfig = applyConfigLayer(mergedConfig, sources, safeEnvOverrides, 'env');
181
182
  return {
182
183
  config: mergedConfig,
183
184
  sources
@@ -390,7 +391,7 @@ export function serializeConfig(config, filepath) {
390
391
  * @returns {{ config: Object|null, filepath: string|null }}
391
392
  */
392
393
  export function extractCosmiconfigResult(result) {
393
- if (!result || !result.config) {
394
+ if (!result?.config) {
394
395
  return {
395
396
  config: null,
396
397
  filepath: null