@vizzly-testing/cli 0.5.0 → 0.7.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 (33) hide show
  1. package/README.md +55 -9
  2. package/dist/cli.js +15 -2
  3. package/dist/commands/finalize.js +72 -0
  4. package/dist/commands/run.js +59 -19
  5. package/dist/commands/tdd.js +6 -13
  6. package/dist/commands/upload.js +1 -0
  7. package/dist/server/handlers/tdd-handler.js +82 -8
  8. package/dist/services/api-service.js +14 -0
  9. package/dist/services/html-report-generator.js +377 -0
  10. package/dist/services/report-generator/report.css +355 -0
  11. package/dist/services/report-generator/viewer.js +100 -0
  12. package/dist/services/server-manager.js +3 -2
  13. package/dist/services/tdd-service.js +436 -66
  14. package/dist/services/test-runner.js +56 -28
  15. package/dist/services/uploader.js +3 -2
  16. package/dist/types/commands/finalize.d.ts +13 -0
  17. package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
  18. package/dist/types/services/api-service.d.ts +6 -0
  19. package/dist/types/services/html-report-generator.d.ts +52 -0
  20. package/dist/types/services/report-generator/viewer.d.ts +0 -0
  21. package/dist/types/services/server-manager.d.ts +19 -1
  22. package/dist/types/services/tdd-service.d.ts +24 -3
  23. package/dist/types/services/uploader.d.ts +2 -1
  24. package/dist/types/utils/config-loader.d.ts +3 -0
  25. package/dist/types/utils/environment-config.d.ts +5 -0
  26. package/dist/types/utils/security.d.ts +29 -0
  27. package/dist/utils/config-loader.js +11 -1
  28. package/dist/utils/environment-config.js +9 -0
  29. package/dist/utils/security.js +154 -0
  30. package/docs/api-reference.md +27 -0
  31. package/docs/tdd-mode.md +58 -12
  32. package/docs/test-integration.md +69 -0
  33. package/package.json +3 -2
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
@@ -123,13 +128,19 @@ For local visual testing with immediate feedback, use the dedicated `tdd` comman
123
128
  # First run - creates local baselines
124
129
  vizzly tdd "npm test"
125
130
 
126
- # Make changes and test - fails if visual differences detected
131
+ # Make changes and test - fails if visual differences detected
127
132
  vizzly tdd "npm test"
128
133
 
129
134
  # Accept changes as new baseline
130
135
  vizzly tdd "npm test" --set-baseline
131
136
  ```
132
137
 
138
+ **Interactive HTML Report:** Each TDD run generates a detailed HTML report with visual comparison tools:
139
+ - **Overlay mode** - Toggle between baseline and current screenshots
140
+ - **Side-by-side mode** - Compare baseline and current images horizontally
141
+ - **Onion skin mode** - Drag to reveal differences interactively
142
+ - **Toggle mode** - Click to switch between baseline and current
143
+
133
144
  **TDD Command Options:**
134
145
  - `--set-baseline` - Accept current screenshots as new baseline
135
146
  - `--baseline-build <id>` - Use specific build as baseline (requires API token)
@@ -144,6 +155,7 @@ vizzly init # Create vizzly.config.js with defaults
144
155
  vizzly status <build-id> # Check build progress and results
145
156
  vizzly status <build-id> --verbose # Detailed build information
146
157
  vizzly status <build-id> --json # Machine-readable output
158
+ vizzly finalize <parallel-id> # Finalize parallel build after all shards complete
147
159
  vizzly doctor # Fast local preflight (no network)
148
160
  vizzly doctor --api # Include API connectivity checks
149
161
  ```
@@ -203,25 +215,25 @@ export default {
203
215
  // API configuration
204
216
  // Set VIZZLY_TOKEN environment variable or uncomment and set here:
205
217
  // apiToken: 'your-token-here',
206
-
218
+
207
219
  // Screenshot configuration
208
220
  screenshots: {
209
221
  directory: './screenshots',
210
222
  formats: ['png']
211
223
  },
212
-
224
+
213
225
  // Server configuration
214
226
  server: {
215
227
  port: 47392,
216
228
  screenshotPath: '/screenshot'
217
229
  },
218
-
230
+
219
231
  // Comparison configuration
220
232
  comparison: {
221
233
  threshold: 0.1,
222
234
  ignoreAntialiasing: true
223
235
  },
224
-
236
+
225
237
  // Upload configuration
226
238
  upload: {
227
239
  concurrency: 5,
@@ -278,11 +290,42 @@ For CI/CD pipelines, use the `--wait` flag to wait for visual comparison results
278
290
  env:
279
291
  VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
280
292
  # Optional: Provide correct git information from GitHub context
281
- VIZZLY_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
282
- VIZZLY_COMMIT_SHA: ${{ github.event.head_commit.id }}
293
+ VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }}
294
+ VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
283
295
  VIZZLY_BRANCH: ${{ github.head_ref || github.ref_name }}
284
296
  ```
285
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
+
286
329
  ### GitLab CI
287
330
  ```yaml
288
331
  visual-tests:
@@ -328,6 +371,9 @@ Check if Vizzly is enabled in the current environment.
328
371
  - `VIZZLY_API_URL`: Override API base URL. Default: `https://vizzly.dev`.
329
372
  - `VIZZLY_LOG_LEVEL`: Logger level. One of `debug`, `info`, `warn`, `error`. Example: `export VIZZLY_LOG_LEVEL=debug`.
330
373
 
374
+ ### Parallel Builds
375
+ - `VIZZLY_PARALLEL_ID`: Unique identifier for parallel test execution. Example: `export VIZZLY_PARALLEL_ID=ci-run-123`.
376
+
331
377
  ### Git Information Override
332
378
  For enhanced CI/CD integration, you can override git detection with these environment variables:
333
379
 
@@ -339,8 +385,8 @@ For enhanced CI/CD integration, you can override git detection with these enviro
339
385
  **Example for GitHub Actions:**
340
386
  ```yaml
341
387
  env:
342
- VIZZLY_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
343
- VIZZLY_COMMIT_SHA: ${{ github.event.head_commit.id }}
388
+ VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }}
389
+ VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
344
390
  VIZZLY_BRANCH: ${{ github.head_ref || github.ref_name }}
345
391
  VIZZLY_PR_NUMBER: ${{ github.event.pull_request.number }}
346
392
  ```
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('api');
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
+ }
@@ -17,15 +17,28 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
17
17
  color: !globalOptions.noColor
18
18
  });
19
19
  let testRunner = null;
20
- let runResult = null;
20
+ let buildId = null;
21
+ let startTime = null;
22
+ let isTddMode = false;
21
23
 
22
24
  // Ensure cleanup on exit
23
25
  const cleanup = async () => {
24
26
  ui.cleanup();
25
- if (testRunner && runResult && runResult.buildId) {
27
+
28
+ // Cancel test runner (kills process and stops server)
29
+ if (testRunner) {
26
30
  try {
27
- // Try to finalize build on interruption
28
- await testRunner.finalizeBuild(runResult.buildId, false, false, Date.now() - (runResult.startTime || Date.now()));
31
+ await testRunner.cancel();
32
+ } catch {
33
+ // Silent fail
34
+ }
35
+ }
36
+
37
+ // Finalize build if we have one
38
+ if (testRunner && buildId) {
39
+ try {
40
+ const executionTime = Date.now() - (startTime || Date.now());
41
+ await testRunner.finalizeBuild(buildId, isTddMode, false, executionTime);
29
42
  } catch {
30
43
  // Silent fail on cleanup
31
44
  }
@@ -113,6 +126,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
113
126
  });
114
127
  testRunner.on('build-created', buildInfo => {
115
128
  buildUrl = buildInfo.url;
129
+ buildId = buildInfo.buildId;
116
130
  // Debug: Log build creation details
117
131
  if (globalOptions.verbose) {
118
132
  ui.info(`Build created: ${buildInfo.buildId} - ${buildInfo.name}`);
@@ -148,26 +162,52 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
148
162
  allowNoToken: config.allowNoToken || false,
149
163
  wait: config.wait || options.wait || false,
150
164
  uploadAll: options.uploadAll || false,
151
- pullRequestNumber
165
+ pullRequestNumber,
166
+ parallelId: config.parallelId
152
167
  };
153
168
 
154
169
  // Start test run
155
170
  ui.info('Starting test execution...');
156
- runResult = {
157
- startTime: Date.now()
158
- };
159
- const result = await testRunner.run(runOptions);
160
- runResult = {
161
- ...runResult,
162
- ...result
163
- };
164
- ui.success('Test run completed successfully');
171
+ startTime = Date.now();
172
+ isTddMode = runOptions.tdd || false;
173
+ let result;
174
+ try {
175
+ result = await testRunner.run(runOptions);
165
176
 
166
- // Show Vizzly summary
167
- if (result.buildId) {
168
- console.log(`🐻 Vizzly: Captured ${result.screenshotsCaptured} screenshots in build ${result.buildId}`);
169
- if (result.url) {
170
- console.log(`🔗 Vizzly: View results at ${result.url}`);
177
+ // Store buildId for cleanup purposes
178
+ if (result.buildId) {
179
+ buildId = result.buildId;
180
+ }
181
+ ui.success('Test run completed successfully');
182
+
183
+ // Show Vizzly summary
184
+ if (result.buildId) {
185
+ console.log(`🐻 Vizzly: Captured ${result.screenshotsCaptured} screenshots in build ${result.buildId}`);
186
+ if (result.url) {
187
+ console.log(`🔗 Vizzly: View results at ${result.url}`);
188
+ }
189
+ }
190
+ } catch (error) {
191
+ // Test execution failed - build should already be finalized by test runner
192
+ ui.stopSpinner();
193
+
194
+ // Check if it's a test command failure (as opposed to setup failure)
195
+ if (error.code === 'TEST_COMMAND_FAILED' || error.code === 'TEST_COMMAND_INTERRUPTED') {
196
+ // Extract exit code from error message if available
197
+ const exitCodeMatch = error.message.match(/exited with code (\d+)/);
198
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1;
199
+ ui.error('Test run failed');
200
+ return {
201
+ success: false,
202
+ exitCode
203
+ };
204
+ } else {
205
+ // Setup or other error
206
+ ui.error('Test run failed', error);
207
+ return {
208
+ success: false,
209
+ exitCode: 1
210
+ };
171
211
  }
172
212
  }
173
213
 
@@ -11,7 +11,6 @@ import { detectBranch, detectCommit } from '../utils/git.js';
11
11
  * @returns {Promise<{result: Object, cleanup: Function}>} Result and cleanup function
12
12
  */
13
13
  export async function tddCommand(testCommand, options = {}, globalOptions = {}) {
14
- // Create UI handler
15
14
  const ui = new ConsoleUI({
16
15
  json: globalOptions.json,
17
16
  verbose: globalOptions.verbose,
@@ -43,11 +42,6 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
43
42
  ui.warning('No API token detected - running in local-only mode');
44
43
  }
45
44
 
46
- // Handle --set-baseline flag
47
- if (options.setBaseline) {
48
- ui.info('🐻 Baseline update mode - current screenshots will become new baselines');
49
- }
50
-
51
45
  // Collect git metadata
52
46
  const branch = await detectBranch(options.branch);
53
47
  const commit = await detectCommit(options.commit);
@@ -117,19 +111,20 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
117
111
  });
118
112
 
119
113
  // Show informational messages about baseline behavior
120
- if (config.apiKey) {
121
- ui.info('API token available - will fetch baselines for local comparison');
114
+ if (options.setBaseline) {
115
+ ui.info('🐻 Baseline update mode - will ignore existing baselines and create new ones');
116
+ } else if (config.baselineBuildId || config.baselineComparisonId) {
117
+ ui.info('API token available - will fetch remote baselines for local comparison');
118
+ } else if (config.apiKey) {
119
+ ui.info('API token available - will use existing local baselines or create new ones');
122
120
  } else {
123
121
  ui.warning('Running without API token - all screenshots will be marked as new');
124
122
  }
125
-
126
- // Prepare TDD run options (no uploads, local comparisons only)
127
123
  const runOptions = {
128
124
  testCommand,
129
125
  port: config.server.port,
130
126
  timeout: config.server.timeout,
131
127
  tdd: true,
132
- // Enable TDD mode
133
128
  setBaseline: options.setBaseline || false,
134
129
  // Pass through baseline update mode
135
130
  branch,
@@ -142,8 +137,6 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
142
137
  baselineComparisonId: config.baselineComparisonId,
143
138
  wait: false // No build to wait for in TDD mode
144
139
  };
145
-
146
- // Start TDD test run (local comparisons only)
147
140
  ui.info('Starting TDD test execution...');
148
141
  const result = await testRunner.run(runOptions);
149
142
 
@@ -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,
@@ -2,17 +2,32 @@ import { Buffer } from 'buffer';
2
2
  import { createServiceLogger } from '../../utils/logger-factory.js';
3
3
  import { TddService } from '../../services/tdd-service.js';
4
4
  import { colors } from '../../utils/colors.js';
5
+ import { sanitizeScreenshotName, validateScreenshotProperties } from '../../utils/security.js';
5
6
  const logger = createServiceLogger('TDD-HANDLER');
6
- export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison) => {
7
- const tddService = new TddService(config, workingDir);
7
+ export const createTddHandler = (config, workingDir, baselineBuild, baselineComparison, setBaseline = false) => {
8
+ const tddService = new TddService(config, workingDir, setBaseline);
8
9
  const builds = new Map();
9
10
  const initialize = async () => {
10
11
  logger.info('🔄 TDD mode enabled - setting up local comparison...');
12
+
13
+ // In baseline update mode, skip all baseline loading/downloading
14
+ if (setBaseline) {
15
+ logger.info('📁 Ready for new baseline creation - all screenshots will be treated as new baselines');
16
+ return;
17
+ }
18
+
19
+ // Check if we have baseline override flags that should force a fresh download
20
+ const shouldForceDownload = (baselineBuild || baselineComparison) && config.apiKey;
21
+ if (shouldForceDownload) {
22
+ logger.info('📥 Baseline override specified, downloading fresh baselines from Vizzly...');
23
+ await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
24
+ return;
25
+ }
11
26
  const baseline = await tddService.loadBaseline();
12
27
  if (!baseline) {
13
28
  if (config.apiKey) {
14
29
  logger.info('📥 No local baseline found, downloading from Vizzly...');
15
- await tddService.downloadBaselines(baselineBuild, baselineComparison);
30
+ await tddService.downloadBaselines(config.build?.environment || 'test', config.build?.branch || null, baselineBuild, baselineComparison);
16
31
  } else {
17
32
  logger.info('📝 No local baseline found and no API token - all screenshots will be marked as new');
18
33
  }
@@ -36,15 +51,72 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
36
51
  if (!build) {
37
52
  throw new Error(`Build ${buildId} not found`);
38
53
  }
54
+
55
+ // Validate and sanitize screenshot name
56
+ let sanitizedName;
57
+ try {
58
+ sanitizedName = sanitizeScreenshotName(name);
59
+ } catch (error) {
60
+ return {
61
+ statusCode: 400,
62
+ body: {
63
+ error: 'Invalid screenshot name',
64
+ details: error.message,
65
+ tddMode: true
66
+ }
67
+ };
68
+ }
69
+
70
+ // Validate and sanitize properties
71
+ let validatedProperties;
72
+ try {
73
+ validatedProperties = validateScreenshotProperties(properties);
74
+ } catch (error) {
75
+ return {
76
+ statusCode: 400,
77
+ body: {
78
+ error: 'Invalid screenshot properties',
79
+ details: error.message,
80
+ tddMode: true
81
+ }
82
+ };
83
+ }
84
+
85
+ // Create unique screenshot name based on properties
86
+ let uniqueName = sanitizedName;
87
+ const relevantProps = [];
88
+
89
+ // Add browser to name if provided (already validated)
90
+ if (validatedProperties.browser) {
91
+ relevantProps.push(validatedProperties.browser);
92
+ }
93
+
94
+ // Add viewport info if provided (already validated)
95
+ if (validatedProperties.viewport && validatedProperties.viewport.width && validatedProperties.viewport.height) {
96
+ relevantProps.push(`${validatedProperties.viewport.width}x${validatedProperties.viewport.height}`);
97
+ }
98
+
99
+ // Combine base name with relevant properties and sanitize the result
100
+ if (relevantProps.length > 0) {
101
+ let proposedUniqueName = `${sanitizedName}-${relevantProps.join('-')}`;
102
+ try {
103
+ uniqueName = sanitizeScreenshotName(proposedUniqueName);
104
+ } catch (error) {
105
+ // If the combined name is invalid, fall back to the base sanitized name
106
+ uniqueName = sanitizedName;
107
+ logger.warn(`Combined screenshot name invalid (${error.message}), using base name: ${uniqueName}`);
108
+ }
109
+ }
39
110
  const screenshot = {
40
- name,
111
+ name: uniqueName,
112
+ originalName: name,
41
113
  imageData: image,
42
- properties,
114
+ properties: validatedProperties,
43
115
  timestamp: Date.now()
44
116
  };
45
117
  build.screenshots.push(screenshot);
46
118
  const imageBuffer = Buffer.from(image, 'base64');
47
- const comparison = await tddService.compareScreenshot(name, imageBuffer, properties);
119
+ const comparison = await tddService.compareScreenshot(uniqueName, imageBuffer, validatedProperties);
48
120
  if (comparison.status === 'failed') {
49
121
  return {
50
122
  statusCode: 422,
@@ -56,7 +128,9 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
56
128
  status: comparison.status,
57
129
  baseline: comparison.baseline,
58
130
  current: comparison.current,
59
- diff: comparison.diff
131
+ diff: comparison.diff,
132
+ diffPercentage: comparison.diffPercentage,
133
+ threshold: comparison.threshold
60
134
  },
61
135
  tddMode: true
62
136
  }
@@ -112,7 +186,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
112
186
  if (build.screenshots.length === 0) {
113
187
  throw new Error('No screenshots to process. Make sure your tests are calling the Vizzly screenshot function.');
114
188
  }
115
- const results = tddService.printResults();
189
+ const results = await tddService.printResults();
116
190
  builds.delete(buildId);
117
191
  return {
118
192
  id: buildId,
@@ -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
  }