@vizzly-testing/cli 0.33.0 → 0.35.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 (37) hide show
  1. package/README.md +53 -3
  2. package/dist/api/core.js +2 -1
  3. package/dist/cli.js +21 -8
  4. package/dist/client/index.js +62 -21
  5. package/dist/commands/api.js +9 -3
  6. package/dist/commands/init.js +214 -2
  7. package/dist/commands/preview.js +13 -11
  8. package/dist/commands/project.js +1 -1
  9. package/dist/commands/run.js +18 -6
  10. package/dist/commands/tdd-daemon.js +8 -3
  11. package/dist/commands/tdd.js +24 -4
  12. package/dist/commands/upload.js +31 -13
  13. package/dist/reporter/reporter-bundle.iife.js +1 -1
  14. package/dist/sdk/index.js +23 -9
  15. package/dist/server/handlers/api-handler.js +3 -2
  16. package/dist/server/handlers/tdd-handler.js +5 -1
  17. package/dist/server/routers/screenshot.js +11 -4
  18. package/dist/services/build-manager.js +6 -1
  19. package/dist/test-runner/core.js +4 -1
  20. package/dist/test-runner/operations.js +2 -1
  21. package/dist/types/client.d.ts +32 -5
  22. package/dist/types/index.d.ts +33 -5
  23. package/dist/uploader/core.js +41 -9
  24. package/dist/uploader/operations.js +19 -17
  25. package/dist/utils/config-loader.js +12 -6
  26. package/dist/utils/context.js +46 -76
  27. package/dist/utils/environment-config.js +20 -0
  28. package/dist/utils/output.js +5 -5
  29. package/dist/utils/screenshot-options.js +60 -10
  30. package/package.json +2 -1
  31. package/skills/vizzly/SKILL.md +99 -0
  32. package/skills/vizzly/agents/openai.yaml +4 -0
  33. package/skills/vizzly/references/cli-context.md +42 -0
  34. package/skills/vizzly/references/dynamic-content.md +44 -0
  35. package/skills/vizzly/references/public-screenshots.md +44 -0
  36. package/skills/vizzly/references/sdks.md +62 -0
  37. package/skills/vizzly/references/setup-ci.md +42 -0
package/README.md CHANGED
@@ -28,6 +28,16 @@ pnpm install -g @vizzly-testing/cli
28
28
  vizzly init
29
29
  ```
30
30
 
31
+ For agent-friendly repos, install the Vizzly skill and add a short project
32
+ `AGENTS.md` note:
33
+
34
+ ```bash
35
+ vizzly init --agent-guidance
36
+ ```
37
+
38
+ Use `vizzly init --agent-skill` to install only the local skill, or
39
+ `vizzly init --skip-agent-skill` when you want config without the agent prompt.
40
+
31
41
  ### Start Local TDD
32
42
 
33
43
  Start the TDD server, run your tests, and open the dashboard at
@@ -47,9 +57,14 @@ Use cloud builds when you want shared baselines, team review, and CI status:
47
57
 
48
58
  ```bash
49
59
  vizzly login
60
+ vizzly project link your-org/your-project
50
61
  vizzly run "pnpm test" --wait
51
62
  ```
52
63
 
64
+ `vizzly login` authenticates your user account. `vizzly project link` creates a
65
+ project-scoped upload credential for this checkout, which `vizzly run` uses for
66
+ cloud uploads.
67
+
53
68
  `--wait` blocks until Vizzly finishes processing the build. It exits with code
54
69
  `1` when visual differences need review.
55
70
 
@@ -104,12 +119,23 @@ test('homepage looks correct', async ({ page }) => {
104
119
 
105
120
  let screenshot = await page.screenshot();
106
121
  await vizzlyScreenshot('homepage', screenshot, {
107
- browser: 'chrome',
108
- viewport: '1920x1080'
122
+ fullPage: true,
123
+ requestTimeout: 5000,
124
+ properties: {
125
+ browser: 'chrome',
126
+ viewport: { width: 1920, height: 1080 },
127
+ },
109
128
  });
110
129
  });
111
130
  ```
112
131
 
132
+ `properties` is your metadata bag for baseline grouping, filtering, and
133
+ debugging. SDK options such as `threshold`, `minClusterSize`, `fullPage`,
134
+ `requestTimeout`, and `buildId` stay at the top level. If one of those reserved
135
+ SDK options is accidentally placed inside `properties`, Vizzly removes it from
136
+ metadata, applies it as an option when possible, and warns so the call site can
137
+ be cleaned up.
138
+
113
139
  The client SDK is lightweight. It posts screenshots to the local Vizzly server
114
140
  or the cloud build wrapper. It works with any test runner.
115
141
 
@@ -129,9 +155,26 @@ await vizzlyScreenshot('homepage', './screenshots/homepage.png');
129
155
  Or upload an existing folder of screenshots:
130
156
 
131
157
  ```bash
132
- vizzly upload ./screenshots
158
+ vizzly upload ./screenshots --threshold 2 --min-cluster-size 4 --batch-size 10 --upload-timeout 60000
133
159
  ```
134
160
 
161
+ `--batch-size` controls how many screenshots are uploaded per request.
162
+ `--upload-timeout` controls the upload client's timeout, including how long
163
+ `--wait` polls for build processing.
164
+
165
+ CI workflows can force every screenshot through even when the SHA cache says it
166
+ already uploaded. Parallel jobs should use the same stable ID, then finalize
167
+ that ID after every shard completes:
168
+
169
+ ```bash
170
+ vizzly run "pnpm test" --upload-all --parallel-id "$GITHUB_RUN_ID"
171
+ vizzly finalize "$GITHUB_RUN_ID"
172
+ ```
173
+
174
+ For smoke jobs where cloud credentials are intentionally unavailable, add
175
+ `--allow-no-token` to `vizzly run` and Vizzly will keep the local screenshot
176
+ server path working without creating a cloud build.
177
+
135
178
  ## Configuration
136
179
 
137
180
  Generate a config file:
@@ -140,6 +183,13 @@ Generate a config file:
140
183
  vizzly init
141
184
  ```
142
185
 
186
+ To teach project agents about Vizzly screenshot memory and the local visual TDD
187
+ loop, add the repo-local skill and AGENTS.md guidance:
188
+
189
+ ```bash
190
+ vizzly init --agent-guidance
191
+ ```
192
+
143
193
  Or create `vizzly.config.js` manually:
144
194
 
145
195
  ```javascript
package/dist/api/core.js CHANGED
@@ -172,7 +172,8 @@ export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
172
172
  name,
173
173
  browser: meta.browser || 'chrome',
174
174
  viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
175
- viewport_height: meta.viewport?.height || meta.viewport_height || 1080
175
+ viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
176
+ properties: meta
176
177
  };
177
178
  }
178
179
 
package/dist/cli.js CHANGED
@@ -207,6 +207,7 @@ const formatHelp = (cmd, helper) => {
207
207
  };
208
208
  function extractGlobalOptionsFromArgv(argv, commandNames = null) {
209
209
  let configPath = null;
210
+ let tokenArg = null;
210
211
  let verboseMode = false;
211
212
  let logLevelArg = null;
212
213
  let jsonArg = null;
@@ -214,6 +215,11 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
214
215
  if ((argv[i] === '-c' || argv[i] === '--config') && argv[i + 1]) {
215
216
  configPath = argv[i + 1];
216
217
  }
218
+ if (argv[i] === '--token' && argv[i + 1]) {
219
+ tokenArg = argv[i + 1];
220
+ } else if (argv[i].startsWith('--token=')) {
221
+ tokenArg = argv[i].substring('--token='.length);
222
+ }
217
223
  if (argv[i] === '-v' || argv[i] === '--verbose') {
218
224
  verboseMode = true;
219
225
  }
@@ -234,6 +240,7 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
234
240
  }
235
241
  return {
236
242
  configPath,
243
+ tokenArg,
237
244
  verboseMode,
238
245
  logLevelArg,
239
246
  jsonArg
@@ -260,6 +267,7 @@ program.name('vizzly').description('Vizzly CLI for visual regression testing').v
260
267
  // We need to manually parse to get the config option early
261
268
  let {
262
269
  configPath,
270
+ tokenArg,
263
271
  verboseMode,
264
272
  logLevelArg,
265
273
  jsonArg
@@ -280,9 +288,11 @@ output.configure({
280
288
  color: colorOverride,
281
289
  json: jsonArg
282
290
  });
283
- const config = await loadConfig(configPath, {});
284
- const services = createServices(config);
285
- const pluginServices = createPluginServices(services);
291
+ let config = await loadConfig(configPath, {
292
+ token: tokenArg
293
+ });
294
+ let services = createServices(config);
295
+ let pluginServices = createPluginServices(services);
286
296
  let plugins = [];
287
297
  try {
288
298
  plugins = await loadPlugins(configPath, config);
@@ -303,16 +313,16 @@ try {
303
313
  } catch (error) {
304
314
  output.debug(`Plugin loading failed: ${error.message}`);
305
315
  }
306
- program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
307
- const globalOptions = program.opts();
316
+ program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').option('--agent-skill', 'Install the repo-local Vizzly agent skill').option('--agent-guidance', 'Add Vizzly guidance to this project AGENTS.md and install the agent skill').option('--skip-agent-skill', 'Skip the Vizzly agent skill prompt').action(async options => {
317
+ let globalOptions = program.opts();
308
318
  await init({
309
319
  ...globalOptions,
310
320
  ...options,
311
321
  plugins
312
322
  });
313
323
  });
314
- 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', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).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', Number).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) => {
315
- const globalOptions = program.opts();
324
+ program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).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', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).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) => {
325
+ let globalOptions = program.opts();
316
326
 
317
327
  // Validate options
318
328
  const validationErrors = validateUploadOptions(path, options);
@@ -323,7 +333,10 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
323
333
  }
324
334
  process.exit(1);
325
335
  }
326
- await uploadCommand(path, options, globalOptions);
336
+ let result = await uploadCommand(path, options, globalOptions);
337
+ if (result?.exitCode) {
338
+ process.exit(result.exitCode);
339
+ }
327
340
  });
328
341
 
329
342
  // TDD command with subcommands - local visual review with an interactive dashboard
@@ -8,22 +8,27 @@ 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 { createScreenshotProperties } from '../utils/screenshot-options.js';
11
+ import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
12
12
 
13
13
  // Internal client state
14
14
  let currentClient = null;
15
+ let currentServerUrl = null;
15
16
  let isDisabled = false;
17
+ let currentFailOnDiff = false;
16
18
 
17
19
  // Default timeout for screenshot requests (30 seconds)
18
20
  let DEFAULT_TIMEOUT_MS = 30000;
19
21
 
20
22
  // Log levels for client SDK output control
21
- export let LOG_LEVELS = {
23
+ export let LOG_LEVELS = Object.freeze({
22
24
  debug: 0,
23
25
  info: 1,
24
26
  warn: 2,
25
27
  error: 3
26
- };
28
+ });
29
+ function getEnvFailOnDiff(env = process.env) {
30
+ return env.VIZZLY_FAIL_ON_DIFF === 'true' || env.VIZZLY_FAIL_ON_DIFF === '1';
31
+ }
27
32
 
28
33
  /**
29
34
  * Check if client should log at the given level
@@ -56,6 +61,7 @@ function isVizzlyDisabled() {
56
61
  function disableVizzly() {
57
62
  isDisabled = true;
58
63
  currentClient = null;
64
+ currentServerUrl = null;
59
65
  }
60
66
 
61
67
  /**
@@ -67,7 +73,8 @@ function disableVizzly() {
67
73
  export function autoDiscoverTddServer(startDir, deps = {}) {
68
74
  let {
69
75
  exists = existsSync,
70
- readFile = readFileSync
76
+ readFile = readFileSync,
77
+ env = process.env
71
78
  } = deps;
72
79
  try {
73
80
  // Look for .vizzly/server.json in current directory and parent directories
@@ -80,6 +87,7 @@ export function autoDiscoverTddServer(startDir, deps = {}) {
80
87
  let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
81
88
  if (serverInfo.port) {
82
89
  let url = `http://localhost:${serverInfo.port}`;
90
+ currentFailOnDiff = getEnvFailOnDiff(env) || Boolean(serverInfo.failOnDiff);
83
91
  return url;
84
92
  }
85
93
  } catch {
@@ -104,6 +112,7 @@ function getClient() {
104
112
  }
105
113
  if (!currentClient) {
106
114
  let serverUrl = getServerUrl();
115
+ currentFailOnDiff = getEnvFailOnDiff();
107
116
 
108
117
  // Auto-detect local TDD server and enable Vizzly if TDD server is found
109
118
  if (!serverUrl) {
@@ -116,7 +125,10 @@ function getClient() {
116
125
 
117
126
  // If we have a server URL, create the client (regardless of initial enabled state)
118
127
  if (serverUrl) {
119
- currentClient = createSimpleClient(serverUrl);
128
+ currentServerUrl = serverUrl;
129
+ currentClient = createSimpleClient(serverUrl, {
130
+ failOnDiff: currentFailOnDiff
131
+ });
120
132
  }
121
133
  }
122
134
  return currentClient;
@@ -183,28 +195,38 @@ function httpPost(url, body, timeoutMs) {
183
195
  * Create a simple HTTP client for screenshots
184
196
  * @private
185
197
  */
186
- function createSimpleClient(serverUrl) {
198
+ function createSimpleClient(serverUrl, clientOptions = {}) {
199
+ let {
200
+ failOnDiff = false
201
+ } = clientOptions;
187
202
  return {
188
203
  async screenshot(name, imageBuffer, options = {}) {
204
+ let normalizedOptions = normalizeScreenshotOptions(options);
205
+ let requestTimeout = normalizedOptions.requestTimeout || DEFAULT_TIMEOUT_MS;
206
+ for (let warning of normalizedOptions.warnings) {
207
+ console.warn(`[vizzly] ${warning.message}`);
208
+ }
189
209
  try {
190
210
  // If it's a string, assume it's a file path and send directly
191
211
  // Otherwise it's a Buffer, so convert to base64
192
212
  let isFilePath = typeof imageBuffer === 'string';
193
213
  let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
194
214
  let type = isFilePath ? 'file-path' : 'base64';
195
- let requestTimeout = options.requestTimeout || DEFAULT_TIMEOUT_MS;
196
- let properties = createScreenshotProperties(options);
215
+ let screenshotData = {
216
+ buildId: normalizedOptions.buildId ?? getBuildId(),
217
+ name,
218
+ image,
219
+ type,
220
+ properties: normalizedOptions.properties
221
+ };
222
+ if (normalizedOptions.warnings.length > 0) {
223
+ screenshotData.warnings = normalizedOptions.warnings;
224
+ }
197
225
  let httpStart = Date.now();
198
226
  let {
199
227
  status,
200
228
  json
201
- } = await httpPost(`${serverUrl}/screenshot`, {
202
- buildId: getBuildId(),
203
- name,
204
- image,
205
- type,
206
- properties
207
- }, requestTimeout);
229
+ } = await httpPost(`${serverUrl}/screenshot`, screenshotData, requestTimeout);
208
230
  let httpMs = Date.now() - httpStart;
209
231
  if (shouldLogClient('debug')) {
210
232
  console.debug(`[vizzly-client] ${name} HTTP completed in ${httpMs}ms`);
@@ -215,6 +237,9 @@ function createSimpleClient(serverUrl) {
215
237
  // The summary will show all failures at the end
216
238
  if (status === 422 && json.tddMode && json.comparison) {
217
239
  let comp = json.comparison;
240
+ if (failOnDiff) {
241
+ throw new Error(`Visual diff detected for "${comp.name}" (${comp.diffPercentage ?? 0}% difference)`);
242
+ }
218
243
 
219
244
  // Return success so test continues and captures remaining screenshots
220
245
  // Visual diff details will be shown in the summary after tests complete
@@ -227,12 +252,15 @@ function createSimpleClient(serverUrl) {
227
252
  }
228
253
  throw new Error(`Screenshot failed: ${status} - ${json.error || 'Unknown error'}`);
229
254
  }
255
+ if (failOnDiff && json?.tddMode && ['diff', 'failed'].includes(json.status)) {
256
+ throw new Error(`Visual diff detected for "${json.name || name}" (${json.diffPercentage ?? 0}% difference)`);
257
+ }
230
258
  return json;
231
259
  } catch (error) {
232
260
  // Handle timeout
233
261
  if (error.message === 'Request timeout') {
234
262
  if (shouldLogClient('error')) {
235
- console.error(`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`);
263
+ console.error(`[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s`);
236
264
  }
237
265
  disableVizzly();
238
266
  return null;
@@ -308,7 +336,7 @@ function createSimpleClient(serverUrl) {
308
336
  * await vizzlyScreenshot('checkout-form', screenshot, {
309
337
  * properties: {
310
338
  * browser: 'chrome',
311
- * viewport: '1920x1080'
339
+ * viewport: { width: 1920, height: 1080 }
312
340
  * },
313
341
  * threshold: 5
314
342
  * });
@@ -366,8 +394,20 @@ export function isVizzlyReady() {
366
394
  * @param {boolean} [config.enabled] - Enable/disable screenshots
367
395
  */
368
396
  export function configure(config = {}) {
397
+ if ('failOnDiff' in config) {
398
+ currentFailOnDiff = config.failOnDiff === true;
399
+ } else if ('serverUrl' in config) {
400
+ currentFailOnDiff = getEnvFailOnDiff();
401
+ }
369
402
  if ('serverUrl' in config) {
370
- currentClient = config.serverUrl ? createSimpleClient(config.serverUrl) : null;
403
+ currentServerUrl = config.serverUrl || null;
404
+ currentClient = config.serverUrl ? createSimpleClient(config.serverUrl, {
405
+ failOnDiff: currentFailOnDiff
406
+ }) : null;
407
+ } else if ('failOnDiff' in config && currentClient && currentServerUrl) {
408
+ currentClient = createSimpleClient(currentServerUrl, {
409
+ failOnDiff: currentFailOnDiff
410
+ });
371
411
  }
372
412
  if (typeof config.enabled === 'boolean') {
373
413
  setVizzlyEnabled(config.enabled);
@@ -397,10 +437,11 @@ export function getVizzlyInfo() {
397
437
  let client = getClient();
398
438
  return {
399
439
  enabled: !isVizzlyDisabled(),
400
- serverUrl: getServerUrl(),
440
+ serverUrl: currentServerUrl || getServerUrl() || null,
401
441
  ready: !isVizzlyDisabled() && client !== null,
402
- buildId: getBuildId(),
442
+ buildId: getBuildId() || null,
403
443
  tddMode: isTddMode(),
404
- disabled: isVizzlyDisabled()
444
+ disabled: isVizzlyDisabled(),
445
+ failOnDiff: currentFailOnDiff
405
446
  };
406
447
  }
@@ -65,7 +65,8 @@ export function appendApiQuery(endpoint, queryOption) {
65
65
  }
66
66
  export function validateApiRequest({
67
67
  endpoint,
68
- method
68
+ method,
69
+ hasData = false
69
70
  }) {
70
71
  let errors = [];
71
72
  if (method !== 'GET' && method !== 'POST') {
@@ -75,6 +76,9 @@ export function validateApiRequest({
75
76
  if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
76
77
  errors.push(`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`);
77
78
  }
79
+ if (hasData && method !== 'POST') {
80
+ errors.push('Request data requires --method POST.');
81
+ }
78
82
  return errors;
79
83
  }
80
84
  export function buildApiRequest({
@@ -85,7 +89,8 @@ export function buildApiRequest({
85
89
  let method = normalizeApiMethod(options.method || 'GET');
86
90
  let errors = validateApiRequest({
87
91
  endpoint: normalizedEndpoint,
88
- method
92
+ method,
93
+ hasData: options.data !== undefined
89
94
  });
90
95
  if (errors.length > 0) {
91
96
  return {
@@ -239,7 +244,8 @@ export function validateApiOptions(endpoint, options = {}) {
239
244
  let method = normalizeApiMethod(options.method || 'GET');
240
245
  errors.push(...validateApiRequest({
241
246
  endpoint: normalizedEndpoint,
242
- method
247
+ method,
248
+ hasData: options.data !== undefined
243
249
  }));
244
250
  return errors;
245
251
  }