@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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * CI Environment Detection
3
+ *
4
+ * Generic functions to extract git and PR information from any CI provider
5
+ */
6
+
7
+ /**
8
+ * Get the branch name from CI environment variables
9
+ * @returns {string|null} Branch name or null if not available
10
+ */
11
+ export function getBranch() {
12
+ return process.env.VIZZLY_BRANCH ||
13
+ // Vizzly override
14
+ process.env.GITHUB_HEAD_REF ||
15
+ // GitHub Actions (for PRs)
16
+ process.env.GITHUB_REF_NAME ||
17
+ // GitHub Actions (for pushes)
18
+ process.env.CI_COMMIT_REF_NAME ||
19
+ // GitLab CI
20
+ process.env.CIRCLE_BRANCH ||
21
+ // CircleCI
22
+ process.env.TRAVIS_BRANCH ||
23
+ // Travis CI
24
+ process.env.BUILDKITE_BRANCH ||
25
+ // Buildkite
26
+ process.env.DRONE_BRANCH ||
27
+ // Drone CI
28
+ process.env.BRANCH_NAME ||
29
+ // Jenkins
30
+ process.env.GIT_BRANCH ||
31
+ // Jenkins (alternative)
32
+ process.env.BITBUCKET_BRANCH ||
33
+ // Bitbucket Pipelines
34
+ process.env.WERCKER_GIT_BRANCH ||
35
+ // Wercker
36
+ process.env.APPVEYOR_REPO_BRANCH ||
37
+ // AppVeyor
38
+ process.env.BUILD_SOURCEBRANCH?.replace(/^refs\/heads\//, '') ||
39
+ // Azure DevOps
40
+ process.env.CODEBUILD_WEBHOOK_HEAD_REF?.replace(/^refs\/heads\//, '') ||
41
+ // AWS CodeBuild
42
+ process.env.SEMAPHORE_GIT_BRANCH ||
43
+ // Semaphore
44
+ null;
45
+ }
46
+
47
+ /**
48
+ * Get the commit SHA from CI environment variables
49
+ * @returns {string|null} Commit SHA or null if not available
50
+ */
51
+ export function getCommit() {
52
+ return process.env.VIZZLY_COMMIT_SHA ||
53
+ // Vizzly override
54
+ process.env.GITHUB_SHA ||
55
+ // GitHub Actions
56
+ process.env.CI_COMMIT_SHA ||
57
+ // GitLab CI
58
+ process.env.CIRCLE_SHA1 ||
59
+ // CircleCI
60
+ process.env.TRAVIS_COMMIT ||
61
+ // Travis CI
62
+ process.env.BUILDKITE_COMMIT ||
63
+ // Buildkite
64
+ process.env.DRONE_COMMIT_SHA ||
65
+ // Drone CI
66
+ process.env.GIT_COMMIT ||
67
+ // Jenkins
68
+ process.env.BITBUCKET_COMMIT ||
69
+ // Bitbucket Pipelines
70
+ process.env.WERCKER_GIT_COMMIT ||
71
+ // Wercker
72
+ process.env.APPVEYOR_REPO_COMMIT ||
73
+ // AppVeyor
74
+ process.env.BUILD_SOURCEVERSION ||
75
+ // Azure DevOps
76
+ process.env.CODEBUILD_RESOLVED_SOURCE_VERSION ||
77
+ // AWS CodeBuild
78
+ process.env.SEMAPHORE_GIT_SHA ||
79
+ // Semaphore
80
+ process.env.HEROKU_TEST_RUN_COMMIT_VERSION ||
81
+ // Heroku CI
82
+ process.env.COMMIT_SHA ||
83
+ // Generic
84
+ process.env.HEAD_COMMIT ||
85
+ // Alternative generic
86
+ process.env.SHA ||
87
+ // Another generic option
88
+ null;
89
+ }
90
+
91
+ /**
92
+ * Get the commit message from CI environment variables
93
+ * @returns {string|null} Commit message or null if not available
94
+ */
95
+ export function getCommitMessage() {
96
+ return process.env.VIZZLY_COMMIT_MESSAGE ||
97
+ // Vizzly override
98
+ process.env.CI_COMMIT_MESSAGE ||
99
+ // GitLab CI
100
+ process.env.TRAVIS_COMMIT_MESSAGE ||
101
+ // Travis CI
102
+ process.env.BUILDKITE_MESSAGE ||
103
+ // Buildkite
104
+ process.env.DRONE_COMMIT_MESSAGE ||
105
+ // Drone CI
106
+ process.env.APPVEYOR_REPO_COMMIT_MESSAGE ||
107
+ // AppVeyor
108
+ process.env.COMMIT_MESSAGE ||
109
+ // Generic
110
+ null;
111
+ }
112
+
113
+ /**
114
+ * Get the pull request number from CI environment variables
115
+ * @returns {number|null} PR number or null if not available/not a PR
116
+ */
117
+ export function getPullRequestNumber() {
118
+ // Check VIZZLY override first
119
+ if (process.env.VIZZLY_PR_NUMBER) {
120
+ return parseInt(process.env.VIZZLY_PR_NUMBER, 10);
121
+ }
122
+
123
+ // GitHub Actions - extract from GITHUB_REF
124
+ if (process.env.GITHUB_ACTIONS && process.env.GITHUB_EVENT_NAME === 'pull_request') {
125
+ const prMatch = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/);
126
+ if (prMatch?.[1]) return parseInt(prMatch[1], 10);
127
+ }
128
+
129
+ // GitLab CI
130
+ if (process.env.CI_MERGE_REQUEST_ID) {
131
+ return parseInt(process.env.CI_MERGE_REQUEST_ID, 10);
132
+ }
133
+
134
+ // CircleCI - extract from PR URL
135
+ if (process.env.CIRCLE_PULL_REQUEST) {
136
+ const prMatch = process.env.CIRCLE_PULL_REQUEST.match(/\/pull\/(\d+)$/);
137
+ if (prMatch?.[1]) return parseInt(prMatch[1], 10);
138
+ }
139
+
140
+ // Travis CI
141
+ if (process.env.TRAVIS_PULL_REQUEST && process.env.TRAVIS_PULL_REQUEST !== 'false') {
142
+ return parseInt(process.env.TRAVIS_PULL_REQUEST, 10);
143
+ }
144
+
145
+ // Buildkite
146
+ if (process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false') {
147
+ return parseInt(process.env.BUILDKITE_PULL_REQUEST, 10);
148
+ }
149
+
150
+ // Drone CI
151
+ if (process.env.DRONE_PULL_REQUEST) {
152
+ return parseInt(process.env.DRONE_PULL_REQUEST, 10);
153
+ }
154
+
155
+ // Jenkins (GitHub Pull Request Builder plugin)
156
+ if (process.env.ghprbPullId) {
157
+ return parseInt(process.env.ghprbPullId, 10);
158
+ }
159
+
160
+ // Azure DevOps
161
+ if (process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
162
+ return parseInt(process.env.SYSTEM_PULLREQUEST_PULLREQUESTID, 10);
163
+ }
164
+
165
+ // AppVeyor
166
+ if (process.env.APPVEYOR_PULL_REQUEST_NUMBER) {
167
+ return parseInt(process.env.APPVEYOR_PULL_REQUEST_NUMBER, 10);
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Get the PR head SHA from CI environment variables
174
+ * @returns {string|null} PR head SHA or null if not available
175
+ */
176
+ export function getPullRequestHeadSha() {
177
+ return process.env.VIZZLY_PR_HEAD_SHA ||
178
+ // Vizzly override
179
+ process.env.GITHUB_SHA ||
180
+ // GitHub Actions
181
+ process.env.CI_COMMIT_SHA ||
182
+ // GitLab CI
183
+ process.env.CIRCLE_SHA1 ||
184
+ // CircleCI
185
+ process.env.TRAVIS_COMMIT ||
186
+ // Travis CI
187
+ process.env.BUILDKITE_COMMIT ||
188
+ // Buildkite
189
+ process.env.DRONE_COMMIT_SHA ||
190
+ // Drone CI
191
+ process.env.ghprbActualCommit ||
192
+ // Jenkins
193
+ process.env.GIT_COMMIT ||
194
+ // Jenkins fallback
195
+ process.env.BUILD_SOURCEVERSION ||
196
+ // Azure DevOps
197
+ process.env.APPVEYOR_REPO_COMMIT ||
198
+ // AppVeyor
199
+ null;
200
+ }
201
+
202
+ /**
203
+ * Get the PR base SHA from CI environment variables
204
+ * @returns {string|null} PR base SHA or null if not available
205
+ */
206
+ export function getPullRequestBaseSha() {
207
+ return process.env.VIZZLY_PR_BASE_SHA ||
208
+ // Vizzly override
209
+ process.env.CI_MERGE_REQUEST_TARGET_BRANCH_SHA ||
210
+ // GitLab CI
211
+ null // Most CIs don't provide this
212
+ ;
213
+ }
214
+
215
+ /**
216
+ * Get the PR head ref (branch) from CI environment variables
217
+ * @returns {string|null} PR head ref or null if not available
218
+ */
219
+ export function getPullRequestHeadRef() {
220
+ return process.env.VIZZLY_PR_HEAD_REF ||
221
+ // Vizzly override
222
+ process.env.GITHUB_HEAD_REF ||
223
+ // GitHub Actions
224
+ process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ||
225
+ // GitLab CI
226
+ process.env.TRAVIS_PULL_REQUEST_BRANCH ||
227
+ // Travis CI
228
+ process.env.DRONE_SOURCE_BRANCH ||
229
+ // Drone CI
230
+ process.env.ghprbSourceBranch ||
231
+ // Jenkins
232
+ process.env.SYSTEM_PULLREQUEST_SOURCEBRANCH?.replace(/^refs\/heads\//, '') ||
233
+ // Azure DevOps
234
+ process.env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH ||
235
+ // AppVeyor
236
+ null;
237
+ }
238
+
239
+ /**
240
+ * Get the PR base ref (target branch) from CI environment variables
241
+ * @returns {string|null} PR base ref or null if not available
242
+ */
243
+ export function getPullRequestBaseRef() {
244
+ return process.env.VIZZLY_PR_BASE_REF ||
245
+ // Vizzly override
246
+ process.env.GITHUB_BASE_REF ||
247
+ // GitHub Actions
248
+ process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ||
249
+ // GitLab CI
250
+ process.env.TRAVIS_BRANCH ||
251
+ // Travis CI (target branch)
252
+ process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH ||
253
+ // Buildkite
254
+ process.env.DRONE_TARGET_BRANCH ||
255
+ // Drone CI
256
+ process.env.ghprbTargetBranch ||
257
+ // Jenkins
258
+ process.env.SYSTEM_PULLREQUEST_TARGETBRANCH?.replace(/^refs\/heads\//, '') ||
259
+ // Azure DevOps
260
+ process.env.APPVEYOR_REPO_BRANCH ||
261
+ // AppVeyor (target branch)
262
+ null;
263
+ }
264
+
265
+ /**
266
+ * Check if we're currently in a pull request context
267
+ * @returns {boolean} True if in a PR context
268
+ */
269
+ export function isPullRequest() {
270
+ return getPullRequestNumber() !== null;
271
+ }
272
+
273
+ /**
274
+ * Get the CI provider name
275
+ * @returns {string} CI provider name or 'unknown'
276
+ */
277
+ export function getCIProvider() {
278
+ if (process.env.GITHUB_ACTIONS) return 'github-actions';
279
+ if (process.env.GITLAB_CI) return 'gitlab-ci';
280
+ if (process.env.CIRCLECI) return 'circleci';
281
+ if (process.env.TRAVIS) return 'travis-ci';
282
+ if (process.env.BUILDKITE) return 'buildkite';
283
+ if (process.env.DRONE) return 'drone-ci';
284
+ if (process.env.JENKINS_URL) return 'jenkins';
285
+ if (process.env.AZURE_HTTP_USER_AGENT || process.env.TF_BUILD) return 'azure-devops';
286
+ if (process.env.CODEBUILD_BUILD_ID) return 'aws-codebuild';
287
+ if (process.env.APPVEYOR) return 'appveyor';
288
+ if (process.env.SEMAPHORE) return 'semaphore';
289
+ if (process.env.WERCKER) return 'wercker';
290
+ if (process.env.BITBUCKET_BUILD_NUMBER) return 'bitbucket-pipelines';
291
+ if (process.env.HEROKU_TEST_RUN_ID) return 'heroku-ci';
292
+ return 'unknown';
293
+ }
@@ -25,6 +25,10 @@ const DEFAULT_CONFIG = {
25
25
  // Comparison Configuration
26
26
  comparison: {
27
27
  threshold: 0.1
28
+ },
29
+ // TDD Configuration
30
+ tdd: {
31
+ openReport: false // Whether to auto-open HTML report in browser
28
32
  }
29
33
  };
30
34
  export async function loadConfig(configPath = null, cliOverrides = {}) {
@@ -42,6 +46,9 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
42
46
  },
43
47
  comparison: {
44
48
  ...DEFAULT_CONFIG.comparison
49
+ },
50
+ tdd: {
51
+ ...DEFAULT_CONFIG.tdd
45
52
  }
46
53
  };
47
54
 
package/dist/utils/git.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
+ import { getBranch as getCIBranch, getCommit as getCICommit, getCommitMessage as getCICommitMessage, getPullRequestNumber } from './ci-env.js';
3
4
  const execAsync = promisify(exec);
4
5
  export async function getCommonAncestor(commit1, commit2, cwd = process.cwd()) {
5
6
  try {
@@ -144,6 +145,23 @@ export async function getCommitMessage(cwd = process.cwd()) {
144
145
  }
145
146
  }
146
147
 
148
+ /**
149
+ * Detect commit message with override and environment variable support
150
+ * @param {string} override - Commit message override from CLI
151
+ * @param {string} cwd - Working directory
152
+ * @returns {Promise<string|null>} Commit message or null if not available
153
+ */
154
+ export async function detectCommitMessage(override = null, cwd = process.cwd()) {
155
+ if (override) return override;
156
+
157
+ // Try CI environment variables first
158
+ const ciCommitMessage = getCICommitMessage();
159
+ if (ciCommitMessage) return ciCommitMessage;
160
+
161
+ // Fallback to regular git log
162
+ return await getCommitMessage(cwd);
163
+ }
164
+
147
165
  /**
148
166
  * Check if the working directory is a git repository
149
167
  * @param {string} cwd - Working directory
@@ -193,6 +211,12 @@ export async function getGitStatus(cwd = process.cwd()) {
193
211
  */
194
212
  export async function detectBranch(override = null, cwd = process.cwd()) {
195
213
  if (override) return override;
214
+
215
+ // Try CI environment variables first
216
+ const ciBranch = getCIBranch();
217
+ if (ciBranch) return ciBranch;
218
+
219
+ // Fallback to git command when no CI environment variables
196
220
  const currentBranch = await getCurrentBranch(cwd);
197
221
  return currentBranch || 'unknown';
198
222
  }
@@ -205,6 +229,12 @@ export async function detectBranch(override = null, cwd = process.cwd()) {
205
229
  */
206
230
  export async function detectCommit(override = null, cwd = process.cwd()) {
207
231
  if (override) return override;
232
+
233
+ // Try CI environment variables first
234
+ const ciCommit = getCICommit();
235
+ if (ciCommit) return ciCommit;
236
+
237
+ // Fallback to git command when no CI environment variables
208
238
  return await getCurrentCommitSha(cwd);
209
239
  }
210
240
 
@@ -223,4 +253,12 @@ export async function generateBuildNameWithGit(override = null, cwd = process.cw
223
253
  return `${branch}-${shortCommit}`;
224
254
  }
225
255
  return generateBuildName();
256
+ }
257
+
258
+ /**
259
+ * Detect pull request number from CI environment
260
+ * @returns {number|null} Pull request number or null if not in PR context
261
+ */
262
+ export function detectPullRequestNumber() {
263
+ return getPullRequestNumber();
226
264
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Security utilities for path sanitization and validation
3
+ * Protects against path traversal attacks and ensures safe file operations
4
+ */
5
+
6
+ import { resolve, normalize, isAbsolute, join } from 'path';
7
+ import { createServiceLogger } from './logger-factory.js';
8
+ const logger = createServiceLogger('SECURITY');
9
+
10
+ /**
11
+ * Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
12
+ * @param {string} name - Original screenshot name
13
+ * @param {number} maxLength - Maximum allowed length (default: 255)
14
+ * @returns {string} Sanitized screenshot name
15
+ */
16
+ export function sanitizeScreenshotName(name, maxLength = 255) {
17
+ if (typeof name !== 'string' || name.length === 0) {
18
+ throw new Error('Screenshot name must be a non-empty string');
19
+ }
20
+ if (name.length > maxLength) {
21
+ throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
22
+ }
23
+
24
+ // Block directory traversal patterns
25
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
26
+ throw new Error('Screenshot name contains invalid path characters');
27
+ }
28
+
29
+ // Block absolute paths
30
+ if (isAbsolute(name)) {
31
+ throw new Error('Screenshot name cannot be an absolute path');
32
+ }
33
+
34
+ // Allow only safe characters: alphanumeric, hyphens, underscores, and dots
35
+ // Replace other characters with underscores
36
+ let sanitized = name.replace(/[^a-zA-Z0-9._-]/g, '_');
37
+
38
+ // Prevent names that start with dots (hidden files)
39
+ if (sanitized.startsWith('.')) {
40
+ sanitized = 'file_' + sanitized;
41
+ }
42
+
43
+ // Ensure we have a valid filename
44
+ if (sanitized.length === 0 || sanitized === '.' || sanitized === '..') {
45
+ sanitized = 'unnamed_screenshot';
46
+ }
47
+ return sanitized;
48
+ }
49
+
50
+ /**
51
+ * Validates that a path stays within the allowed working directory bounds
52
+ * @param {string} targetPath - Path to validate
53
+ * @param {string} workingDir - Working directory that serves as the security boundary
54
+ * @returns {string} Resolved and normalized path if valid
55
+ * @throws {Error} If path is invalid or outside bounds
56
+ */
57
+ export function validatePathSecurity(targetPath, workingDir) {
58
+ if (typeof targetPath !== 'string' || targetPath.length === 0) {
59
+ throw new Error('Path must be a non-empty string');
60
+ }
61
+ if (typeof workingDir !== 'string' || workingDir.length === 0) {
62
+ throw new Error('Working directory must be a non-empty string');
63
+ }
64
+
65
+ // Normalize and resolve both paths
66
+ let resolvedWorkingDir = resolve(normalize(workingDir));
67
+ let resolvedTargetPath = resolve(normalize(targetPath));
68
+
69
+ // Ensure the target path starts with the working directory
70
+ if (!resolvedTargetPath.startsWith(resolvedWorkingDir)) {
71
+ logger.warn(`Path traversal attempt blocked: ${targetPath} (resolved: ${resolvedTargetPath}) is outside working directory: ${resolvedWorkingDir}`);
72
+ throw new Error('Path is outside the allowed working directory');
73
+ }
74
+ return resolvedTargetPath;
75
+ }
76
+
77
+ /**
78
+ * Safely constructs a path within the working directory
79
+ * @param {string} workingDir - Base working directory
80
+ * @param {...string} pathSegments - Path segments to join
81
+ * @returns {string} Safely constructed path
82
+ * @throws {Error} If resulting path would be outside working directory
83
+ */
84
+ export function safePath(workingDir, ...pathSegments) {
85
+ if (pathSegments.length === 0) {
86
+ return validatePathSecurity(workingDir, workingDir);
87
+ }
88
+
89
+ // Sanitize each path segment
90
+ let sanitizedSegments = pathSegments.map(segment => {
91
+ if (typeof segment !== 'string') {
92
+ throw new Error('Path segment must be a string');
93
+ }
94
+
95
+ // Block directory traversal in segments
96
+ if (segment.includes('..')) {
97
+ throw new Error('Path segment contains directory traversal sequence');
98
+ }
99
+ return segment;
100
+ });
101
+ let targetPath = join(workingDir, ...sanitizedSegments);
102
+ return validatePathSecurity(targetPath, workingDir);
103
+ }
104
+
105
+ /**
106
+ * Validates screenshot properties object for safe values
107
+ * @param {Object} properties - Properties to validate
108
+ * @returns {Object} Validated properties object
109
+ */
110
+ export function validateScreenshotProperties(properties = {}) {
111
+ if (properties === null || typeof properties !== 'object') {
112
+ return {};
113
+ }
114
+ let validated = {};
115
+
116
+ // Validate common properties with safe constraints
117
+ if (properties.browser && typeof properties.browser === 'string') {
118
+ try {
119
+ validated.browser = sanitizeScreenshotName(properties.browser, 50);
120
+ } catch (error) {
121
+ // Skip invalid browser names, don't include them
122
+ logger.warn(`Invalid browser name '${properties.browser}': ${error.message}`);
123
+ }
124
+ }
125
+ if (properties.viewport && typeof properties.viewport === 'object') {
126
+ let viewport = {};
127
+ if (typeof properties.viewport.width === 'number' && properties.viewport.width > 0 && properties.viewport.width <= 10000) {
128
+ viewport.width = Math.floor(properties.viewport.width);
129
+ }
130
+ if (typeof properties.viewport.height === 'number' && properties.viewport.height > 0 && properties.viewport.height <= 10000) {
131
+ viewport.height = Math.floor(properties.viewport.height);
132
+ }
133
+ if (Object.keys(viewport).length > 0) {
134
+ validated.viewport = viewport;
135
+ }
136
+ }
137
+
138
+ // Allow other safe string properties but sanitize them
139
+ for (let [key, value] of Object.entries(properties)) {
140
+ if (key === 'browser' || key === 'viewport') continue; // Already handled
141
+
142
+ if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
143
+ if (typeof value === 'string' && value.length <= 200) {
144
+ // Store sanitized version of string values
145
+ validated[key] = value.replace(/[<>&"']/g, ''); // Basic HTML entity prevention
146
+ } else if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
147
+ validated[key] = value;
148
+ } else if (typeof value === 'boolean') {
149
+ validated[key] = value;
150
+ }
151
+ }
152
+ }
153
+ return validated;
154
+ }
@@ -481,14 +481,29 @@ Configuration loaded via cosmiconfig in this order:
481
481
 
482
482
  ### Environment Variables
483
483
 
484
+ **Core Configuration:**
484
485
  - `VIZZLY_TOKEN` - API authentication token
485
486
  - `VIZZLY_API_URL` - API base URL override
486
487
  - `VIZZLY_LOG_LEVEL` - Logger level (`debug`, `info`, `warn`, `error`)
488
+
489
+ **Git Information Override (CI/CD Enhancement):**
490
+ - `VIZZLY_COMMIT_SHA` - Override detected commit SHA
491
+ - `VIZZLY_COMMIT_MESSAGE` - Override detected commit message
492
+ - `VIZZLY_BRANCH` - Override detected branch name
493
+ - `VIZZLY_PR_NUMBER` - Override detected pull request number
494
+
495
+ **Runtime (Set by CLI):**
487
496
  - `VIZZLY_SERVER_URL` - Screenshot server URL (set by CLI)
488
497
  - `VIZZLY_ENABLED` - Enable/disable client (set by CLI)
489
498
  - `VIZZLY_BUILD_ID` - Current build ID (set by CLI)
490
499
  - `VIZZLY_TDD_MODE` - TDD mode active (set by CLI)
491
500
 
501
+ **Priority Order for Git Information:**
502
+ 1. CLI arguments (`--commit`, `--branch`, `--message`)
503
+ 2. `VIZZLY_*` environment variables
504
+ 3. CI-specific environment variables (e.g., `GITHUB_SHA`, `CI_COMMIT_SHA`)
505
+ 4. Git command detection
506
+
492
507
  ## Error Handling
493
508
 
494
509
  ### Client Errors