@vizzly-testing/cli 0.35.3-beta.3 → 0.35.3-beta.4

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
@@ -153,10 +153,9 @@ test('homepage looks correct', async ({ page }) => {
153
153
 
154
154
  `properties` is your metadata bag for baseline grouping, filtering, and
155
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.
156
+ `requestTimeout`, and `buildId` stay at the top level. Reserved SDK and capture
157
+ fields inside `properties` are ignored and produce a warning; they are never
158
+ interpreted as options or stored as user metadata.
160
159
 
161
160
  The client SDK is lightweight. It posts screenshots to the local Vizzly server
162
161
  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
@@ -77,11 +78,14 @@ export function buildRequestHeaders({
77
78
  * @param {string|null} sha256 - Pre-computed SHA256 hash (optional)
78
79
  * @returns {Object} Screenshot upload payload
79
80
  */
80
- export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null) {
81
+ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null, screenshotOptions = {}) {
81
82
  let payload = {
82
83
  name,
83
- properties: metadata ?? {}
84
+ properties: createScreenshotProperties({
85
+ properties: metadata
86
+ })
84
87
  };
88
+ Object.assign(payload, getScreenshotOptionsPayload(screenshotOptions));
85
89
  if (buffer) {
86
90
  payload.image_data = buffer.toString('base64');
87
91
  }
@@ -98,8 +102,8 @@ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = nul
98
102
  * @param {string} sha256 - Pre-computed SHA256 hash
99
103
  * @returns {Object} Screenshot resolution payload
100
104
  */
101
- export function buildScreenshotResolvePayload(name, metadata = {}, sha256) {
102
- return buildScreenshotPayload(name, null, metadata, sha256);
105
+ export function buildScreenshotResolvePayload(name, metadata = {}, sha256, screenshotOptions = {}) {
106
+ return buildScreenshotPayload(name, null, metadata, sha256, screenshotOptions);
103
107
  }
104
108
 
105
109
  /**
@@ -180,13 +184,16 @@ export function buildShaCheckPayload(screenshots, buildId) {
180
184
  */
181
185
  export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
182
186
  let meta = metadata || {};
187
+ let properties = createScreenshotProperties({
188
+ properties: meta
189
+ });
183
190
  return {
184
191
  sha256,
185
192
  name,
186
193
  browser: meta.browser || 'chrome',
187
194
  viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
188
195
  viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
189
- properties: meta
196
+ properties
190
197
  };
191
198
  }
192
199
 
@@ -179,10 +179,10 @@ function canUploadBytesAfterShaResolveError(error) {
179
179
  * @param {boolean} skipDedup - Skip SHA deduplication (uploadAll mode)
180
180
  * @returns {Promise<Object>} Upload result
181
181
  */
182
- export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false) {
182
+ export async function uploadScreenshot(client, buildId, name, buffer, metadata = {}, skipDedup = false, screenshotOptions = {}) {
183
183
  // Skip SHA deduplication if requested
184
184
  if (skipDedup) {
185
- let payload = buildScreenshotPayload(name, buffer, metadata);
185
+ let payload = buildScreenshotPayload(name, buffer, metadata, null, screenshotOptions);
186
186
  return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
187
187
  method: 'POST',
188
188
  headers: {
@@ -194,7 +194,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
194
194
 
195
195
  // Normal flow with server-side SHA resolution.
196
196
  let sha256 = computeSha256(buffer);
197
- let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256);
197
+ let resolvePayload = buildScreenshotResolvePayload(name, metadata, sha256, screenshotOptions);
198
198
  try {
199
199
  let resolveResult = await client.request(`/api/sdk/builds/${buildId}/screenshots`, {
200
200
  method: 'POST',
@@ -222,7 +222,7 @@ export async function uploadScreenshot(client, buildId, name, buffer, metadata =
222
222
  }
223
223
 
224
224
  // File doesn't exist, or resolve failed; proceed with upload.
225
- let payload = buildScreenshotPayload(name, buffer, metadata, sha256);
225
+ let payload = buildScreenshotPayload(name, buffer, metadata, sha256, screenshotOptions);
226
226
  return client.request(`/api/sdk/builds/${buildId}/screenshots`, {
227
227
  method: 'POST',
228
228
  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;
@@ -219,6 +219,7 @@ function createSimpleClient(serverUrl, clientOptions = {}) {
219
219
  type,
220
220
  properties: normalizedOptions.properties
221
221
  };
222
+ Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
222
223
  if (normalizedOptions.warnings.length > 0) {
223
224
  screenshotData.warnings = normalizedOptions.warnings;
224
225
  }
@@ -4,6 +4,8 @@
4
4
  * No I/O, no side effects - just data transformations.
5
5
  */
6
6
 
7
+ import { getScreenshotOptionsPayload } from '../utils/screenshot-options.js';
8
+
7
9
  // ============================================================================
8
10
  // Request Validation
9
11
  // ============================================================================
@@ -149,9 +151,11 @@ export function buildServerStoppedMessage() {
149
151
  * @returns {{ name: string, image: string, properties?: Object }}
150
152
  */
151
153
  export function extractScreenshotData(body) {
152
- return {
154
+ let screenshotData = {
153
155
  name: body.name,
154
156
  image: body.image,
155
157
  properties: body.properties
156
158
  };
159
+ Object.assign(screenshotData, getScreenshotOptionsPayload(body));
160
+ return screenshotData;
157
161
  }
package/dist/sdk/index.js CHANGED
@@ -19,7 +19,7 @@ import { createUploader } from '../uploader/index.js';
19
19
  import { loadConfig } from '../utils/config-loader.js';
20
20
  import { resolveImageBuffer } from '../utils/file-helpers.js';
21
21
  import * as output from '../utils/output.js';
22
- import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
22
+ import { getScreenshotOptionsPayload, normalizeScreenshotOptions } from '../utils/screenshot-options.js';
23
23
 
24
24
  /**
25
25
  * Create a new Vizzly instance with custom configuration
@@ -314,6 +314,7 @@ export class VizzlySDK extends EventEmitter {
314
314
  type: 'base64',
315
315
  properties: normalizedOptions.properties
316
316
  };
317
+ Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
317
318
  if (normalizedOptions.warnings.length > 0) {
318
319
  screenshotData.warnings = normalizedOptions.warnings;
319
320
  }
@@ -4,6 +4,7 @@ import { resolve } from 'node:path';
4
4
  import { uploadScreenshot as defaultUploadScreenshot } from '../../api/index.js';
5
5
  import { detectImageInputType } from '../../utils/image-input-detector.js';
6
6
  import * as output from '../../utils/output.js';
7
+ import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
7
8
 
8
9
  /**
9
10
  * API Handler - Non-blocking screenshot upload
@@ -47,8 +48,23 @@ export const createApiHandler = (client, {
47
48
  let vizzlyDisabled = false;
48
49
  let screenshotCount = 0;
49
50
  let uploadPromises = [];
50
- const handleScreenshot = async (buildId, name, image, properties = {}, type, warnings = []) => {
51
+ const handleScreenshot = async (buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
51
52
  let handlerStart = Date.now();
53
+ let normalizedOptions = normalizeScreenshotOptions({
54
+ ...screenshotOptions,
55
+ properties
56
+ });
57
+ properties = normalizedOptions.properties;
58
+ screenshotOptions = {
59
+ ...screenshotOptions,
60
+ threshold: normalizedOptions.threshold,
61
+ minClusterSize: normalizedOptions.minClusterSize,
62
+ fullPage: normalizedOptions.fullPage,
63
+ captureMode: normalizedOptions.captureMode,
64
+ deviceScaleFactor: normalizedOptions.deviceScaleFactor,
65
+ selector: normalizedOptions.selector
66
+ };
67
+ warnings = [...(warnings || []), ...normalizedOptions.warnings];
52
68
  output.debug('upload', `${name} received`, {
53
69
  buildId: buildId?.slice(0, 8)
54
70
  });
@@ -131,7 +147,7 @@ export const createApiHandler = (client, {
131
147
  let uploadStart = Date.now();
132
148
 
133
149
  // Fire upload in background - DON'T AWAIT!
134
- let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}).then(result => {
150
+ let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}, false, screenshotOptions).then(result => {
135
151
  let duration = Date.now() - uploadStart;
136
152
  if (!result.skipped) {
137
153
  output.debug('upload', `${name} completed`, {
@@ -5,6 +5,7 @@ import { getDimensionsSync as defaultGetDimensionsSync } from '@vizzly-testing/h
5
5
  import { TddService as DefaultTddService } from '../../tdd/tdd-service.js';
6
6
  import { detectImageInputType as defaultDetectImageInputType } from '../../utils/image-input-detector.js';
7
7
  import * as defaultOutput from '../../utils/output.js';
8
+ import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
8
9
  import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
9
10
 
10
11
  /**
@@ -305,7 +306,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
305
306
  output.debug('tdd', `baseline: ${baseline.buildName}`);
306
307
  }
307
308
  };
308
- const handleScreenshot = async (_buildId, name, image, properties = {}, type, warnings = []) => {
309
+ const handleScreenshot = async (_buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
309
310
  let handlerStart = Date.now();
310
311
  output.debug('tdd', `${name} received`);
311
312
 
@@ -324,13 +325,18 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
324
325
  };
325
326
  }
326
327
 
327
- // Unwrap double-nested properties if needed (client SDK wraps options in properties field)
328
+ // Preserve the old nested wrapper for older clients, then filter user
329
+ // properties before local comparison/report serialization.
328
330
  let unwrappedProperties = unwrapProperties(properties);
331
+ let normalizedProperties = normalizeScreenshotOptions({
332
+ properties: unwrappedProperties
333
+ });
334
+ warnings = [...(warnings || []), ...normalizedProperties.warnings];
329
335
 
330
336
  // Validate and sanitize properties
331
337
  let validatedProperties;
332
338
  try {
333
- validatedProperties = validateScreenshotProperties(unwrappedProperties);
339
+ validatedProperties = validateScreenshotProperties(normalizedProperties.properties);
334
340
  } catch (error) {
335
341
  return {
336
342
  statusCode: 400,
@@ -419,7 +425,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
419
425
 
420
426
  // Use the sanitized name as-is (no modification with browser/viewport)
421
427
  // Baseline matching uses signature logic (name + viewport_width + browser)
422
- const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties);
428
+ const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties, screenshotOptions);
423
429
 
424
430
  // Comparison tracked by tdd.js event handler
425
431
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import * as output from '../../utils/output.js';
7
+ import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
7
8
  import { parseJsonBody } from '../middleware/json-parser.js';
8
9
  import { sendError, sendJson } from '../middleware/response.js';
9
10
 
@@ -33,7 +34,13 @@ export function createScreenshotRouter({
33
34
  properties,
34
35
  image,
35
36
  type,
36
- warnings
37
+ warnings,
38
+ threshold,
39
+ minClusterSize,
40
+ fullPage,
41
+ captureMode,
42
+ deviceScaleFactor,
43
+ selector
37
44
  } = body;
38
45
  if (!name || !image) {
39
46
  sendError(res, 400, 'name and image are required');
@@ -42,7 +49,23 @@ export function createScreenshotRouter({
42
49
 
43
50
  // Use buildId from request body, or fall back to server's buildId
44
51
  const effectiveBuildId = buildId || defaultBuildId;
45
- const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, properties, type, warnings);
52
+ const normalizedOptions = normalizeScreenshotOptions({
53
+ properties,
54
+ threshold,
55
+ minClusterSize,
56
+ fullPage,
57
+ captureMode,
58
+ deviceScaleFactor,
59
+ selector
60
+ });
61
+ const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, normalizedOptions.properties, type, [...(warnings || []), ...normalizedOptions.warnings], {
62
+ threshold: normalizedOptions.threshold,
63
+ minClusterSize: normalizedOptions.minClusterSize,
64
+ fullPage: normalizedOptions.fullPage,
65
+ captureMode: normalizedOptions.captureMode,
66
+ deviceScaleFactor: normalizedOptions.deviceScaleFactor,
67
+ selector: normalizedOptions.selector
68
+ });
46
69
  sendJson(res, result.statusCode, result.body);
47
70
  return true;
48
71
  } catch (error) {
@@ -752,7 +752,7 @@ export class TddService {
752
752
  /**
753
753
  * Compare a screenshot against baseline
754
754
  */
755
- async compareScreenshot(name, imageBuffer, properties = {}) {
755
+ async compareScreenshot(name, imageBuffer, properties = {}, screenshotOptions = {}) {
756
756
  // Destructure dependencies
757
757
  let {
758
758
  output,
@@ -850,8 +850,8 @@ export class TddService {
850
850
  }
851
851
 
852
852
  // Baseline exists - compare
853
- let effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
854
- let effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
853
+ let effectiveThreshold = typeof screenshotOptions.threshold === 'number' && screenshotOptions.threshold >= 0 ? screenshotOptions.threshold : this.threshold;
854
+ let effectiveMinClusterSize = Number.isInteger(screenshotOptions.minClusterSize) && screenshotOptions.minClusterSize >= 1 ? screenshotOptions.minClusterSize : this.minClusterSize;
855
855
  try {
856
856
  let honeydiffResult = await compareImages(baselineImagePath, currentImagePath, diffImagePath, {
857
857
  threshold: effectiveThreshold,
@@ -401,7 +401,11 @@ export interface TddService {
401
401
  compareScreenshot(
402
402
  name: string,
403
403
  imageBuffer: Buffer,
404
- properties?: Record<string, unknown>
404
+ properties?: Record<string, unknown>,
405
+ screenshotOptions?: Pick<
406
+ ScreenshotOptions,
407
+ 'threshold' | 'minClusterSize' | 'fullPage'
408
+ >
405
409
  ): Promise<ComparisonResult>;
406
410
 
407
411
  getResults(): TddResults;
@@ -9,16 +9,62 @@ export let RESERVED_PROPERTY_OPTIONS = Object.freeze({
9
9
  minClusterSize: {
10
10
  message: 'Move "minClusterSize" out of properties; properties is only for user metadata.'
11
11
  },
12
+ min_cluster_size: {
13
+ message: 'Move "min_cluster_size" out of properties; properties is only for user metadata.'
14
+ },
12
15
  fullPage: {
13
16
  message: 'Move "fullPage" out of properties; properties is only for user metadata.'
14
17
  },
18
+ full_page: {
19
+ message: 'Move "full_page" out of properties; properties is only for user metadata.'
20
+ },
21
+ captureMode: {
22
+ message: 'Move "captureMode" out of properties; properties is only for user metadata.'
23
+ },
24
+ capture_mode: {
25
+ message: 'Move "capture_mode" out of properties; properties is only for user metadata.'
26
+ },
27
+ deviceScaleFactor: {
28
+ message: 'Move "deviceScaleFactor" out of properties; properties is only for user metadata.'
29
+ },
30
+ device_scale_factor: {
31
+ message: 'Move "device_scale_factor" out of properties; properties is only for user metadata.'
32
+ },
33
+ pixelRatio: {
34
+ message: 'Move "pixelRatio" out of properties; properties is only for user metadata.'
35
+ },
36
+ dpr: {
37
+ message: 'Move "dpr" out of properties; properties is only for user metadata.'
38
+ },
39
+ selector: {
40
+ message: 'Move "selector" out of properties; properties is only for user metadata.'
41
+ },
42
+ component: {
43
+ message: 'Move "component" out of properties; properties is only for user metadata.'
44
+ },
45
+ elementSelector: {
46
+ message: 'Move "elementSelector" out of properties; properties is only for user metadata.'
47
+ },
48
+ element_selector: {
49
+ message: 'Move "element_selector" out of properties; properties is only for user metadata.'
50
+ },
15
51
  buildId: {
16
52
  message: 'Move "buildId" out of properties; properties is only for user metadata.'
17
53
  },
54
+ build_id: {
55
+ message: 'Move "build_id" out of properties; properties is only for user metadata.'
56
+ },
18
57
  requestTimeout: {
19
58
  message: 'Move "requestTimeout" out of properties; properties is only for user metadata.'
59
+ },
60
+ request_timeout: {
61
+ message: 'Move "request_timeout" out of properties; properties is only for user metadata.'
20
62
  }
21
63
  });
64
+ export let SCREENSHOT_OPTION_NAMES = Object.freeze(['threshold', 'minClusterSize', 'fullPage', 'captureMode', 'deviceScaleFactor', 'selector']);
65
+ export function getScreenshotOptionsPayload(options = {}) {
66
+ return Object.fromEntries(SCREENSHOT_OPTION_NAMES.filter(option => options[option] !== undefined).map(option => [option, options[option]]));
67
+ }
22
68
  function createReservedPropertyWarning(option) {
23
69
  return {
24
70
  code: 'reserved-property-option',
@@ -28,8 +74,9 @@ function createReservedPropertyWarning(option) {
28
74
  }
29
75
 
30
76
  /**
31
- * Normalize screenshot SDK options into the properties payload consumed by the
32
- * local TDD server and cloud-compatible comparison path.
77
+ * Normalize screenshot SDK options into the local and cloud upload payload.
78
+ * Reserved names found inside properties are discarded instead of being promoted
79
+ * into options.
33
80
  */
34
81
  export function normalizeScreenshotOptions(options = {}) {
35
82
  let {
@@ -38,38 +85,30 @@ export function normalizeScreenshotOptions(options = {}) {
38
85
  requestTimeout,
39
86
  threshold,
40
87
  minClusterSize,
41
- fullPage
88
+ fullPage,
89
+ captureMode,
90
+ deviceScaleFactor,
91
+ selector
42
92
  } = options;
43
93
  let warnings = [];
44
94
  let normalizedProperties = {};
45
- for (let [key, value] of Object.entries(properties || {})) {
95
+ let sourceProperties = properties && typeof properties === 'object' && !Array.isArray(properties) ? properties : {};
96
+ for (let [key, value] of Object.entries(sourceProperties)) {
46
97
  if (RESERVED_PROPERTY_OPTIONS[key]) {
47
98
  warnings.push(createReservedPropertyWarning(key));
48
- if (key === 'threshold' && threshold === undefined) threshold = value;
49
- if (key === 'minClusterSize' && minClusterSize === undefined) {
50
- minClusterSize = value;
51
- }
52
- if (key === 'fullPage' && fullPage === undefined) fullPage = value;
53
- if (key === 'buildId' && buildId === undefined) buildId = value;
54
- if (key === 'requestTimeout' && requestTimeout === undefined) {
55
- requestTimeout = value;
56
- }
57
99
  continue;
58
100
  }
59
101
  normalizedProperties[key] = value;
60
102
  }
61
- if (threshold !== undefined) {
62
- normalizedProperties.threshold = threshold;
63
- }
64
- if (minClusterSize !== undefined) {
65
- normalizedProperties.minClusterSize = minClusterSize;
66
- }
67
- if (fullPage !== undefined) {
68
- normalizedProperties.fullPage = fullPage;
69
- }
70
103
  return {
71
104
  buildId,
72
105
  requestTimeout,
106
+ threshold,
107
+ minClusterSize,
108
+ fullPage,
109
+ captureMode,
110
+ deviceScaleFactor,
111
+ selector,
73
112
  properties: normalizedProperties,
74
113
  warnings
75
114
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.35.3-beta.3",
3
+ "version": "0.35.3-beta.4",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",