@vizzly-testing/cli 0.6.0 → 0.7.1

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.
package/README.md CHANGED
@@ -82,6 +82,7 @@ await vizzlyScreenshot('homepage', screenshot, {
82
82
  vizzly upload <directory> # Upload screenshots from directory
83
83
  vizzly upload ./screenshots --wait # Wait for processing
84
84
  vizzly upload ./screenshots --upload-all # Upload all without deduplication
85
+ vizzly upload ./screenshots --parallel-id "ci-run-123" # For parallel CI builds
85
86
  ```
86
87
 
87
88
  ### Run Tests with Integration
@@ -90,6 +91,7 @@ vizzly run "npm test" # Run with Vizzly integration
90
91
  vizzly run "pytest" --port 3002 # Custom port
91
92
  vizzly run "npm test" --wait # Wait for build completion
92
93
  vizzly run "npm test" --allow-no-token # Run without API token
94
+ vizzly run "npm test" --parallel-id "ci-run-123" # For parallel CI builds
93
95
  ```
94
96
 
95
97
  #### Run Command Options
@@ -111,6 +113,9 @@ vizzly run "npm test" --allow-no-token # Run without API token
111
113
  - `--upload-timeout <ms>` - Upload wait timeout in ms
112
114
  - `--upload-all` - Upload all screenshots without SHA deduplication
113
115
 
116
+ **Parallel Execution:**
117
+ - `--parallel-id <id>` - Unique identifier for parallel test execution (also via `VIZZLY_PARALLEL_ID`)
118
+
114
119
  **Development & Testing:**
115
120
  - `--allow-no-token` - Allow running without API token (useful for local development)
116
121
  - `--token <token>` - API token override
@@ -150,6 +155,7 @@ vizzly init # Create vizzly.config.js with defaults
150
155
  vizzly status <build-id> # Check build progress and results
151
156
  vizzly status <build-id> --verbose # Detailed build information
152
157
  vizzly status <build-id> --json # Machine-readable output
158
+ vizzly finalize <parallel-id> # Finalize parallel build after all shards complete
153
159
  vizzly doctor # Fast local preflight (no network)
154
160
  vizzly doctor --api # Include API connectivity checks
155
161
  ```
@@ -289,6 +295,37 @@ For CI/CD pipelines, use the `--wait` flag to wait for visual comparison results
289
295
  VIZZLY_BRANCH: ${{ github.head_ref || github.ref_name }}
290
296
  ```
291
297
 
298
+ ### Parallel Builds in CI
299
+
300
+ For parallel test execution, use `--parallel-id` to ensure all shards contribute to the same build:
301
+
302
+ ```yaml
303
+ # GitHub Actions with parallel matrix
304
+ jobs:
305
+ e2e-tests:
306
+ strategy:
307
+ matrix:
308
+ shard: [1/4, 2/4, 3/4, 4/4]
309
+ steps:
310
+ - name: Run tests with Vizzly
311
+ run: |
312
+ npx vizzly run "npm test -- --shard=${{ matrix.shard }}" \
313
+ --parallel-id="${{ github.run_id }}-${{ github.run_attempt }}"
314
+ env:
315
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
316
+
317
+ finalize-e2e:
318
+ needs: e2e-tests
319
+ runs-on: ubuntu-latest
320
+ if: always() && needs.e2e-tests.result == 'success'
321
+ steps:
322
+ - name: Finalize parallel build
323
+ run: |
324
+ npx vizzly finalize "${{ github.run_id }}-${{ github.run_attempt }}"
325
+ env:
326
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
327
+ ```
328
+
292
329
  ### GitLab CI
293
330
  ```yaml
294
331
  visual-tests:
@@ -334,6 +371,9 @@ Check if Vizzly is enabled in the current environment.
334
371
  - `VIZZLY_API_URL`: Override API base URL. Default: `https://vizzly.dev`.
335
372
  - `VIZZLY_LOG_LEVEL`: Logger level. One of `debug`, `info`, `warn`, `error`. Example: `export VIZZLY_LOG_LEVEL=debug`.
336
373
 
374
+ ### Parallel Builds
375
+ - `VIZZLY_PARALLEL_ID`: Unique identifier for parallel test execution. Example: `export VIZZLY_PARALLEL_ID=ci-run-123`.
376
+
337
377
  ### Git Information Override
338
378
  For enhanced CI/CD integration, you can override git detection with these environment variables:
339
379
 
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { uploadCommand, validateUploadOptions } from './commands/upload.js';
6
6
  import { runCommand, validateRunOptions } from './commands/run.js';
7
7
  import { tddCommand, validateTddOptions } from './commands/tdd.js';
8
8
  import { statusCommand, validateStatusOptions } from './commands/status.js';
9
+ import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
9
10
  import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
10
11
  import { getPackageVersion } from './utils/package-info.js';
11
12
  program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output').option('--json', 'Machine-readable output').option('--no-color', 'Disable colored output');
@@ -16,7 +17,7 @@ program.command('init').description('Initialize Vizzly in your project').option(
16
17
  ...options
17
18
  });
18
19
  });
19
- program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').action(async (path, options) => {
20
+ program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
20
21
  const globalOptions = program.opts();
21
22
 
22
23
  // Validate options
@@ -59,7 +60,7 @@ program.command('tdd').description('Run tests in TDD mode with local visual comp
59
60
  }
60
61
  await cleanup();
61
62
  });
62
- program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').action(async (command, options) => {
63
+ program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (command, options) => {
63
64
  const globalOptions = program.opts();
64
65
 
65
66
  // Validate options
@@ -94,6 +95,18 @@ program.command('status').description('Check the status of a build').argument('<
94
95
  }
95
96
  await statusCommand(buildId, options, globalOptions);
96
97
  });
98
+ program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
99
+ const globalOptions = program.opts();
100
+
101
+ // Validate options
102
+ const validationErrors = validateFinalizeOptions(parallelId, options);
103
+ if (validationErrors.length > 0) {
104
+ console.error('Validation errors:');
105
+ validationErrors.forEach(error => console.error(` - ${error}`));
106
+ process.exit(1);
107
+ }
108
+ await finalizeCommand(parallelId, options, globalOptions);
109
+ });
97
110
  program.command('doctor').description('Run diagnostics to check your environment and configuration').option('--api', 'Include API connectivity checks').action(async options => {
98
111
  const globalOptions = program.opts();
99
112
 
@@ -0,0 +1,72 @@
1
+ import { loadConfig } from '../utils/config-loader.js';
2
+ import { ConsoleUI } from '../utils/console-ui.js';
3
+ import { createServiceContainer } from '../container/index.js';
4
+
5
+ /**
6
+ * Finalize command implementation
7
+ * @param {string} parallelId - Parallel ID to finalize
8
+ * @param {Object} options - Command options
9
+ * @param {Object} globalOptions - Global CLI options
10
+ */
11
+ export async function finalizeCommand(parallelId, options = {}, globalOptions = {}) {
12
+ // Create UI handler
13
+ const ui = new ConsoleUI({
14
+ json: globalOptions.json,
15
+ verbose: globalOptions.verbose,
16
+ color: !globalOptions.noColor
17
+ });
18
+ try {
19
+ // Load configuration with CLI overrides
20
+ const allOptions = {
21
+ ...globalOptions,
22
+ ...options
23
+ };
24
+ const config = await loadConfig(globalOptions.config, allOptions);
25
+
26
+ // Validate API token
27
+ if (!config.apiKey) {
28
+ ui.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
29
+ return;
30
+ }
31
+ if (globalOptions.verbose) {
32
+ ui.info('Configuration loaded', {
33
+ parallelId,
34
+ apiUrl: config.apiUrl
35
+ });
36
+ }
37
+
38
+ // Create service container and get API service
39
+ ui.startSpinner('Finalizing parallel build...');
40
+ const container = await createServiceContainer(config, 'finalize');
41
+ const apiService = await container.get('apiService');
42
+ ui.stopSpinner();
43
+
44
+ // Call finalize endpoint
45
+ const result = await apiService.finalizeParallelBuild(parallelId);
46
+ if (globalOptions.json) {
47
+ console.log(JSON.stringify(result, null, 2));
48
+ } else {
49
+ ui.success(`Parallel build ${result.build.id} finalized successfully`);
50
+ ui.info(`Status: ${result.build.status}`);
51
+ ui.info(`Parallel ID: ${result.build.parallel_id}`);
52
+ }
53
+ } catch (error) {
54
+ ui.stopSpinner();
55
+ ui.error('Failed to finalize parallel build', error);
56
+ } finally {
57
+ ui.cleanup();
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Validate finalize options
63
+ * @param {string} parallelId - Parallel ID to finalize
64
+ * @param {Object} options - Command options
65
+ */
66
+ export function validateFinalizeOptions(parallelId, _options) {
67
+ const errors = [];
68
+ if (!parallelId || parallelId.trim() === '') {
69
+ errors.push('Parallel ID is required');
70
+ }
71
+ return errors;
72
+ }
@@ -162,7 +162,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
162
162
  allowNoToken: config.allowNoToken || false,
163
163
  wait: config.wait || options.wait || false,
164
164
  uploadAll: options.uploadAll || false,
165
- pullRequestNumber
165
+ pullRequestNumber,
166
+ parallelId: config.parallelId
166
167
  };
167
168
 
168
169
  // Start test run
@@ -101,6 +101,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
101
101
  uploadAll: options.uploadAll || false,
102
102
  metadata: options.metadata ? JSON.parse(options.metadata) : {},
103
103
  pullRequestNumber,
104
+ parallelId: config.parallelId,
104
105
  onProgress: progressData => {
105
106
  const {
106
107
  message: progressMessage,
@@ -241,4 +241,18 @@ export class ApiService {
241
241
  async getTokenContext() {
242
242
  return this.request('/api/sdk/token/context');
243
243
  }
244
+
245
+ /**
246
+ * Finalize a parallel build
247
+ * @param {string} parallelId - Parallel ID to finalize
248
+ * @returns {Promise<Object>} Finalization result
249
+ */
250
+ async finalizeParallelBuild(parallelId) {
251
+ return this.request(`/api/sdk/parallel/${parallelId}/finalize`, {
252
+ method: 'POST',
253
+ headers: {
254
+ 'Content-Type': 'application/json'
255
+ }
256
+ });
257
+ }
244
258
  }
@@ -159,9 +159,15 @@ export class TddService {
159
159
  });
160
160
  }
161
161
 
162
- // Download each screenshot (with efficient SHA checking)
162
+ // Download screenshots in batches with progress indication
163
163
  let downloadedCount = 0;
164
164
  let skippedCount = 0;
165
+ let errorCount = 0;
166
+ const totalScreenshots = buildDetails.screenshots.length;
167
+ const batchSize = 5; // Download up to 5 screenshots concurrently
168
+
169
+ // Filter screenshots that need to be downloaded
170
+ const screenshotsToProcess = [];
165
171
  for (const screenshot of buildDetails.screenshots) {
166
172
  // Sanitize screenshot name for security
167
173
  let sanitizedName;
@@ -169,6 +175,7 @@ export class TddService {
169
175
  sanitizedName = sanitizeScreenshotName(screenshot.name);
170
176
  } catch (error) {
171
177
  logger.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
178
+ errorCount++;
172
179
  continue;
173
180
  }
174
181
  const imagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
@@ -190,28 +197,75 @@ export class TddService {
190
197
  const downloadUrl = screenshot.original_url || screenshot.url;
191
198
  if (!downloadUrl) {
192
199
  logger.warn(`⚠️ Screenshot ${sanitizedName} has no download URL - skipping`);
193
- continue; // Skip screenshots without URLs
200
+ errorCount++;
201
+ continue;
194
202
  }
195
- logger.debug(`📥 Downloading screenshot: ${sanitizedName} from ${downloadUrl}`);
196
- try {
197
- // Download the image
198
- const response = await fetchWithTimeout(downloadUrl);
199
- if (!response.ok) {
200
- throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
201
- }
202
- const arrayBuffer = await response.arrayBuffer();
203
- const imageBuffer = Buffer.from(arrayBuffer);
204
- writeFileSync(imagePath, imageBuffer);
205
- downloadedCount++;
206
- logger.debug(`✓ Downloaded ${sanitizedName}.png`);
207
- } catch (error) {
208
- logger.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
203
+ screenshotsToProcess.push({
204
+ screenshot,
205
+ sanitizedName,
206
+ imagePath,
207
+ downloadUrl
208
+ });
209
+ }
210
+
211
+ // Process downloads in batches
212
+ const actualDownloadsNeeded = screenshotsToProcess.length;
213
+ if (actualDownloadsNeeded > 0) {
214
+ logger.info(`📥 Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...`);
215
+ for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
216
+ const batch = screenshotsToProcess.slice(i, i + batchSize);
217
+ const batchNum = Math.floor(i / batchSize) + 1;
218
+ const totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
219
+ logger.info(`📦 Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)`);
220
+
221
+ // Download batch concurrently
222
+ const downloadPromises = batch.map(async ({
223
+ sanitizedName,
224
+ imagePath,
225
+ downloadUrl
226
+ }) => {
227
+ try {
228
+ logger.debug(`📥 Downloading: ${sanitizedName}`);
229
+ const response = await fetchWithTimeout(downloadUrl);
230
+ if (!response.ok) {
231
+ throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
232
+ }
233
+ const arrayBuffer = await response.arrayBuffer();
234
+ const imageBuffer = Buffer.from(arrayBuffer);
235
+ writeFileSync(imagePath, imageBuffer);
236
+ logger.debug(`✓ Downloaded ${sanitizedName}.png`);
237
+ return {
238
+ success: true,
239
+ name: sanitizedName
240
+ };
241
+ } catch (error) {
242
+ logger.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
243
+ return {
244
+ success: false,
245
+ name: sanitizedName,
246
+ error: error.message
247
+ };
248
+ }
249
+ });
250
+ const batchResults = await Promise.all(downloadPromises);
251
+ const batchSuccesses = batchResults.filter(r => r.success).length;
252
+ const batchFailures = batchResults.filter(r => !r.success).length;
253
+ downloadedCount += batchSuccesses;
254
+ errorCount += batchFailures;
255
+
256
+ // Show progress
257
+ const totalProcessed = downloadedCount + skippedCount + errorCount;
258
+ const progressPercent = Math.round(totalProcessed / totalScreenshots * 100);
259
+ logger.info(`📊 Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch`);
209
260
  }
210
261
  }
211
262
 
212
263
  // Check if we actually downloaded any screenshots
213
- if (downloadedCount === 0) {
264
+ if (downloadedCount === 0 && skippedCount === 0) {
214
265
  logger.error('❌ No screenshots were successfully downloaded from the baseline build');
266
+ if (errorCount > 0) {
267
+ logger.info(`💡 ${errorCount} screenshots had errors - check download URLs and network connection`);
268
+ }
215
269
  logger.info('💡 This usually means the build failed or screenshots have no download URLs');
216
270
  logger.info('💡 Try using a successful build ID, or run without --baseline-build to create local baselines');
217
271
  return null;
@@ -258,9 +312,16 @@ export class TddService {
258
312
  };
259
313
  const metadataPath = join(this.baselinePath, 'metadata.json');
260
314
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
261
- if (skippedCount > 0) {
262
- const actualDownloads = downloadedCount - skippedCount;
263
- logger.info(`✅ Baseline ready - ${actualDownloads} downloaded, ${skippedCount} skipped (matching SHA) - ${downloadedCount}/${buildDetails.screenshots.length} total`);
315
+
316
+ // Final summary
317
+ const actualDownloads = downloadedCount - skippedCount;
318
+ const totalAttempted = downloadedCount + errorCount;
319
+ if (skippedCount > 0 || errorCount > 0) {
320
+ let summaryParts = [];
321
+ if (actualDownloads > 0) summaryParts.push(`${actualDownloads} downloaded`);
322
+ if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped (matching SHA)`);
323
+ if (errorCount > 0) summaryParts.push(`${errorCount} failed`);
324
+ logger.info(`✅ Baseline ready - ${summaryParts.join(', ')} - ${totalAttempted}/${buildDetails.screenshots.length} total`);
264
325
  } else {
265
326
  logger.info(`✅ Baseline downloaded successfully - ${downloadedCount}/${buildDetails.screenshots.length} screenshots`);
266
327
  }
@@ -138,7 +138,8 @@ export class TestRunner extends BaseService {
138
138
  environment: options.environment || 'test',
139
139
  commit_sha: options.commit,
140
140
  commit_message: options.message,
141
- github_pull_request_number: options.pullRequestNumber
141
+ github_pull_request_number: options.pullRequestNumber,
142
+ parallel_id: options.parallelId
142
143
  });
143
144
  this.logger.debug(`Build created with ID: ${buildResult.id}`);
144
145
 
@@ -51,6 +51,7 @@ export function createUploader({
51
51
  environment = 'production',
52
52
  threshold,
53
53
  pullRequestNumber,
54
+ parallelId,
54
55
  onProgress = () => {}
55
56
  }) {
56
57
  try {
@@ -101,7 +102,8 @@ export function createUploader({
101
102
  commit_message: message,
102
103
  environment,
103
104
  threshold,
104
- github_pull_request_number: pullRequestNumber
105
+ github_pull_request_number: pullRequestNumber,
106
+ parallel_id: parallelId
105
107
  };
106
108
  const build = await api.createBuild(buildInfo);
107
109
  const buildId = build.id;
@@ -219,7 +221,6 @@ export function createUploader({
219
221
  if (build.status === 'failed') {
220
222
  throw new UploadError(`Build failed: ${build.error || 'Unknown error'}`);
221
223
  }
222
- await new Promise(resolve => setTimeout(resolve, 2000));
223
224
  }
224
225
  throw new TimeoutError(`Build timed out after ${timeout}ms`, {
225
226
  buildId,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Finalize command implementation
3
+ * @param {string} parallelId - Parallel ID to finalize
4
+ * @param {Object} options - Command options
5
+ * @param {Object} globalOptions - Global CLI options
6
+ */
7
+ export function finalizeCommand(parallelId: string, options?: any, globalOptions?: any): Promise<void>;
8
+ /**
9
+ * Validate finalize options
10
+ * @param {string} parallelId - Parallel ID to finalize
11
+ * @param {Object} options - Command options
12
+ */
13
+ export function validateFinalizeOptions(parallelId: string, _options: any): string[];
@@ -76,4 +76,10 @@ export class ApiService {
76
76
  * @returns {Promise<Object>} Token context data
77
77
  */
78
78
  getTokenContext(): Promise<any>;
79
+ /**
80
+ * Finalize a parallel build
81
+ * @param {string} parallelId - Parallel ID to finalize
82
+ * @returns {Promise<Object>} Finalization result
83
+ */
84
+ finalizeParallelBuild(parallelId: string): Promise<any>;
79
85
  }
@@ -8,7 +8,7 @@ export function createUploader({ apiKey, apiUrl, userAgent, command, upload: upl
8
8
  command: any;
9
9
  upload?: {};
10
10
  }, options?: {}): {
11
- upload: ({ screenshotsDir, buildName, branch, commit, message, environment, threshold, pullRequestNumber, onProgress, }: {
11
+ upload: ({ screenshotsDir, buildName, branch, commit, message, environment, threshold, pullRequestNumber, parallelId, onProgress, }: {
12
12
  screenshotsDir: any;
13
13
  buildName: any;
14
14
  branch: any;
@@ -17,6 +17,7 @@ export function createUploader({ apiKey, apiUrl, userAgent, command, upload: upl
17
17
  environment?: string;
18
18
  threshold: any;
19
19
  pullRequestNumber: any;
20
+ parallelId: any;
20
21
  onProgress?: () => void;
21
22
  }) => Promise<{
22
23
  success: boolean;
@@ -37,6 +37,11 @@ export function getServerUrl(): string | undefined;
37
37
  * @returns {string|undefined} Build ID
38
38
  */
39
39
  export function getBuildId(): string | undefined;
40
+ /**
41
+ * Get parallel ID from environment
42
+ * @returns {string|undefined} Parallel ID
43
+ */
44
+ export function getParallelId(): string | undefined;
40
45
  /**
41
46
  * Check if TDD mode is enabled
42
47
  * @returns {boolean} Whether TDD mode is enabled
@@ -1,6 +1,6 @@
1
1
  import { cosmiconfigSync } from 'cosmiconfig';
2
2
  import { resolve } from 'path';
3
- import { getApiToken, getApiUrl } from './environment-config.js';
3
+ import { getApiToken, getApiUrl, getParallelId } from './environment-config.js';
4
4
  const DEFAULT_CONFIG = {
5
5
  // API Configuration
6
6
  apiKey: getApiToken(),
@@ -62,8 +62,10 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
62
62
  // 2. Override with environment variables
63
63
  const envApiKey = getApiToken();
64
64
  const envApiUrl = getApiUrl();
65
+ const envParallelId = getParallelId();
65
66
  if (envApiKey) config.apiKey = envApiKey;
66
67
  if (envApiUrl !== 'https://vizzly.dev') config.apiUrl = envApiUrl;
68
+ if (envParallelId) config.parallelId = envParallelId;
67
69
 
68
70
  // 3. Apply CLI overrides (highest priority)
69
71
  applyCLIOverrides(config, cliOverrides);
@@ -85,6 +87,7 @@ function applyCLIOverrides(config, cliOverrides = {}) {
85
87
  if (cliOverrides.branch) config.build.branch = cliOverrides.branch;
86
88
  if (cliOverrides.commit) config.build.commit = cliOverrides.commit;
87
89
  if (cliOverrides.message) config.build.message = cliOverrides.message;
90
+ if (cliOverrides.parallelId) config.parallelId = cliOverrides.parallelId;
88
91
 
89
92
  // Server overrides
90
93
  if (cliOverrides.port) config.server.port = parseInt(cliOverrides.port, 10);
@@ -59,6 +59,14 @@ export function getBuildId() {
59
59
  return process.env.VIZZLY_BUILD_ID;
60
60
  }
61
61
 
62
+ /**
63
+ * Get parallel ID from environment
64
+ * @returns {string|undefined} Parallel ID
65
+ */
66
+ export function getParallelId() {
67
+ return process.env.VIZZLY_PARALLEL_ID;
68
+ }
69
+
62
70
  /**
63
71
  * Check if TDD mode is enabled
64
72
  * @returns {boolean} Whether TDD mode is enabled
@@ -88,6 +96,7 @@ export function getAllEnvironmentConfig() {
88
96
  enabled: isVizzlyEnabled(),
89
97
  serverUrl: getServerUrl(),
90
98
  buildId: getBuildId(),
99
+ parallelId: getParallelId(),
91
100
  tddMode: isTddMode()
92
101
  };
93
102
  }
@@ -289,6 +289,7 @@ Upload screenshots from a directory.
289
289
  - `--token <token>` - API token override
290
290
  - `--wait` - Wait for build completion
291
291
  - `--upload-all` - Upload all screenshots without SHA deduplication
292
+ - `--parallel-id <id>` - Unique identifier for parallel test execution
292
293
 
293
294
  **Exit Codes:**
294
295
  - `0` - Success (all approved or no changes)
@@ -321,6 +322,9 @@ Run tests with Vizzly integration.
321
322
  - `--upload-timeout <ms>` - Upload wait timeout in ms (default: from config or 30000)
322
323
  - `--upload-all` - Upload all screenshots without SHA deduplication
323
324
 
325
+ *Parallel Execution:*
326
+ - `--parallel-id <id>` - Unique identifier for parallel test execution
327
+
324
328
  *Development & Testing:*
325
329
  - `--allow-no-token` - Allow running without API token
326
330
  - `--token <token>` - API token override
@@ -403,6 +407,26 @@ Check build status.
403
407
  - `1` - Build has changes requiring review
404
408
  - `2` - Build failed or error
405
409
 
410
+ ### `vizzly finalize <parallel-id>`
411
+
412
+ Finalize a parallel build after all shards complete.
413
+
414
+ **Arguments:**
415
+ - `<parallel-id>` - Parallel ID to finalize
416
+
417
+ **Description:**
418
+ When using parallel execution with `--parallel-id`, all test shards contribute screenshots to the same shared build. After all shards complete successfully, use this command to finalize the build and trigger comparison processing.
419
+
420
+ **Example:**
421
+ ```bash
422
+ # After all parallel shards complete
423
+ vizzly finalize "ci-run-123-attempt-1"
424
+ ```
425
+
426
+ **Exit Codes:**
427
+ - `0` - Build finalized successfully
428
+ - `1` - Finalization failed or error
429
+
406
430
  ### `vizzly doctor`
407
431
 
408
432
  Run environment diagnostics.
@@ -486,6 +510,9 @@ Configuration loaded via cosmiconfig in this order:
486
510
  - `VIZZLY_API_URL` - API base URL override
487
511
  - `VIZZLY_LOG_LEVEL` - Logger level (`debug`, `info`, `warn`, `error`)
488
512
 
513
+ **Parallel Builds:**
514
+ - `VIZZLY_PARALLEL_ID` - Unique identifier for parallel test execution
515
+
489
516
  **Git Information Override (CI/CD Enhancement):**
490
517
  - `VIZZLY_COMMIT_SHA` - Override detected commit SHA
491
518
  - `VIZZLY_COMMIT_MESSAGE` - Override detected commit message
@@ -209,6 +209,15 @@ vizzly run "npm test" --upload-all
209
209
  vizzly run "npm test" --threshold 0.02
210
210
  ```
211
211
 
212
+ ### Parallel Execution
213
+
214
+ **`--parallel-id <id>`** - Unique identifier for parallel test execution
215
+ ```bash
216
+ vizzly run "npm test" --parallel-id "ci-run-123"
217
+ ```
218
+
219
+ When using parallel execution, multiple test runners can contribute screenshots to the same build. This is particularly useful for CI/CD pipelines with parallel jobs.
220
+
212
221
  ### Development Options
213
222
 
214
223
  For TDD mode, use the dedicated `vizzly tdd` command. See [TDD Mode Guide](./tdd-mode.md) for details.
@@ -283,6 +292,43 @@ jobs:
283
292
 
284
293
  **Enhanced Git Information:** The `VIZZLY_*` environment variables ensure accurate git metadata is captured in your builds, avoiding issues with merge commits that can occur in CI environments.
285
294
 
295
+ ### Parallel Builds in CI
296
+
297
+ For parallel test execution, use `--parallel-id` to ensure all shards contribute to the same build:
298
+
299
+ ```yaml
300
+ # GitHub Actions with parallel matrix
301
+ jobs:
302
+ e2e-tests:
303
+ strategy:
304
+ matrix:
305
+ shard: [1/4, 2/4, 3/4, 4/4]
306
+ steps:
307
+ - name: Run tests with Vizzly
308
+ run: |
309
+ npx vizzly run "npm test -- --shard=${{ matrix.shard }}" \
310
+ --parallel-id="${{ github.run_id }}-${{ github.run_attempt }}"
311
+ env:
312
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
313
+
314
+ finalize-e2e:
315
+ needs: e2e-tests
316
+ runs-on: ubuntu-latest
317
+ if: always() && needs.e2e-tests.result == 'success'
318
+ steps:
319
+ - name: Finalize parallel build
320
+ run: |
321
+ npx vizzly finalize "${{ github.run_id }}-${{ github.run_attempt }}"
322
+ env:
323
+ VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
324
+ ```
325
+
326
+ **How Parallel Builds Work:**
327
+ 1. All shards with the same `--parallel-id` contribute to one shared build
328
+ 2. First shard creates the build, subsequent shards add screenshots to it
329
+ 3. After all shards complete, use `vizzly finalize` to trigger comparison processing
330
+ 4. Use GitHub's run ID + attempt for uniqueness across workflow runs
331
+
286
332
  ### GitLab CI
287
333
 
288
334
  ```yaml
@@ -294,6 +340,29 @@ visual-tests:
294
340
  - npx vizzly run "npm test" --wait
295
341
  variables:
296
342
  VIZZLY_TOKEN: $VIZZLY_TOKEN
343
+
344
+ # Parallel execution example
345
+ visual-tests-parallel:
346
+ stage: test
347
+ parallel:
348
+ matrix:
349
+ - SHARD: "1/4"
350
+ - SHARD: "2/4"
351
+ - SHARD: "3/4"
352
+ - SHARD: "4/4"
353
+ script:
354
+ - npm ci
355
+ - npx vizzly run "npm test -- --shard=$SHARD" --parallel-id "$CI_PIPELINE_ID-$CI_JOB_ID"
356
+ variables:
357
+ VIZZLY_TOKEN: $VIZZLY_TOKEN
358
+
359
+ finalize-visual-tests:
360
+ stage: finalize
361
+ needs: ["visual-tests-parallel"]
362
+ script:
363
+ - npx vizzly finalize "$CI_PIPELINE_ID-$CI_JOB_ID"
364
+ variables:
365
+ VIZZLY_TOKEN: $VIZZLY_TOKEN
297
366
  ```
298
367
 
299
368
  ## Advanced Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",