@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
@@ -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,
@@ -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
@@ -1,10 +1,11 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync as defaultExistsSync, readFileSync } from 'node:fs';
2
2
  import { basename, isAbsolute, join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
3
4
  import { normalizeReportData } from '../utils/report-data.js';
4
5
  let LOCAL_CONTEXT_SOURCE = 'local_workspace';
5
6
  let DEFAULT_LOCAL_REVIEW_QUEUE_LIMIT = 50;
6
7
  function readJsonIfExists(path) {
7
- if (!existsSync(path)) {
8
+ if (!defaultExistsSync(path)) {
8
9
  return null;
9
10
  }
10
11
  try {
@@ -72,7 +73,7 @@ function resolveAssetReference(assetPath, snapshot) {
72
73
  if (!assetPath) {
73
74
  return null;
74
75
  }
75
- if (/^https?:\/\//.test(assetPath) || isAbsolute(assetPath)) {
76
+ if (/^https?:\/\//.test(assetPath)) {
76
77
  return assetPath;
77
78
  }
78
79
  if (assetPath.startsWith('/images/')) {
@@ -81,6 +82,9 @@ function resolveAssetReference(assetPath, snapshot) {
81
82
  }
82
83
  return join(snapshot.vizzlyDir, assetPath.replace('/images/', ''));
83
84
  }
85
+ if (isAbsolute(assetPath)) {
86
+ return assetPath;
87
+ }
84
88
  return assetPath;
85
89
  }
86
90
  function normalizeConfirmedRegions(regions = []) {
@@ -149,6 +153,71 @@ function buildBuildSnapshot(snapshot) {
149
153
  created_at: snapshot.session?.createdAt || null
150
154
  };
151
155
  }
156
+ function buildBaselineSnapshot(snapshot) {
157
+ let metadata = snapshot.baselineMetadata;
158
+ if (!metadata) {
159
+ return {
160
+ selected: null,
161
+ selection_reason: 'no_local_baseline_metadata',
162
+ comparison_baseline_build_ids: []
163
+ };
164
+ }
165
+ return {
166
+ selected: {
167
+ id: metadata.buildId || 'local-baseline',
168
+ name: metadata.buildName || metadata.buildId || 'Local TDD Baseline',
169
+ branch: metadata.branch || 'local',
170
+ commit_sha: metadata.buildInfo?.commitSha || null,
171
+ commit_message: metadata.buildInfo?.commitMessage || null,
172
+ approval_status: metadata.buildInfo?.approvalStatus || 'approved',
173
+ status: metadata.buildInfo?.completedAt ? 'completed' : 'local',
174
+ created_at: metadata.createdAt || null,
175
+ completed_at: metadata.buildInfo?.completedAt || null
176
+ },
177
+ selection_reason: 'local_workspace_baseline_metadata',
178
+ comparison_baseline_build_ids: metadata.buildId ? [metadata.buildId] : []
179
+ };
180
+ }
181
+ function buildReviewState(build, reviewSummary) {
182
+ let reasons = [];
183
+ if (build.approval_status === 'pending') {
184
+ reasons.push('build_pending_approval');
185
+ }
186
+ if (reviewSummary.pending > 0) {
187
+ reasons.push('comparisons_need_review');
188
+ }
189
+ return {
190
+ needs_review: reasons.length > 0,
191
+ reasons,
192
+ pending_comparisons: reviewSummary.pending,
193
+ unresolved_comments: 0
194
+ };
195
+ }
196
+ function mapLocalScreenshot(snapshot, comparison) {
197
+ let mapped = mapLocalComparison(snapshot, comparison);
198
+ let baselineBuildId = snapshot.baselineMetadata?.buildId || null;
199
+ return {
200
+ id: mapped.screenshot.id,
201
+ name: mapped.screenshot.name,
202
+ browser: mapped.screenshot.browser,
203
+ viewport: {
204
+ width: mapped.screenshot.viewport_width,
205
+ height: mapped.screenshot.viewport_height
206
+ },
207
+ url: mapped.screenshot.original_url,
208
+ baseline: mapped.baseline ? {
209
+ id: mapped.baseline.id,
210
+ build_id: baselineBuildId,
211
+ name: mapped.baseline.name,
212
+ browser: mapped.baseline.browser,
213
+ viewport: {
214
+ width: mapped.baseline.viewport_width,
215
+ height: mapped.baseline.viewport_height
216
+ },
217
+ url: mapped.baseline.original_url
218
+ } : null
219
+ };
220
+ }
152
221
  function mapLocalComparison(snapshot, comparison) {
153
222
  let details = snapshot.comparisonDetails[comparison.id] || {};
154
223
  let comparisonName = comparison.originalName || comparison.name;
@@ -156,12 +225,19 @@ function mapLocalComparison(snapshot, comparison) {
156
225
  let hotspotAnalysis = buildHotspotAnalysis(snapshot, comparisonName, details);
157
226
  let properties = comparison.properties || {};
158
227
  let buildSnapshot = buildBuildSnapshot(snapshot);
228
+ let result = mapComparisonResult(comparison.status);
229
+ let approvalStatus = mapApprovalStatus(comparison.status);
230
+ let baselineBuildId = snapshot.baselineMetadata?.buildId || null;
231
+ let diffImageUrl = resolveAssetReference(comparison.diff, snapshot);
232
+ let diffRegions = details.diffClusters || [];
159
233
  return {
160
234
  id: comparison.id,
161
235
  name: comparisonName,
236
+ screenshot_name: comparisonName,
162
237
  status: comparison.status,
163
- result: mapComparisonResult(comparison.status),
164
- approval_status: mapApprovalStatus(comparison.status),
238
+ result,
239
+ approval_status: approvalStatus,
240
+ needs_review: approvalStatus === 'pending' && ['changed', 'new'].includes(result),
165
241
  build_id: buildSnapshot.id,
166
242
  build_name: buildSnapshot.name,
167
243
  build_branch: buildSnapshot.branch,
@@ -181,17 +257,33 @@ function mapLocalComparison(snapshot, comparison) {
181
257
  },
182
258
  baseline: comparison.baseline ? {
183
259
  id: `${comparison.id}-baseline`,
260
+ build_id: baselineBuildId,
184
261
  name: comparisonName,
185
262
  browser: properties.browser ?? null,
186
263
  viewport_width: properties.viewport_width ?? null,
187
264
  viewport_height: properties.viewport_height ?? null,
188
265
  original_url: resolveAssetReference(comparison.baseline, snapshot)
189
266
  } : null,
267
+ diff: {
268
+ percentage: comparison.diffPercentage ?? null,
269
+ changed_pixels: comparison.diffCount ?? null,
270
+ total_pixels: comparison.totalPixels ?? null,
271
+ threshold: comparison.threshold ?? null,
272
+ image_url: diffImageUrl,
273
+ regions: diffRegions,
274
+ cluster_metadata: diffRegions.length ? {
275
+ clusterCount: diffRegions.length,
276
+ local_workspace: true
277
+ } : null,
278
+ fingerprint_hash: null,
279
+ fingerprint_data: null,
280
+ diff_lines: []
281
+ },
190
282
  analysis: {
191
- diff_image_url: resolveAssetReference(comparison.diff, snapshot),
192
- diff_regions: details.diffClusters || [],
193
- cluster_metadata: details.diffClusters ? {
194
- clusterCount: details.diffClusters.length,
283
+ diff_image_url: diffImageUrl,
284
+ diff_regions: diffRegions,
285
+ cluster_metadata: diffRegions.length ? {
286
+ clusterCount: diffRegions.length,
195
287
  local_workspace: true
196
288
  } : null,
197
289
  diff_lines: null,
@@ -219,9 +311,17 @@ function createLocalWorkspaceError(message) {
219
311
  error.code = 'LOCAL_WORKSPACE_CONTEXT';
220
312
  return error;
221
313
  }
314
+ function createReportUrl(snapshot) {
315
+ let reportPath = join(snapshot.vizzlyDir, 'report', 'index.html');
316
+ if (!snapshot.existsSync(reportPath)) {
317
+ return null;
318
+ }
319
+ return pathToFileURL(reportPath).href;
320
+ }
222
321
  export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
223
322
  let projectRoot = options.projectRoot || process.cwd();
224
323
  let readJson = deps.readJsonIfExists || readJsonIfExists;
324
+ let existsSync = deps.existsSync || defaultExistsSync;
225
325
  let snapshotCache = null;
226
326
  function loadSnapshot() {
227
327
  if (snapshotCache) {
@@ -231,6 +331,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
231
331
  snapshotCache = {
232
332
  projectRoot,
233
333
  vizzlyDir,
334
+ existsSync,
234
335
  serverInfo: readJson(join(vizzlyDir, 'server.json')),
235
336
  session: readJson(join(vizzlyDir, 'session.json')),
236
337
  reportData: normalizeReportData(readJson(join(vizzlyDir, 'report-data.json')) || createEmptyReportData()),
@@ -278,14 +379,20 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
278
379
  return buildLocalScope(projectRoot);
279
380
  }
280
381
  function createBuildLinks(snapshot) {
382
+ let reportUrl = createReportUrl(snapshot);
281
383
  if (!snapshot.serverInfo?.port) {
282
- return {};
384
+ return reportUrl ? {
385
+ report_url: reportUrl
386
+ } : {};
283
387
  }
284
388
  let buildId = buildBuildSnapshot(snapshot).id;
285
389
  return {
286
390
  build_url: `http://127.0.0.1:${snapshot.serverInfo.port}/builds`,
287
391
  comparison_url_prefix: `http://127.0.0.1:${snapshot.serverInfo.port}/comparison`,
288
- current_build_id: buildId
392
+ current_build_id: buildId,
393
+ ...(reportUrl ? {
394
+ report_url: reportUrl
395
+ } : {})
289
396
  };
290
397
  }
291
398
  function getBuildContext(buildId) {
@@ -295,25 +402,38 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) {
295
402
  throw createLocalWorkspaceError(`Local workspace context is only available for the active session build (${resolvedBuild.id})`);
296
403
  }
297
404
  let mappedComparisons = snapshot.reportData.comparisons.map(comparison => mapLocalComparison(snapshot, comparison));
405
+ let mappedScreenshots = snapshot.reportData.comparisons.map(comparison => mapLocalScreenshot(snapshot, comparison));
298
406
  let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons);
407
+ let reviewState = buildReviewState(resolvedBuild, reviewSummary);
299
408
  return {
300
409
  resource: 'build_context',
301
410
  source: LOCAL_CONTEXT_SOURCE,
302
411
  scope: createScope(),
303
412
  build: resolvedBuild,
413
+ baseline: buildBaselineSnapshot(snapshot),
414
+ status: reviewState,
304
415
  summary: {
305
416
  comparisons: {
306
417
  total: mappedComparisons.length,
307
418
  changed: mappedComparisons.filter(comparison => comparison.result === 'changed').length,
308
419
  new: mappedComparisons.filter(comparison => comparison.result === 'new').length
309
420
  },
310
- review: reviewSummary
421
+ review: reviewSummary,
422
+ comments: {
423
+ build: 0,
424
+ screenshot: 0
425
+ }
311
426
  },
312
427
  review: {
313
428
  comments: [],
314
429
  assignments: []
315
430
  },
431
+ screenshots: mappedScreenshots,
316
432
  comparisons: mappedComparisons,
433
+ comments: {
434
+ build: [],
435
+ screenshot_count: 0
436
+ },
317
437
  links: createBuildLinks(snapshot)
318
438
  };
319
439
  }
@@ -4,7 +4,8 @@ export function resolveContextSource(options = {}, deps = {}) {
4
4
  requestedSource = 'auto',
5
5
  command,
6
6
  target = null,
7
- projectRoot = process.cwd()
7
+ projectRoot = process.cwd(),
8
+ hasCloudScope = false
8
9
  } = options;
9
10
  let {
10
11
  createLocalWorkspaceContextProvider = defaultCreateLocalWorkspaceContextProvider
@@ -25,6 +26,9 @@ export function resolveContextSource(options = {}, deps = {}) {
25
26
  }
26
27
  return 'local';
27
28
  }
29
+ if (hasCloudScope) {
30
+ return 'cloud';
31
+ }
28
32
  if (isLocalAvailable && (localSnapshot == null ? localProvider.canHandle(command, target) : localProvider.canHandle(command, target, localSnapshot))) {
29
33
  return 'local';
30
34
  }
package/dist/index.js CHANGED
@@ -11,13 +11,14 @@ import 'dotenv/config';
11
11
  // Client exports for convenience
12
12
  export { configure, setEnabled, vizzlyScreenshot } from './client/index.js';
13
13
  // Errors
14
- export { UploadError } from './errors/vizzly-error.js';
14
+ export { AuthError, BuildError, ConfigError, NetworkError, ScreenshotError, TimeoutError, UploadError, ValidationError, VizzlyError } from './errors/vizzly-error.js';
15
+ export { createPluginServices } from './plugin-api.js';
15
16
  // Primary SDK export
16
- export { createVizzly } from './sdk/index.js';
17
+ export { createVizzly, VizzlySDK } from './sdk/index.js';
17
18
  export { createServices } from './services/index.js';
18
- // Core services (for advanced usage)
19
- export { createUploader } from './services/uploader.js';
20
19
  export { createTDDService } from './tdd/tdd-service.js';
20
+ // Core services (for advanced usage)
21
+ export { createUploader } from './uploader/index.js';
21
22
  // Configuration helper
22
23
  export { defineConfig } from './utils/config-helpers.js';
23
24
  // Utilities