@vizzly-testing/cli 0.35.3-beta.3 → 0.36.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
@@ -144,19 +144,19 @@ test('homepage looks correct', async ({ page }) => {
144
144
  fullPage: true,
145
145
  requestTimeout: 5000,
146
146
  properties: {
147
- browser: 'chrome',
148
- viewport: { width: 1920, height: 1080 },
147
+ theme: 'dark',
148
+ locale: 'en-US',
149
149
  },
150
150
  });
151
151
  });
152
152
  ```
153
153
 
154
- `properties` is your metadata bag for baseline grouping, filtering, and
155
- debugging. SDK options such as `threshold`, `minClusterSize`, `fullPage`,
156
- `requestTimeout`, and `buildId` stay at the top level. If one of those reserved
157
- SDK options is accidentally placed inside `properties`, Vizzly removes it from
158
- metadata, applies it as an option when possible, and warns so the call site can
159
- be cleaned up.
154
+ `properties` is your key/value metadata bag for baseline grouping, filtering,
155
+ and debugging. Vizzly passes every property through as user metadata; property
156
+ names are never interpreted as options. The supported top-level options are
157
+ `threshold`, `minClusterSize`, `fullPage`, `requestTimeout`, and `buildId`.
158
+ Vizzly reads width and height from the captured image, so viewport dimensions do
159
+ not need to be included in `properties`.
160
160
 
161
161
  The client SDK is lightweight. It posts screenshots to the local Vizzly server
162
162
  or the cloud build wrapper. It works with any test runner.
package/dist/api/core.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import crypto from 'node:crypto';
9
9
  import { URLSearchParams } from 'node:url';
10
+ import { createScreenshotProperties, getScreenshotOptionsPayload } from '../utils/screenshot-options.js';
10
11
 
11
12
  // ============================================================================
12
13
  // Header Building
@@ -29,14 +30,17 @@ export function buildAuthHeader(token) {
29
30
  * @param {string} version - CLI version
30
31
  * @param {string} command - Command being executed (run, upload, tdd, etc.)
31
32
  * @param {string|null} sdkUserAgent - Optional SDK user agent to append
33
+ * @param {string} nodeVersion - Node.js version to append
32
34
  * @returns {string} Complete User-Agent string
33
35
  */
34
- export function buildUserAgent(version, command, sdkUserAgent = null) {
36
+ export function buildUserAgent(version, command, sdkUserAgent = null, nodeVersion = process.versions.node) {
35
37
  let baseUserAgent = `vizzly-cli/${version} (${command})`;
38
+ let userAgentParts = [baseUserAgent];
36
39
  if (sdkUserAgent) {
37
- return `${baseUserAgent} ${sdkUserAgent}`;
40
+ userAgentParts.push(sdkUserAgent);
38
41
  }
39
- return baseUserAgent;
42
+ if (nodeVersion) userAgentParts.push(`node/${nodeVersion}`);
43
+ return userAgentParts.join(' ');
40
44
  }
41
45
 
42
46
  /**
@@ -73,15 +77,18 @@ export function buildRequestHeaders({
73
77
  * Build payload for screenshot upload or SHA resolution
74
78
  * @param {string} name - Screenshot name
75
79
  * @param {Buffer|null} buffer - Image data, or null when resolving by SHA
76
- * @param {Object} metadata - Screenshot metadata (viewport, browser, etc.)
80
+ * @param {Object} metadata - User screenshot properties
77
81
  * @param {string|null} sha256 - Pre-computed SHA256 hash (optional)
78
82
  * @returns {Object} Screenshot upload payload
79
83
  */
80
- export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null) {
84
+ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null, screenshotOptions = {}) {
81
85
  let payload = {
82
86
  name,
83
- properties: metadata ?? {}
87
+ properties: createScreenshotProperties({
88
+ properties: metadata
89
+ })
84
90
  };
91
+ Object.assign(payload, getScreenshotOptionsPayload(screenshotOptions));
85
92
  if (buffer) {
86
93
  payload.image_data = buffer.toString('base64');
87
94
  }
@@ -94,12 +101,12 @@ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = nul
94
101
  /**
95
102
  * Build payload for server-side screenshot resolution without sending image bytes
96
103
  * @param {string} name - Screenshot name
97
- * @param {Object} metadata - Screenshot metadata (viewport, browser, etc.)
104
+ * @param {Object} metadata - User screenshot properties
98
105
  * @param {string} sha256 - Pre-computed SHA256 hash
99
106
  * @returns {Object} Screenshot resolution payload
100
107
  */
101
- export function buildScreenshotResolvePayload(name, metadata = {}, sha256) {
102
- return buildScreenshotPayload(name, null, metadata, sha256);
108
+ export function buildScreenshotResolvePayload(name, metadata = {}, sha256, screenshotOptions = {}) {
109
+ return buildScreenshotPayload(name, null, metadata, sha256, screenshotOptions);
103
110
  }
104
111
 
105
112
  /**
@@ -180,13 +187,13 @@ export function buildShaCheckPayload(screenshots, buildId) {
180
187
  */
181
188
  export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
182
189
  let meta = metadata || {};
190
+ let properties = createScreenshotProperties({
191
+ properties: meta
192
+ });
183
193
  return {
184
194
  sha256,
185
195
  name,
186
- browser: meta.browser || 'chrome',
187
- viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
188
- viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
189
- properties: meta
196
+ properties
190
197
  };
191
198
  }
192
199
 
@@ -80,13 +80,15 @@ export async function createBuild(client, metadata) {
80
80
  * @param {Object} client - API client
81
81
  * @param {string} buildId - Build ID
82
82
  * @param {string} status - Build status (pending|running|completed|failed)
83
- * @param {number|null} executionTimeMs - Execution time in milliseconds
83
+ * @param {number|null} [executionTimeMs=null] - Execution time in milliseconds
84
+ * @param {string} [failureReason] - Explanation sent only for a failed build
84
85
  * @returns {Promise<Object>} Updated build data
85
86
  */
86
- export async function updateBuildStatus(client, buildId, status, executionTimeMs = null) {
87
+ export async function updateBuildStatus(client, buildId, status, executionTimeMs = null, failureReason) {
87
88
  let body = {
88
89
  status
89
90
  };
91
+ if (status === 'failed' && failureReason) body.failureReason = failureReason;
90
92
  if (executionTimeMs != null) {
91
93
  body.executionTimeMs = executionTimeMs;
92
94
  }
@@ -100,16 +102,17 @@ export async function updateBuildStatus(client, buildId, status, executionTimeMs
100
102
  }
101
103
 
102
104
  /**
103
- * Finalize a build (convenience wrapper for updateBuildStatus)
105
+ * Mark a build completed or failed, with an optional failure explanation.
104
106
  * @param {Object} client - API client
105
107
  * @param {string} buildId - Build ID
106
- * @param {boolean} success - Whether the build succeeded
107
- * @param {number|null} executionTimeMs - Execution time in milliseconds
108
+ * @param {boolean} [success=true] - Whether tests and uploads succeeded
109
+ * @param {number|null} [executionTimeMs=null] - Execution time in milliseconds
110
+ * @param {string} [failureReason] - Explanation sent only for a failed build
108
111
  * @returns {Promise<Object>} Finalized build data
109
112
  */
110
- export async function finalizeBuild(client, buildId, success = true, executionTimeMs = null) {
113
+ export async function finalizeBuild(client, buildId, success = true, executionTimeMs = null, failureReason) {
111
114
  let status = success ? 'completed' : 'failed';
112
- return updateBuildStatus(client, buildId, status, executionTimeMs);
115
+ return updateBuildStatus(client, buildId, status, executionTimeMs, failureReason);
113
116
  }
114
117
 
115
118
  /**
@@ -179,10 +182,10 @@ function canUploadBytesAfterShaResolveError(error) {
179
182
  * @param {boolean} skipDedup - Skip SHA deduplication (uploadAll mode)
180
183
  * @returns {Promise<Object>} Upload result
181
184
  */
182
- export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false) {
185
+ export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false, screenshotOptions = {}) {
183
186
  // Skip SHA deduplication if requested
184
187
  if (skipDedup) {
185
- let payload = buildScreenshotPayload(name, buffer, metadata);
188
+ let payload = buildScreenshotPayload(name, buffer, metadata, null, screenshotOptions);
186
189
  return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
187
190
  method: 'POST',
188
191
  headers: {
@@ -194,7 +197,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
194
197
 
195
198
  // Normal flow with server-side SHA resolution.
196
199
  let sha256 = computeSha256(buffer);
197
- let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256);
200
+ let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256, screenshotOptions);
198
201
  try {
199
202
  let resolveResult = await client.request(`/api/sdk/builds/${buildId}/screenshots`, {
200
203
  method: 'POST',
@@ -222,7 +225,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
222
225
  }
223
226
 
224
227
  // File doesn't exist, or resolve failed; proceed with upload.
225
- let payload = buildScreenshotPayload(name, buffer, metadata, sha256);
228
+ let payload = buildScreenshotPayload(name, buffer, metadata, sha256, screenshotOptions);
226
229
  return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
227
230
  method: 'POST',
228
231
  headers: {
@@ -8,7 +8,7 @@ import { request as httpRequest } from 'node:http';
8
8
  import { request as httpsRequest } from 'node:https';
9
9
  import { dirname, join, parse } from 'node:path';
10
10
  import { getBuildId, getServerUrl, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
11
- import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
11
+ import { getScreenshotOptionsPayload, normalizeScreenshotOptions } from '../utils/screenshot-options.js';
12
12
 
13
13
  // Internal client state
14
14
  let currentClient = null;
@@ -203,9 +203,6 @@ function createSimpleClient(serverUrl, clientOptions = {}) {
203
203
  async screenshot(name, imageBuffer, options = {}) {
204
204
  let normalizedOptions = normalizeScreenshotOptions(options);
205
205
  let requestTimeout = normalizedOptions.requestTimeout || DEFAULT_TIMEOUT_MS;
206
- for (let warning of normalizedOptions.warnings) {
207
- console.warn(`[vizzly] ${warning.message}`);
208
- }
209
206
  try {
210
207
  // If it's a string, assume it's a file path and send directly
211
208
  // Otherwise it's a Buffer, so convert to base64
@@ -219,9 +216,7 @@ function createSimpleClient(serverUrl, clientOptions = {}) {
219
216
  type,
220
217
  properties: normalizedOptions.properties
221
218
  };
222
- if (normalizedOptions.warnings.length > 0) {
223
- screenshotData.warnings = normalizedOptions.warnings;
224
- }
219
+ Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
225
220
  let httpStart = Date.now();
226
221
  let {
227
222
  status,
@@ -1,5 +1,10 @@
1
1
  /**
2
- * Run command implementation
2
+ * Run tests and report their visual results in terminal or JSON output.
3
+ *
4
+ * Incomplete uploads preserve the test exit code and skip comparison waiting.
5
+ * After successful uploads, --wait can still fail for visual comparison results.
6
+ * The returned success flag describes command success, not upload completeness.
7
+ *
3
8
  * Uses functional operations directly - no class wrappers needed
4
9
  */
5
10
 
@@ -16,6 +21,14 @@ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
16
21
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
17
22
  import * as defaultOutput from '../utils/output.js';
18
23
  import { writeSession as defaultWriteSession } from '../utils/session.js';
24
+ function formatUploadCounts({
25
+ total,
26
+ uploaded,
27
+ reused,
28
+ failed
29
+ }) {
30
+ return `${total} captured, ${uploaded} uploaded, ${reused} reused, ${failed} failed`;
31
+ }
19
32
  export async function resolveBuildDisplayUrl({
20
33
  result,
21
34
  config,
@@ -68,7 +81,9 @@ function buildContextCommand(buildId, {
68
81
  * @param {string} testCommand - Test command to execute
69
82
  * @param {Object} options - Command options
70
83
  * @param {Object} globalOptions - Global CLI options
71
- * @param {Object} deps - Dependencies for testing
84
+ * @param {Object} deps - Command services and process hooks.
85
+ * @returns {Promise<Object>} Command outcome with success, an optional exitCode,
86
+ * and run results when available.
72
87
  */
73
88
  export async function runCommand(testCommand, options = {}, globalOptions = {}, deps = {}) {
74
89
  let {
@@ -321,6 +336,33 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
321
336
  if (result.buildId) {
322
337
  buildId = result.buildId;
323
338
  }
339
+ if (result.uploads?.failed > 0) {
340
+ let message = `${result.uploads.failed} screenshot(s) failed to upload. Visual testing is incomplete.`;
341
+ if (globalOptions.json) {
342
+ output.data({
343
+ buildId: result.buildId,
344
+ status: 'incomplete',
345
+ screenshotsCaptured: result.screenshotsCaptured,
346
+ uploads: result.uploads,
347
+ error: {
348
+ code: 'SCREENSHOT_UPLOAD_FAILED',
349
+ message
350
+ },
351
+ url: result.url,
352
+ exitCode: 0
353
+ });
354
+ } else {
355
+ output.warn(message);
356
+ output.print(`Screenshots: ${formatUploadCounts(result.uploads)}`);
357
+ if (result.url) output.info(`Results: ${result.url}`);
358
+ }
359
+ output.cleanup();
360
+ return {
361
+ success: true,
362
+ exitCode: 0,
363
+ result
364
+ };
365
+ }
324
366
 
325
367
  // JSON output mode - output structured data and exit
326
368
  if (globalOptions.json && !runOptions.wait) {
@@ -336,6 +378,9 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
336
378
  status: 'completed',
337
379
  url: displayUrl,
338
380
  screenshotsCaptured: result.screenshotsCaptured || 0,
381
+ ...(result.uploads ? {
382
+ uploads: result.uploads
383
+ } : {}),
339
384
  executionTimeMs,
340
385
  git: {
341
386
  branch,
@@ -362,7 +407,8 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
362
407
  if (result.buildId && !globalOptions.json) {
363
408
  output.blank();
364
409
  let colors = output.getColors();
365
- output.print(` ${colors.brand.textTertiary('Screenshots')} ${colors.white(result.screenshotsCaptured)}`);
410
+ let screenshotSummary = result.uploads ? formatUploadCounts(result.uploads) : result.screenshotsCaptured;
411
+ output.print(` ${colors.brand.textTertiary('Screenshots')} ${colors.white(screenshotSummary)}`);
366
412
  let displayUrl = await resolveBuildDisplayUrl({
367
413
  result,
368
414
  config,
@@ -392,6 +438,9 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
392
438
  output.data({
393
439
  buildId: buildId || null,
394
440
  status: 'failed',
441
+ ...(error.uploads ? {
442
+ uploads: error.uploads
443
+ } : {}),
395
444
  error: {
396
445
  code: error.code,
397
446
  message: error.message
@@ -479,6 +528,9 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
479
528
  status: buildResult.status,
480
529
  url: displayUrl,
481
530
  screenshotsCaptured: result.screenshotsCaptured || 0,
531
+ ...(result.uploads ? {
532
+ uploads: result.uploads
533
+ } : {}),
482
534
  executionTimeMs,
483
535
  git: {
484
536
  branch,