@vizzly-testing/cli 0.4.0 → 0.6.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.
package/README.md CHANGED
@@ -123,13 +123,19 @@ For local visual testing with immediate feedback, use the dedicated `tdd` comman
123
123
  # First run - creates local baselines
124
124
  vizzly tdd "npm test"
125
125
 
126
- # Make changes and test - fails if visual differences detected
126
+ # Make changes and test - fails if visual differences detected
127
127
  vizzly tdd "npm test"
128
128
 
129
129
  # Accept changes as new baseline
130
130
  vizzly tdd "npm test" --set-baseline
131
131
  ```
132
132
 
133
+ **Interactive HTML Report:** Each TDD run generates a detailed HTML report with visual comparison tools:
134
+ - **Overlay mode** - Toggle between baseline and current screenshots
135
+ - **Side-by-side mode** - Compare baseline and current images horizontally
136
+ - **Onion skin mode** - Drag to reveal differences interactively
137
+ - **Toggle mode** - Click to switch between baseline and current
138
+
133
139
  **TDD Command Options:**
134
140
  - `--set-baseline` - Accept current screenshots as new baseline
135
141
  - `--baseline-build <id>` - Use specific build as baseline (requires API token)
@@ -203,25 +209,25 @@ export default {
203
209
  // API configuration
204
210
  // Set VIZZLY_TOKEN environment variable or uncomment and set here:
205
211
  // apiToken: 'your-token-here',
206
-
212
+
207
213
  // Screenshot configuration
208
214
  screenshots: {
209
215
  directory: './screenshots',
210
216
  formats: ['png']
211
217
  },
212
-
218
+
213
219
  // Server configuration
214
220
  server: {
215
221
  port: 47392,
216
222
  screenshotPath: '/screenshot'
217
223
  },
218
-
224
+
219
225
  // Comparison configuration
220
226
  comparison: {
221
227
  threshold: 0.1,
222
228
  ignoreAntialiasing: true
223
229
  },
224
-
230
+
225
231
  // Upload configuration
226
232
  upload: {
227
233
  concurrency: 5,
@@ -277,6 +283,10 @@ For CI/CD pipelines, use the `--wait` flag to wait for visual comparison results
277
283
  run: npx vizzly run "npm test" --wait
278
284
  env:
279
285
  VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
286
+ # Optional: Provide correct git information from GitHub context
287
+ VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }}
288
+ VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
289
+ VIZZLY_BRANCH: ${{ github.head_ref || github.ref_name }}
280
290
  ```
281
291
 
282
292
  ### GitLab CI
@@ -319,10 +329,30 @@ Check if Vizzly is enabled in the current environment.
319
329
 
320
330
  ## Environment Variables
321
331
 
332
+ ### Core Configuration
322
333
  - `VIZZLY_TOKEN`: API authentication token. Example: `export VIZZLY_TOKEN=your-token`.
323
334
  - `VIZZLY_API_URL`: Override API base URL. Default: `https://vizzly.dev`.
324
335
  - `VIZZLY_LOG_LEVEL`: Logger level. One of `debug`, `info`, `warn`, `error`. Example: `export VIZZLY_LOG_LEVEL=debug`.
325
336
 
337
+ ### Git Information Override
338
+ For enhanced CI/CD integration, you can override git detection with these environment variables:
339
+
340
+ - `VIZZLY_COMMIT_SHA`: Override detected commit SHA. Useful in CI environments.
341
+ - `VIZZLY_COMMIT_MESSAGE`: Override detected commit message. Useful in CI environments.
342
+ - `VIZZLY_BRANCH`: Override detected branch name. Useful in CI environments.
343
+ - `VIZZLY_PR_NUMBER`: Override detected pull request number. Useful for PR-specific builds.
344
+
345
+ **Example for GitHub Actions:**
346
+ ```yaml
347
+ env:
348
+ VIZZLY_COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message || github.event.head_commit.message }}
349
+ VIZZLY_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.id }}
350
+ VIZZLY_BRANCH: ${{ github.head_ref || github.ref_name }}
351
+ VIZZLY_PR_NUMBER: ${{ github.event.pull_request.number }}
352
+ ```
353
+
354
+ These variables take highest priority over both CLI arguments and automatic git detection.
355
+
326
356
  ## Contributing
327
357
 
328
358
  We welcome contributions! Whether you're fixing bugs, adding features, or improving documentation, your help makes Vizzly better for everyone.
package/dist/cli.js CHANGED
@@ -38,10 +38,26 @@ program.command('tdd').description('Run tests in TDD mode with local visual comp
38
38
  validationErrors.forEach(error => console.error(` - ${error}`));
39
39
  process.exit(1);
40
40
  }
41
- const result = await tddCommand(command, options, globalOptions);
41
+ const {
42
+ result,
43
+ cleanup
44
+ } = await tddCommand(command, options, globalOptions);
45
+
46
+ // Set up cleanup on process signals
47
+ const handleCleanup = async () => {
48
+ await cleanup();
49
+ };
50
+ process.once('SIGINT', () => {
51
+ handleCleanup().then(() => process.exit(1));
52
+ });
53
+ process.once('SIGTERM', () => {
54
+ handleCleanup().then(() => process.exit(1));
55
+ });
42
56
  if (result && !result.success && result.exitCode > 0) {
57
+ await cleanup();
43
58
  process.exit(result.exitCode);
44
59
  }
60
+ await cleanup();
45
61
  });
46
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) => {
47
63
  const globalOptions = program.opts();
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from '../utils/config-loader.js';
2
2
  import { ConsoleUI } from '../utils/console-ui.js';
3
3
  import { createServiceContainer } from '../container/index.js';
4
- import { detectBranch, detectCommit, getCommitMessage, generateBuildNameWithGit } from '../utils/git.js';
4
+ import { detectBranch, detectCommit, detectCommitMessage, detectPullRequestNumber, generateBuildNameWithGit } from '../utils/git.js';
5
5
 
6
6
  /**
7
7
  * Run command implementation
@@ -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) {
30
+ try {
31
+ await testRunner.cancel();
32
+ } catch {
33
+ // Silent fail
34
+ }
35
+ }
36
+
37
+ // Finalize build if we have one
38
+ if (testRunner && buildId) {
26
39
  try {
27
- // Try to finalize build on interruption
28
- await testRunner.finalizeBuild(runResult.buildId, false, false, Date.now() - (runResult.startTime || Date.now()));
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
  }
@@ -55,8 +68,9 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
55
68
  // Collect git metadata and build info
56
69
  const branch = await detectBranch(options.branch);
57
70
  const commit = await detectCommit(options.commit);
58
- const message = options.message || (await getCommitMessage());
71
+ const message = options.message || (await detectCommitMessage());
59
72
  const buildName = await generateBuildNameWithGit(options.buildName);
73
+ const pullRequestNumber = detectPullRequestNumber();
60
74
  if (globalOptions.verbose) {
61
75
  ui.info('Configuration loaded', {
62
76
  testCommand,
@@ -112,6 +126,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
112
126
  });
113
127
  testRunner.on('build-created', buildInfo => {
114
128
  buildUrl = buildInfo.url;
129
+ buildId = buildInfo.buildId;
115
130
  // Debug: Log build creation details
116
131
  if (globalOptions.verbose) {
117
132
  ui.info(`Build created: ${buildInfo.buildId} - ${buildInfo.name}`);
@@ -146,26 +161,52 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
146
161
  eager: config.eager || false,
147
162
  allowNoToken: config.allowNoToken || false,
148
163
  wait: config.wait || options.wait || false,
149
- uploadAll: options.uploadAll || false
164
+ uploadAll: options.uploadAll || false,
165
+ pullRequestNumber
150
166
  };
151
167
 
152
168
  // Start test run
153
169
  ui.info('Starting test execution...');
154
- runResult = {
155
- startTime: Date.now()
156
- };
157
- const result = await testRunner.run(runOptions);
158
- runResult = {
159
- ...runResult,
160
- ...result
161
- };
162
- ui.success('Test run completed successfully');
170
+ startTime = Date.now();
171
+ isTddMode = runOptions.tdd || false;
172
+ let result;
173
+ try {
174
+ result = await testRunner.run(runOptions);
163
175
 
164
- // Show Vizzly summary
165
- if (result.buildId) {
166
- console.log(`🐻 Vizzly: Captured ${result.screenshotsCaptured} screenshots in build ${result.buildId}`);
167
- if (result.url) {
168
- console.log(`🔗 Vizzly: View results at ${result.url}`);
176
+ // Store buildId for cleanup purposes
177
+ if (result.buildId) {
178
+ buildId = result.buildId;
179
+ }
180
+ ui.success('Test run completed successfully');
181
+
182
+ // Show Vizzly summary
183
+ if (result.buildId) {
184
+ console.log(`🐻 Vizzly: Captured ${result.screenshotsCaptured} screenshots in build ${result.buildId}`);
185
+ if (result.url) {
186
+ console.log(`🔗 Vizzly: View results at ${result.url}`);
187
+ }
188
+ }
189
+ } catch (error) {
190
+ // Test execution failed - build should already be finalized by test runner
191
+ ui.stopSpinner();
192
+
193
+ // Check if it's a test command failure (as opposed to setup failure)
194
+ if (error.code === 'TEST_COMMAND_FAILED' || error.code === 'TEST_COMMAND_INTERRUPTED') {
195
+ // Extract exit code from error message if available
196
+ const exitCodeMatch = error.message.match(/exited with code (\d+)/);
197
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1;
198
+ ui.error('Test run failed');
199
+ return {
200
+ success: false,
201
+ exitCode
202
+ };
203
+ } else {
204
+ // Setup or other error
205
+ ui.error('Test run failed', error);
206
+ return {
207
+ success: false,
208
+ exitCode: 1
209
+ };
169
210
  }
170
211
  }
171
212
 
@@ -8,29 +8,26 @@ import { detectBranch, detectCommit } from '../utils/git.js';
8
8
  * @param {string} testCommand - Test command to execute
9
9
  * @param {Object} options - Command options
10
10
  * @param {Object} globalOptions - Global CLI options
11
+ * @returns {Promise<{result: Object, cleanup: Function}>} Result and cleanup function
11
12
  */
12
13
  export async function tddCommand(testCommand, options = {}, globalOptions = {}) {
13
- // Create UI handler
14
14
  const ui = new ConsoleUI({
15
15
  json: globalOptions.json,
16
16
  verbose: globalOptions.verbose,
17
17
  color: !globalOptions.noColor
18
18
  });
19
19
  let testRunner = null;
20
+ let isCleanedUp = false;
20
21
 
21
- // Ensure cleanup on exit - store listeners for proper cleanup
22
+ // Create cleanup function that can be called by the caller
22
23
  const cleanup = async () => {
24
+ if (isCleanedUp) return;
25
+ isCleanedUp = true;
23
26
  ui.cleanup();
24
- // The test runner's finally block will handle server cleanup
25
- // We just need to ensure UI cleanup happens
26
- };
27
- const sigintHandler = async () => {
28
- await cleanup();
29
- process.exit(1);
27
+ if (testRunner?.cancel) {
28
+ await testRunner.cancel();
29
+ }
30
30
  };
31
- const exitHandler = () => ui.cleanup();
32
- process.on('SIGINT', sigintHandler);
33
- process.on('exit', exitHandler);
34
31
  try {
35
32
  // Load configuration with CLI overrides
36
33
  const allOptions = {
@@ -45,11 +42,6 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
45
42
  ui.warning('No API token detected - running in local-only mode');
46
43
  }
47
44
 
48
- // Handle --set-baseline flag
49
- if (options.setBaseline) {
50
- ui.info('🐻 Baseline update mode - current screenshots will become new baselines');
51
- }
52
-
53
45
  // Collect git metadata
54
46
  const branch = await detectBranch(options.branch);
55
47
  const commit = await detectCommit(options.commit);
@@ -119,19 +111,20 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
119
111
  });
120
112
 
121
113
  // Show informational messages about baseline behavior
122
- if (config.apiKey) {
123
- 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');
124
120
  } else {
125
121
  ui.warning('Running without API token - all screenshots will be marked as new');
126
122
  }
127
-
128
- // Prepare TDD run options (no uploads, local comparisons only)
129
123
  const runOptions = {
130
124
  testCommand,
131
125
  port: config.server.port,
132
126
  timeout: config.server.timeout,
133
127
  tdd: true,
134
- // Enable TDD mode
135
128
  setBaseline: options.setBaseline || false,
136
129
  // Pass through baseline update mode
137
130
  branch,
@@ -144,8 +137,6 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
144
137
  baselineComparisonId: config.baselineComparisonId,
145
138
  wait: false // No build to wait for in TDD mode
146
139
  };
147
-
148
- // Start TDD test run (local comparisons only)
149
140
  ui.info('Starting TDD test execution...');
150
141
  const result = await testRunner.run(runOptions);
151
142
 
@@ -166,22 +157,31 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {})
166
157
  }
167
158
  ui.success('TDD test run completed');
168
159
 
169
- // Exit with appropriate code based on comparison results
170
- if (result.failed || result.comparisons && result.comparisons.some(c => c.status === 'failed')) {
160
+ // Determine success based on comparison results
161
+ const hasFailures = result.failed || result.comparisons && result.comparisons.some(c => c.status === 'failed');
162
+ if (hasFailures) {
171
163
  ui.error('Visual differences detected in TDD mode', {}, 0);
172
- // Return error status without calling process.exit in tests
173
- return {
174
- success: false,
175
- exitCode: 1
176
- };
177
164
  }
178
- ui.cleanup();
165
+
166
+ // Return result and cleanup function
167
+ return {
168
+ result: {
169
+ success: !hasFailures,
170
+ exitCode: hasFailures ? 1 : 0,
171
+ ...result
172
+ },
173
+ cleanup
174
+ };
179
175
  } catch (error) {
180
176
  ui.error('TDD test run failed', error);
181
- } finally {
182
- // Remove event listeners to prevent memory leaks
183
- process.removeListener('SIGINT', sigintHandler);
184
- process.removeListener('exit', exitHandler);
177
+ return {
178
+ result: {
179
+ success: false,
180
+ exitCode: 1,
181
+ error: error.message
182
+ },
183
+ cleanup
184
+ };
185
185
  }
186
186
  }
187
187
 
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from '../utils/config-loader.js';
2
2
  import { ConsoleUI } from '../utils/console-ui.js';
3
3
  import { createServiceContainer } from '../container/index.js';
4
- import { detectBranch, detectCommit, getCommitMessage, generateBuildNameWithGit } from '../utils/git.js';
4
+ import { detectBranch, detectCommit, detectCommitMessage, detectPullRequestNumber, generateBuildNameWithGit } from '../utils/git.js';
5
5
  import { ApiService } from '../services/api-service.js';
6
6
 
7
7
  /**
@@ -71,8 +71,9 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
71
71
  // Collect git metadata if not provided
72
72
  const branch = await detectBranch(options.branch);
73
73
  const commit = await detectCommit(options.commit);
74
- const message = options.message || (await getCommitMessage());
74
+ const message = options.message || (await detectCommitMessage());
75
75
  const buildName = await generateBuildNameWithGit(options.buildName);
76
+ const pullRequestNumber = detectPullRequestNumber();
76
77
  ui.info(`Uploading screenshots from: ${screenshotsPath}`);
77
78
  if (globalOptions.verbose) {
78
79
  ui.info('Configuration loaded', {
@@ -99,6 +100,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
99
100
  threshold: config.comparison.threshold,
100
101
  uploadAll: options.uploadAll || false,
101
102
  metadata: options.metadata ? JSON.parse(options.metadata) : {},
103
+ pullRequestNumber,
102
104
  onProgress: progressData => {
103
105
  const {
104
106
  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,