@vizzly-testing/cli 0.1.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +363 -0
  3. package/bin/vizzly.js +3 -0
  4. package/dist/cli.js +104 -0
  5. package/dist/client/index.js +237 -0
  6. package/dist/commands/doctor.js +158 -0
  7. package/dist/commands/init.js +102 -0
  8. package/dist/commands/run.js +224 -0
  9. package/dist/commands/status.js +164 -0
  10. package/dist/commands/tdd.js +212 -0
  11. package/dist/commands/upload.js +181 -0
  12. package/dist/container/index.js +184 -0
  13. package/dist/errors/vizzly-error.js +149 -0
  14. package/dist/index.js +31 -0
  15. package/dist/screenshot-wrapper.js +68 -0
  16. package/dist/sdk/index.js +364 -0
  17. package/dist/server/index.js +522 -0
  18. package/dist/services/api-service.js +215 -0
  19. package/dist/services/base-service.js +154 -0
  20. package/dist/services/build-manager.js +214 -0
  21. package/dist/services/screenshot-server.js +96 -0
  22. package/dist/services/server-manager.js +61 -0
  23. package/dist/services/service-utils.js +171 -0
  24. package/dist/services/tdd-service.js +444 -0
  25. package/dist/services/test-runner.js +210 -0
  26. package/dist/services/uploader.js +413 -0
  27. package/dist/types/cli.d.ts +2 -0
  28. package/dist/types/client/index.d.ts +76 -0
  29. package/dist/types/commands/doctor.d.ts +11 -0
  30. package/dist/types/commands/init.d.ts +14 -0
  31. package/dist/types/commands/run.d.ts +13 -0
  32. package/dist/types/commands/status.d.ts +13 -0
  33. package/dist/types/commands/tdd.d.ts +13 -0
  34. package/dist/types/commands/upload.d.ts +13 -0
  35. package/dist/types/container/index.d.ts +61 -0
  36. package/dist/types/errors/vizzly-error.d.ts +75 -0
  37. package/dist/types/index.d.ts +10 -0
  38. package/dist/types/index.js +153 -0
  39. package/dist/types/screenshot-wrapper.d.ts +27 -0
  40. package/dist/types/sdk/index.d.ts +108 -0
  41. package/dist/types/server/index.d.ts +38 -0
  42. package/dist/types/services/api-service.d.ts +77 -0
  43. package/dist/types/services/base-service.d.ts +72 -0
  44. package/dist/types/services/build-manager.d.ts +68 -0
  45. package/dist/types/services/screenshot-server.d.ts +10 -0
  46. package/dist/types/services/server-manager.d.ts +8 -0
  47. package/dist/types/services/service-utils.d.ts +45 -0
  48. package/dist/types/services/tdd-service.d.ts +55 -0
  49. package/dist/types/services/test-runner.d.ts +25 -0
  50. package/dist/types/services/uploader.d.ts +34 -0
  51. package/dist/types/types/index.d.ts +373 -0
  52. package/dist/types/utils/colors.d.ts +12 -0
  53. package/dist/types/utils/config-helpers.d.ts +6 -0
  54. package/dist/types/utils/config-loader.d.ts +22 -0
  55. package/dist/types/utils/console-ui.d.ts +61 -0
  56. package/dist/types/utils/diagnostics.d.ts +69 -0
  57. package/dist/types/utils/environment-config.d.ts +54 -0
  58. package/dist/types/utils/environment.d.ts +36 -0
  59. package/dist/types/utils/error-messages.d.ts +42 -0
  60. package/dist/types/utils/fetch-utils.d.ts +1 -0
  61. package/dist/types/utils/framework-detector.d.ts +5 -0
  62. package/dist/types/utils/git.d.ts +44 -0
  63. package/dist/types/utils/help.d.ts +11 -0
  64. package/dist/types/utils/image-comparison.d.ts +42 -0
  65. package/dist/types/utils/logger-factory.d.ts +26 -0
  66. package/dist/types/utils/logger.d.ts +79 -0
  67. package/dist/types/utils/package-info.d.ts +15 -0
  68. package/dist/types/utils/package.d.ts +1 -0
  69. package/dist/types/utils/project-detection.d.ts +19 -0
  70. package/dist/types/utils/ui-helpers.d.ts +23 -0
  71. package/dist/utils/colors.js +66 -0
  72. package/dist/utils/config-helpers.js +8 -0
  73. package/dist/utils/config-loader.js +120 -0
  74. package/dist/utils/console-ui.js +226 -0
  75. package/dist/utils/diagnostics.js +184 -0
  76. package/dist/utils/environment-config.js +93 -0
  77. package/dist/utils/environment.js +109 -0
  78. package/dist/utils/error-messages.js +34 -0
  79. package/dist/utils/fetch-utils.js +9 -0
  80. package/dist/utils/framework-detector.js +40 -0
  81. package/dist/utils/git.js +226 -0
  82. package/dist/utils/help.js +66 -0
  83. package/dist/utils/image-comparison.js +172 -0
  84. package/dist/utils/logger-factory.js +76 -0
  85. package/dist/utils/logger.js +231 -0
  86. package/dist/utils/package-info.js +38 -0
  87. package/dist/utils/package.js +9 -0
  88. package/dist/utils/project-detection.js +145 -0
  89. package/dist/utils/ui-helpers.js +86 -0
  90. package/package.json +103 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Test Runner Service
3
+ * Orchestrates the test execution flow
4
+ */
5
+
6
+ import { BaseService } from './base-service.js';
7
+ import { VizzlyError } from '../errors/vizzly-error.js';
8
+ import { spawn } from 'child_process';
9
+ export class TestRunner extends BaseService {
10
+ constructor(config, logger, buildManager, serverManager, tddService) {
11
+ super(config, logger);
12
+ this.buildManager = buildManager;
13
+ this.serverManager = serverManager;
14
+ this.tddService = tddService;
15
+ this.testProcess = null;
16
+ }
17
+ async run(options) {
18
+ const {
19
+ testCommand,
20
+ tdd,
21
+ allowNoToken
22
+ } = options;
23
+ const startTime = Date.now();
24
+ let buildId = null;
25
+ if (!testCommand) {
26
+ throw new VizzlyError('No test command provided', 'TEST_COMMAND_MISSING');
27
+ }
28
+
29
+ // If no token is allowed and not in TDD mode, just run the command without Vizzly integration
30
+ if (allowNoToken && !this.config.apiKey && !tdd) {
31
+ const env = {
32
+ ...process.env,
33
+ VIZZLY_ENABLED: 'false'
34
+ };
35
+ await this.executeTestCommand(testCommand, env);
36
+ return {
37
+ testsPassed: 1,
38
+ testsFailed: 0,
39
+ screenshotsCaptured: 0
40
+ };
41
+ }
42
+ try {
43
+ let buildInfo = null;
44
+ let buildUrl = null;
45
+ let screenshotCount = 0;
46
+ if (tdd) {
47
+ // TDD mode: create local build for fast feedback
48
+ this.logger.debug('TDD mode: creating local build...');
49
+ const build = await this.buildManager.createBuild(options);
50
+ buildId = build.id;
51
+ this.logger.debug(`TDD build created with ID: ${build.id}`);
52
+ } else if (options.eager) {
53
+ // Eager mode: create build immediately via API
54
+ this.logger.debug('Eager mode: creating build via API...');
55
+ const apiService = await this.createApiService();
56
+ if (apiService) {
57
+ const buildResult = await apiService.createBuild({
58
+ build: {
59
+ name: options.buildName || `Test Run ${new Date().toISOString()}`,
60
+ branch: options.branch || 'main',
61
+ environment: options.environment || 'test',
62
+ commit_sha: options.commit,
63
+ commit_message: options.message
64
+ }
65
+ });
66
+ buildId = buildResult.id;
67
+ buildUrl = buildResult.url;
68
+ this.logger.debug(`Eager build created with ID: ${buildId}`);
69
+ if (buildUrl) {
70
+ this.logger.info(`Build URL: ${buildUrl}`);
71
+ }
72
+
73
+ // Emit build created event for eager mode
74
+ this.emit('build-created', {
75
+ buildId: buildResult.id,
76
+ url: buildResult.url,
77
+ name: buildResult.name || options.buildName
78
+ });
79
+ } else {
80
+ this.logger.warn('No API key available for eager build creation, falling back to lazy mode');
81
+ }
82
+ } else {
83
+ // Lazy mode: prepare build info for API creation on first screenshot
84
+ buildInfo = {
85
+ buildName: options.buildName || `Test Run ${new Date().toISOString()}`,
86
+ branch: options.branch || 'main',
87
+ environment: options.environment || 'test',
88
+ commitSha: options.commit,
89
+ commitMessage: options.message
90
+ };
91
+ }
92
+
93
+ // Start server with appropriate configuration
94
+ const mode = tdd ? 'tdd' : options.eager ? 'eager' : 'lazy';
95
+ await this.serverManager.start(buildId, buildInfo, mode);
96
+
97
+ // Forward server events
98
+ if (this.serverManager.server && this.serverManager.server.emitter) {
99
+ this.serverManager.server.emitter.on('build-created', buildInfo => {
100
+ // Update local buildId and buildUrl from server
101
+ buildId = buildInfo.buildId;
102
+ buildUrl = buildInfo.url;
103
+ this.emit('build-created', buildInfo);
104
+ });
105
+ this.serverManager.server.emitter.on('screenshot-captured', screenshotInfo => {
106
+ screenshotCount++;
107
+ this.emit('screenshot-captured', screenshotInfo);
108
+ });
109
+ }
110
+ if (tdd) {
111
+ this.logger.debug('TDD service ready for comparisons');
112
+ }
113
+ const env = {
114
+ ...process.env,
115
+ VIZZLY_SERVER_URL: `http://localhost:${this.config.server.port}`,
116
+ VIZZLY_BUILD_ID: buildId || 'lazy',
117
+ // Use 'lazy' for API-driven builds
118
+ VIZZLY_ENABLED: 'true',
119
+ VIZZLY_SET_BASELINE: options.setBaseline || options['set-baseline'] ? 'true' : 'false'
120
+ };
121
+ await this.executeTestCommand(testCommand, env);
122
+
123
+ // Finalize builds based on mode
124
+ const executionTime = Date.now() - startTime;
125
+ await this.finalizeBuild(buildId, tdd, true, executionTime);
126
+ return {
127
+ buildId: buildId,
128
+ url: buildUrl,
129
+ testsPassed: 1,
130
+ testsFailed: 0,
131
+ screenshotsCaptured: screenshotCount
132
+ };
133
+ } catch (error) {
134
+ this.logger.error('Test run failed:', error);
135
+
136
+ // Finalize builds on failure too
137
+ const executionTime = Date.now() - startTime;
138
+ await this.finalizeBuild(buildId, tdd, false, executionTime);
139
+ throw error;
140
+ } finally {
141
+ await this.serverManager.stop();
142
+ if (tdd && this.tddService && typeof this.tddService.stop === 'function') {
143
+ await this.tddService.stop();
144
+ }
145
+ }
146
+ }
147
+ async createApiService() {
148
+ if (!this.config.apiKey) return null;
149
+ const {
150
+ ApiService
151
+ } = await import('./api-service.js');
152
+ return new ApiService({
153
+ ...this.config,
154
+ command: 'run'
155
+ }, {
156
+ logger: this.logger
157
+ });
158
+ }
159
+ async finalizeBuild(buildId, isTddMode, success, executionTime) {
160
+ if (!buildId) {
161
+ this.logger.debug('No buildId to finalize');
162
+ return;
163
+ }
164
+ try {
165
+ if (isTddMode) {
166
+ // TDD mode: use buildManager for local builds
167
+ if (this.buildManager.getCurrentBuild()) {
168
+ await this.buildManager.finalizeBuild(buildId, {
169
+ success
170
+ });
171
+ this.logger.debug(`TDD build ${buildId} finalized with success: ${success}`);
172
+ }
173
+ } else {
174
+ // API mode (eager/lazy): use API service to update build status
175
+ const apiService = await this.createApiService();
176
+ if (apiService) {
177
+ await apiService.finalizeBuild(buildId, success, executionTime);
178
+ }
179
+ }
180
+ } catch (error) {
181
+ // Don't fail the entire run if build finalization fails
182
+ this.logger.warn(`Failed to finalize build ${buildId}:`, error.message);
183
+ }
184
+ }
185
+ async executeTestCommand(testCommand, env) {
186
+ return new Promise((resolve, reject) => {
187
+ // Use shell to execute the full command string
188
+ this.testProcess = spawn(testCommand, {
189
+ env,
190
+ stdio: 'inherit',
191
+ shell: true
192
+ });
193
+ this.testProcess.on('error', error => {
194
+ reject(new VizzlyError(`Failed to run test command: ${error.message}`), 'TEST_COMMAND_FAILED');
195
+ });
196
+ this.testProcess.on('exit', code => {
197
+ if (code !== 0) {
198
+ reject(new VizzlyError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
199
+ } else {
200
+ resolve();
201
+ }
202
+ });
203
+ });
204
+ }
205
+ async cancel() {
206
+ if (this.testProcess) {
207
+ this.testProcess.kill('SIGTERM');
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Vizzly Screenshot Uploader
3
+ * Handles screenshot uploads to the Vizzly platform
4
+ */
5
+
6
+ import { glob } from 'glob';
7
+ import { readFile, stat } from 'fs/promises';
8
+ import { basename } from 'path';
9
+ import crypto from 'crypto';
10
+ import { createUploaderLogger } from '../utils/logger-factory.js';
11
+ import { ApiService } from './api-service.js';
12
+ import { getDefaultBranch } from '../utils/git.js';
13
+ import { UploadError, TimeoutError, ValidationError } from '../errors/vizzly-error.js';
14
+ const DEFAULT_BATCH_SIZE = 10;
15
+ const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
16
+ const DEFAULT_TIMEOUT = 30000; // 30 seconds
17
+
18
+ /**
19
+ * Create a new uploader instance
20
+ */
21
+ export function createUploader({
22
+ apiKey,
23
+ apiUrl,
24
+ userAgent,
25
+ command,
26
+ upload: uploadConfig = {}
27
+ }, options = {}) {
28
+ const logger = options.logger || createUploaderLogger(options);
29
+ const signal = options.signal || new AbortController().signal;
30
+ const api = new ApiService({
31
+ baseUrl: apiUrl,
32
+ token: apiKey,
33
+ command: command || 'upload',
34
+ userAgent,
35
+ allowNoToken: true
36
+ });
37
+
38
+ // Resolve tunable parameters from options or config
39
+ const batchSize = Number(options.batchSize ?? uploadConfig?.batchSize ?? DEFAULT_BATCH_SIZE);
40
+ const TIMEOUT_MS = Number(options.timeout ?? uploadConfig?.timeout ?? DEFAULT_TIMEOUT);
41
+
42
+ /**
43
+ * Upload screenshots to Vizzly
44
+ */
45
+ async function upload({
46
+ screenshotsDir,
47
+ buildName,
48
+ branch,
49
+ commit,
50
+ message,
51
+ environment = 'production',
52
+ threshold,
53
+ onProgress = () => {}
54
+ }) {
55
+ try {
56
+ // Validate required config
57
+ if (!apiKey) {
58
+ throw new ValidationError('API key is required', {
59
+ config: {
60
+ apiKey,
61
+ apiUrl
62
+ }
63
+ });
64
+ }
65
+ if (!screenshotsDir) {
66
+ throw new ValidationError('Screenshots directory is required');
67
+ }
68
+ const stats = await stat(screenshotsDir);
69
+ if (!stats.isDirectory()) {
70
+ throw new ValidationError(`${screenshotsDir} is not a directory`);
71
+ }
72
+
73
+ // Find screenshots
74
+ const files = await findScreenshots(screenshotsDir);
75
+ if (files.length === 0) {
76
+ throw new UploadError('No screenshot files found', {
77
+ directory: screenshotsDir,
78
+ pattern: '**/*.png'
79
+ });
80
+ }
81
+ onProgress({
82
+ phase: 'scanning',
83
+ total: files.length
84
+ });
85
+
86
+ // Process files to get metadata
87
+ const fileMetadata = await processFiles(files, signal, current => onProgress({
88
+ phase: 'processing',
89
+ current,
90
+ total: files.length
91
+ }));
92
+
93
+ // Check which files need uploading
94
+ const {
95
+ toUpload,
96
+ existing
97
+ } = await checkExistingFiles(fileMetadata, api, signal);
98
+ onProgress({
99
+ phase: 'deduplication',
100
+ toUpload: toUpload.length,
101
+ existing: existing.length,
102
+ total: files.length
103
+ });
104
+
105
+ // Create build and upload files
106
+ const result = await uploadFiles({
107
+ toUpload,
108
+ existing,
109
+ buildInfo: {
110
+ name: buildName || `Upload ${new Date().toISOString()}`,
111
+ branch: branch || (await getDefaultBranch()) || 'main',
112
+ commitSha: commit,
113
+ commitMessage: message,
114
+ environment,
115
+ threshold
116
+ },
117
+ api,
118
+ signal,
119
+ batchSize: batchSize,
120
+ onProgress: current => onProgress({
121
+ phase: 'uploading',
122
+ current,
123
+ total: toUpload.length
124
+ })
125
+ });
126
+ onProgress({
127
+ phase: 'completed',
128
+ buildId: result.buildId,
129
+ url: result.url
130
+ });
131
+ return {
132
+ success: true,
133
+ buildId: result.buildId,
134
+ url: result.url,
135
+ stats: {
136
+ total: files.length,
137
+ uploaded: toUpload.length,
138
+ skipped: existing.length
139
+ }
140
+ };
141
+ } catch (error) {
142
+ logger.error('Upload failed:', error);
143
+
144
+ // Re-throw if already a VizzlyError
145
+ if (error.name && error.name.includes('Error') && error.code) {
146
+ throw error;
147
+ }
148
+
149
+ // Wrap unknown errors
150
+ throw new UploadError(`Upload failed: ${error.message}`, {
151
+ originalError: error.message,
152
+ stack: error.stack
153
+ });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Wait for a build to complete
159
+ */
160
+ async function waitForBuild(buildId, timeout = TIMEOUT_MS) {
161
+ const startTime = Date.now();
162
+ while (Date.now() - startTime < timeout) {
163
+ if (signal.aborted) {
164
+ throw new UploadError('Operation cancelled', {
165
+ buildId
166
+ });
167
+ }
168
+ let resp;
169
+ try {
170
+ resp = await api.request(`/api/sdk/builds/${buildId}`, {
171
+ signal
172
+ });
173
+ } catch (err) {
174
+ const match = String(err?.message || '').match(/API request failed: (\d+)/);
175
+ const code = match ? match[1] : 'unknown';
176
+ throw new UploadError(`Failed to check build status: ${code}`);
177
+ }
178
+ const build = resp?.build ?? resp;
179
+ if (build.status === 'completed') {
180
+ // Extract comparison data for the response
181
+ const result = {
182
+ status: 'completed',
183
+ build
184
+ };
185
+
186
+ // Add comparison summary if available
187
+ if (typeof build.comparisonsTotal === 'number') {
188
+ result.comparisons = build.comparisonsTotal;
189
+ result.passedComparisons = build.comparisonsPassed || 0;
190
+ result.failedComparisons = build.comparisonsFailed || 0;
191
+ } else {
192
+ // Ensure failedComparisons is always a number, even when comparison data is missing
193
+ // This prevents the run command exit code check from failing
194
+ result.passedComparisons = 0;
195
+ result.failedComparisons = 0;
196
+ }
197
+
198
+ // Add build URL if available
199
+ if (build.url) {
200
+ result.url = build.url;
201
+ }
202
+ return result;
203
+ }
204
+ if (build.status === 'failed') {
205
+ throw new UploadError(`Build failed: ${build.error || 'Unknown error'}`);
206
+ }
207
+ await new Promise(resolve => setTimeout(resolve, 2000));
208
+ }
209
+ throw new TimeoutError(`Build timed out after ${timeout}ms`, {
210
+ buildId,
211
+ timeout,
212
+ elapsed: Date.now() - startTime
213
+ });
214
+ }
215
+ return {
216
+ upload,
217
+ waitForBuild
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Find all PNG screenshots in a directory
223
+ */
224
+ async function findScreenshots(directory) {
225
+ const pattern = `${directory}/**/*.png`;
226
+ return glob(pattern, {
227
+ absolute: true
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Process files to extract metadata and compute hashes
233
+ */
234
+ async function* processFilesGenerator(files, signal) {
235
+ for (const filePath of files) {
236
+ if (signal.aborted) throw new UploadError('Operation cancelled');
237
+ const buffer = await readFile(filePath);
238
+ const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
239
+ yield {
240
+ path: filePath,
241
+ filename: basename(filePath),
242
+ buffer,
243
+ sha256
244
+ };
245
+ }
246
+ }
247
+ async function processFiles(files, signal, onProgress) {
248
+ const results = [];
249
+ let count = 0;
250
+ for await (const file of processFilesGenerator(files, signal)) {
251
+ results.push(file);
252
+ count++;
253
+ if (count % 10 === 0 || count === files.length) {
254
+ onProgress(count);
255
+ }
256
+ }
257
+ return results;
258
+ }
259
+
260
+ /**
261
+ * Check which files already exist on the server
262
+ */
263
+ async function checkExistingFiles(fileMetadata, api, signal) {
264
+ const allShas = fileMetadata.map(f => f.sha256);
265
+ const existingShas = new Set();
266
+
267
+ // Check in batches
268
+ for (let i = 0; i < allShas.length; i += DEFAULT_SHA_CHECK_BATCH_SIZE) {
269
+ if (signal.aborted) throw new UploadError('Operation cancelled');
270
+ const batch = allShas.slice(i, i + DEFAULT_SHA_CHECK_BATCH_SIZE);
271
+ try {
272
+ const res = await api.request('/api/sdk/check-shas', {
273
+ method: 'POST',
274
+ headers: {
275
+ 'Content-Type': 'application/json'
276
+ },
277
+ body: JSON.stringify({
278
+ shas: batch
279
+ }),
280
+ signal
281
+ });
282
+ const {
283
+ existing = []
284
+ } = res || {};
285
+ existing.forEach(sha => existingShas.add(sha));
286
+ } catch (error) {
287
+ // Continue without deduplication on error
288
+ console.debug('SHA check failed, continuing without deduplication:', error.message);
289
+ }
290
+ }
291
+ return {
292
+ toUpload: fileMetadata.filter(f => !existingShas.has(f.sha256)),
293
+ existing: fileMetadata.filter(f => existingShas.has(f.sha256))
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Upload files to Vizzly
299
+ */
300
+ async function uploadFiles({
301
+ toUpload,
302
+ existing,
303
+ buildInfo,
304
+ api,
305
+ signal,
306
+ batchSize,
307
+ onProgress
308
+ }) {
309
+ let buildId = null;
310
+ let result = null;
311
+
312
+ // If all files exist, just create a build
313
+ if (toUpload.length === 0) {
314
+ return createBuildWithExisting({
315
+ existing,
316
+ buildInfo,
317
+ api,
318
+ signal
319
+ });
320
+ }
321
+
322
+ // Upload in batches
323
+ for (let i = 0; i < toUpload.length; i += batchSize) {
324
+ if (signal.aborted) throw new UploadError('Operation cancelled');
325
+ const batch = toUpload.slice(i, i + batchSize);
326
+ const isFirstBatch = i === 0;
327
+ const form = new FormData();
328
+ if (isFirstBatch) {
329
+ // First batch creates the build
330
+ form.append('build_name', buildInfo.name);
331
+ form.append('branch', buildInfo.branch);
332
+ form.append('environment', buildInfo.environment);
333
+ if (buildInfo.commitSha) form.append('commit_sha', buildInfo.commitSha);
334
+ if (buildInfo.commitMessage) form.append('commit_message', buildInfo.commitMessage);
335
+ if (buildInfo.threshold !== undefined) form.append('threshold', buildInfo.threshold.toString());
336
+
337
+ // Include existing SHAs
338
+ if (existing.length > 0) {
339
+ form.append('existing_shas', JSON.stringify(existing.map(f => f.sha256)));
340
+ }
341
+ } else {
342
+ // Subsequent batches add to existing build
343
+ form.append('build_id', buildId);
344
+ }
345
+
346
+ // Add files
347
+ for (const file of batch) {
348
+ const blob = new Blob([file.buffer], {
349
+ type: 'image/png'
350
+ });
351
+ form.append('screenshots', blob, file.filename);
352
+ }
353
+ try {
354
+ result = await api.request('/api/sdk/upload', {
355
+ method: 'POST',
356
+ body: form,
357
+ signal,
358
+ headers: {}
359
+ });
360
+ } catch (err) {
361
+ throw new UploadError(`Upload failed: ${err.message}`, {
362
+ batch: i / batchSize + 1
363
+ });
364
+ }
365
+ if (isFirstBatch && result.build?.id) {
366
+ buildId = result.build.id;
367
+ }
368
+ onProgress(i + batch.length);
369
+ }
370
+ return {
371
+ buildId: result.build?.id || buildId,
372
+ url: result.build?.url || result.url
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Create a build with only existing files
378
+ */
379
+ async function createBuildWithExisting({
380
+ existing,
381
+ buildInfo,
382
+ api,
383
+ signal
384
+ }) {
385
+ const form = new FormData();
386
+ form.append('build_name', buildInfo.name);
387
+ form.append('branch', buildInfo.branch);
388
+ form.append('environment', buildInfo.environment);
389
+ form.append('existing_shas', JSON.stringify(existing.map(f => f.sha256)));
390
+ if (buildInfo.commitSha) form.append('commit_sha', buildInfo.commitSha);
391
+ if (buildInfo.commitMessage) form.append('commit_message', buildInfo.commitMessage);
392
+ if (buildInfo.threshold !== undefined) form.append('threshold', buildInfo.threshold.toString());
393
+ let result;
394
+ try {
395
+ result = await api.request('/api/sdk/upload', {
396
+ method: 'POST',
397
+ body: form,
398
+ signal,
399
+ headers: {}
400
+ });
401
+ } catch (err) {
402
+ throw new UploadError(`Failed to create build: ${err.message}`);
403
+ }
404
+ return {
405
+ buildId: result.build?.id,
406
+ url: result.build?.url || result.url
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Uploader class for handling screenshot uploads
412
+ */
413
+ // Legacy Uploader class removed — all functionality lives in createUploader.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Take a screenshot for visual regression testing
3
+ *
4
+ * @param {string} name - Unique name for the screenshot
5
+ * @param {Buffer} imageBuffer - PNG image data as a Buffer
6
+ * @param {Object} [options] - Optional configuration
7
+ * @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
8
+ * @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
9
+ * @param {string} [options.variant] - Variant name for organizing screenshots
10
+ * @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot
11
+ *
12
+ * @returns {Promise<void>}
13
+ *
14
+ * @example
15
+ * // Basic usage
16
+ * import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
17
+ *
18
+ * const screenshot = await page.screenshot();
19
+ * await vizzlyScreenshot('homepage', screenshot);
20
+ *
21
+ * @example
22
+ * // With properties and threshold
23
+ * await vizzlyScreenshot('checkout-form', screenshot, {
24
+ * properties: {
25
+ * browser: 'chrome',
26
+ * viewport: '1920x1080'
27
+ * },
28
+ * threshold: 5
29
+ * });
30
+ *
31
+ * @throws {VizzlyError} When screenshot capture fails or client is not initialized
32
+ */
33
+ export function vizzlyScreenshot(name: string, imageBuffer: Buffer, options?: {
34
+ properties?: Record<string, any>;
35
+ threshold?: number;
36
+ variant?: string;
37
+ fullPage?: boolean;
38
+ }): Promise<void>;
39
+ /**
40
+ * Wait for all queued screenshots to be processed
41
+ *
42
+ * @returns {Promise<void>}
43
+ *
44
+ * @example
45
+ * afterAll(async () => {
46
+ * await vizzlyFlush();
47
+ * });
48
+ */
49
+ export function vizzlyFlush(): Promise<void>;
50
+ /**
51
+ * Check if the Vizzly client is initialized and ready
52
+ *
53
+ * @returns {boolean} True if client is ready, false otherwise
54
+ */
55
+ export function isVizzlyReady(): boolean;
56
+ /**
57
+ * Configure the client with custom settings
58
+ *
59
+ * @param {Object} config - Configuration options
60
+ * @param {string} [config.serverUrl] - Server URL override
61
+ * @param {boolean} [config.enabled] - Enable/disable screenshots
62
+ */
63
+ export function configure(config?: {
64
+ serverUrl?: string;
65
+ enabled?: boolean;
66
+ }): void;
67
+ /**
68
+ * Enable or disable screenshot capture
69
+ * @param {boolean} enabled - Whether to enable screenshots
70
+ */
71
+ export function setEnabled(enabled: boolean): void;
72
+ /**
73
+ * Get information about Vizzly client state
74
+ * @returns {Object} Client information
75
+ */
76
+ export function getVizzlyInfo(): any;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Doctor command implementation - Run diagnostics to check environment
3
+ * @param {Object} options - Command options
4
+ * @param {Object} globalOptions - Global CLI options
5
+ */
6
+ export function doctorCommand(options?: any, globalOptions?: any): Promise<void>;
7
+ /**
8
+ * Validate doctor options (no specific validation needed)
9
+ * @param {Object} options - Command options
10
+ */
11
+ export function validateDoctorOptions(): any[];