@vizzly-testing/cli 0.34.0 → 0.35.1

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 +67 -7
  2. package/dist/api/core.js +18 -4
  3. package/dist/api/endpoints.js +38 -15
  4. package/dist/api/index.js +1 -1
  5. package/dist/cli.js +270 -194
  6. package/dist/client/index.js +62 -21
  7. package/dist/commands/api.js +9 -3
  8. package/dist/commands/preview.js +30 -26
  9. package/dist/commands/project.js +1 -1
  10. package/dist/commands/run.js +25 -10
  11. package/dist/commands/status.js +14 -1
  12. package/dist/commands/tdd-daemon-process.js +5 -1
  13. package/dist/commands/tdd-daemon.js +217 -139
  14. package/dist/commands/tdd.js +26 -5
  15. package/dist/commands/upload.js +31 -13
  16. package/dist/reporter/reporter-bundle.iife.js +6 -6
  17. package/dist/sdk/index.js +23 -9
  18. package/dist/server/handlers/api-handler.js +3 -2
  19. package/dist/server/handlers/tdd-handler.js +5 -1
  20. package/dist/server/routers/screenshot.js +11 -4
  21. package/dist/services/build-manager.js +6 -1
  22. package/dist/tdd/tdd-service.js +6 -2
  23. package/dist/test-runner/core.js +8 -3
  24. package/dist/test-runner/operations.js +19 -3
  25. package/dist/types/client.d.ts +32 -5
  26. package/dist/types/index.d.ts +33 -5
  27. package/dist/uploader/core.js +41 -9
  28. package/dist/uploader/operations.js +19 -17
  29. package/dist/utils/config-loader.js +13 -6
  30. package/dist/utils/context.js +46 -76
  31. package/dist/utils/environment-config.js +20 -0
  32. package/dist/utils/output.js +8 -5
  33. package/dist/utils/screenshot-options.js +60 -10
  34. package/package.json +1 -1
  35. package/skills/vizzly/SKILL.md +12 -4
  36. package/skills/vizzly/references/cli-context.md +8 -2
  37. package/skills/vizzly/references/setup-ci.md +3 -1
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { cosmiconfigSync } from 'cosmiconfig';
3
3
  import { CONFIG_DEFAULTS, deepMerge } from '../config/core.js';
4
4
  import { validateVizzlyConfigWithDefaults } from './config-schema.js';
5
- import { getApiToken, getApiUrl, getBuildName, getParallelId } from './environment-config.js';
5
+ import { getApiToken, getApiUrl, getBuildName, getMinClusterSize, getParallelId, getThreshold } from './environment-config.js';
6
6
  import { getAccessToken } from './global-config.js';
7
7
  import * as output from './output.js';
8
8
  import { getActiveProjectLink } from './project-link-store.js';
@@ -26,10 +26,12 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
26
26
  mergeConfig(config, validatedFileConfig);
27
27
 
28
28
  // 3. Override with environment variables (higher priority than fallbacks)
29
- const envApiKey = getApiToken();
30
- const envApiUrl = getApiUrl();
31
- const envBuildName = getBuildName();
32
- const envParallelId = getParallelId();
29
+ let envApiKey = getApiToken();
30
+ let envApiUrl = getApiUrl();
31
+ let envBuildName = getBuildName();
32
+ let envParallelId = getParallelId();
33
+ let envThreshold = getThreshold();
34
+ let envMinClusterSize = getMinClusterSize();
33
35
  if (envApiKey) {
34
36
  config.apiKey = envApiKey;
35
37
  output.debug('config', 'using token from environment');
@@ -40,6 +42,10 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
40
42
  output.debug('config', 'using build name from environment');
41
43
  }
42
44
  if (envParallelId) config.parallelId = envParallelId;
45
+ if (envThreshold !== undefined) config.comparison.threshold = envThreshold;
46
+ if (envMinClusterSize !== undefined) {
47
+ config.comparison.minClusterSize = envMinClusterSize;
48
+ }
43
49
 
44
50
  // 4. Apply CLI overrides (highest priority)
45
51
  if (cliOverrides.token) {
@@ -76,6 +82,7 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
76
82
  config.userToken = userToken;
77
83
  output.debug('config', 'using user login for user-authenticated commands');
78
84
  }
85
+ config._configPath = result?.filepath || null;
79
86
  return config;
80
87
  }
81
88
 
@@ -109,7 +116,7 @@ function applyCLIOverrides(config, cliOverrides = {}) {
109
116
  }
110
117
 
111
118
  // Comparison overrides
112
- if (cliOverrides.threshold !== undefined) config.comparison.threshold = cliOverrides.threshold;
119
+ if (cliOverrides.threshold !== undefined) config.comparison.threshold = Number(cliOverrides.threshold);
113
120
  if (cliOverrides.minClusterSize !== undefined) {
114
121
  config.comparison.minClusterSize = Number(cliOverrides.minClusterSize);
115
122
  }
@@ -11,6 +11,37 @@
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { join } from 'node:path';
14
+ function getGlobalConfigPath() {
15
+ return join(process.env.VIZZLY_HOME || join(homedir(), '.vizzly'), 'config.json');
16
+ }
17
+ function readJsonFile(path) {
18
+ try {
19
+ if (existsSync(path)) {
20
+ return JSON.parse(readFileSync(path, 'utf8'));
21
+ }
22
+ } catch {
23
+ // Optional context should never block the command that is rendering it.
24
+ }
25
+ return null;
26
+ }
27
+ function readGlobalConfig() {
28
+ return readJsonFile(getGlobalConfigPath()) || {};
29
+ }
30
+ function readBaselineInfo(cwd) {
31
+ let path = join(cwd, '.vizzly', 'baselines');
32
+ let metadata = readJsonFile(join(path, 'metadata.json'));
33
+ return {
34
+ count: metadata?.screenshots?.length || 0,
35
+ path: metadata ? path : null
36
+ };
37
+ }
38
+ function readServerInfo(cwd) {
39
+ let serverInfo = readJsonFile(join(cwd, '.vizzly', 'server.json'));
40
+ return {
41
+ running: !!serverInfo,
42
+ port: serverInfo ? serverInfo.port : null
43
+ };
44
+ }
14
45
 
15
46
  /**
16
47
  * Get dynamic context about the current Vizzly state
@@ -22,46 +53,12 @@ export function getContext() {
22
53
  let items = [];
23
54
  try {
24
55
  let cwd = process.cwd();
25
- let globalConfigPath = join(process.env.VIZZLY_HOME || join(homedir(), '.vizzly'), 'config.json');
26
-
27
- // Load global config once
28
- let globalConfig = {};
29
- try {
30
- if (existsSync(globalConfigPath)) {
31
- globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf8'));
32
- }
33
- } catch {
34
- // Ignore
35
- }
56
+ let globalConfig = readGlobalConfig();
36
57
 
37
58
  // Check for vizzly.config.js (project config)
38
59
  let hasProjectConfig = existsSync(join(cwd, 'vizzly.config.js'));
39
-
40
- // Check for .vizzly directory (TDD baselines)
41
- let baselineCount = 0;
42
- try {
43
- let metaPath = join(cwd, '.vizzly', 'baselines', 'metadata.json');
44
- if (existsSync(metaPath)) {
45
- let meta = JSON.parse(readFileSync(metaPath, 'utf8'));
46
- baselineCount = meta.screenshots?.length || 0;
47
- }
48
- } catch {
49
- // Ignore
50
- }
51
-
52
- // Check for TDD server running
53
- let serverRunning = false;
54
- let serverPort = null;
55
- try {
56
- let serverFile = join(cwd, '.vizzly', 'server.json');
57
- if (existsSync(serverFile)) {
58
- let serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
59
- serverPort = serverInfo.port;
60
- serverRunning = true;
61
- }
62
- } catch {
63
- // Ignore
64
- }
60
+ let baselineInfo = readBaselineInfo(cwd);
61
+ let serverInfo = readServerInfo(cwd);
65
62
 
66
63
  // Check for OAuth login (from vizzly login)
67
64
  let isLoggedIn = !!globalConfig.auth?.accessToken;
@@ -71,11 +68,11 @@ export function getContext() {
71
68
  let hasEnvToken = !!process.env.VIZZLY_TOKEN;
72
69
 
73
70
  // Build context items - prioritize most useful info
74
- if (serverRunning) {
71
+ if (serverInfo.running) {
75
72
  items.push({
76
73
  type: 'success',
77
74
  label: 'TDD Server',
78
- value: `running on :${serverPort}`
75
+ value: `running on :${serverInfo.port}`
79
76
  });
80
77
  }
81
78
  if (isLoggedIn && userName) {
@@ -97,14 +94,14 @@ export function getContext() {
97
94
  value: 'run vizzly login'
98
95
  });
99
96
  }
100
- if (baselineCount > 0) {
97
+ if (baselineInfo.count > 0) {
101
98
  items.push({
102
99
  type: 'success',
103
100
  label: 'Baselines',
104
- value: `${baselineCount} screenshots`
101
+ value: `${baselineInfo.count} screenshots`
105
102
  });
106
103
  }
107
- if (!hasProjectConfig && !serverRunning && baselineCount === 0) {
104
+ if (!hasProjectConfig && !serverInfo.running && baselineInfo.count === 0) {
108
105
  // Only show "no config" hint if there's nothing else useful
109
106
  items.push({
110
107
  type: 'info',
@@ -126,7 +123,6 @@ export function getContext() {
126
123
  */
127
124
  export function getDetailedContext() {
128
125
  let cwd = process.cwd();
129
- let globalConfigPath = join(process.env.VIZZLY_HOME || join(homedir(), '.vizzly'), 'config.json');
130
126
  let context = {
131
127
  tddServer: {
132
128
  running: false,
@@ -146,42 +142,16 @@ export function getDetailedContext() {
146
142
  }
147
143
  };
148
144
  try {
149
- // Load global config
150
- let globalConfig = {};
151
- try {
152
- if (existsSync(globalConfigPath)) {
153
- globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf8'));
154
- }
155
- } catch {
156
- // Ignore
157
- }
145
+ let globalConfig = readGlobalConfig();
158
146
 
159
147
  // Check for vizzly.config.js
160
148
  context.project.hasConfig = existsSync(join(cwd, 'vizzly.config.js'));
161
-
162
- // Check for baselines
163
- try {
164
- let metaPath = join(cwd, '.vizzly', 'baselines', 'metadata.json');
165
- if (existsSync(metaPath)) {
166
- let meta = JSON.parse(readFileSync(metaPath, 'utf8'));
167
- context.baselines.count = meta.screenshots?.length || 0;
168
- context.baselines.path = join(cwd, '.vizzly', 'baselines');
169
- }
170
- } catch {
171
- // Ignore
172
- }
173
-
174
- // Check for TDD server
175
- try {
176
- let serverFile = join(cwd, '.vizzly', 'server.json');
177
- if (existsSync(serverFile)) {
178
- let serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
179
- context.tddServer.running = true;
180
- context.tddServer.port = serverInfo.port;
181
- }
182
- } catch {
183
- // Ignore
184
- }
149
+ let baselineInfo = readBaselineInfo(cwd);
150
+ context.baselines.count = baselineInfo.count;
151
+ context.baselines.path = baselineInfo.path;
152
+ let serverInfo = readServerInfo(cwd);
153
+ context.tddServer.running = serverInfo.running;
154
+ context.tddServer.port = serverInfo.port;
185
155
 
186
156
  // Check auth status
187
157
  context.auth.loggedIn = !!globalConfig.auth?.accessToken;
@@ -85,6 +85,24 @@ export function getBuildName() {
85
85
  return process.env.VIZZLY_BUILD_NAME;
86
86
  }
87
87
 
88
+ /**
89
+ * Get comparison threshold from environment
90
+ * @returns {number|undefined} Threshold value when set
91
+ */
92
+ export function getThreshold() {
93
+ if (process.env.VIZZLY_THRESHOLD === undefined) return undefined;
94
+ return Number(process.env.VIZZLY_THRESHOLD);
95
+ }
96
+
97
+ /**
98
+ * Get minimum changed cluster size from environment
99
+ * @returns {number|undefined} Minimum cluster size when set
100
+ */
101
+ export function getMinClusterSize() {
102
+ if (process.env.VIZZLY_MIN_CLUSTER_SIZE === undefined) return undefined;
103
+ return Number(process.env.VIZZLY_MIN_CLUSTER_SIZE);
104
+ }
105
+
88
106
  /**
89
107
  * Check if TDD mode is enabled
90
108
  * @returns {boolean} Whether TDD mode is enabled
@@ -117,6 +135,8 @@ export function getAllEnvironmentConfig() {
117
135
  buildId: getBuildId(),
118
136
  buildName: getBuildName(),
119
137
  parallelId: getParallelId(),
138
+ threshold: getThreshold(),
139
+ minClusterSize: getMinClusterSize(),
120
140
  tddMode: isTddMode()
121
141
  };
122
142
  }
@@ -232,6 +232,9 @@ export function getLogLevel() {
232
232
  export function isVerbose() {
233
233
  return config.logLevel === 'debug';
234
234
  }
235
+ export function isJson() {
236
+ return config.json;
237
+ }
235
238
 
236
239
  /**
237
240
  * Show command header with distinctive branding
@@ -282,7 +285,7 @@ export function success(message, data = {}) {
282
285
  });
283
286
  if (config.silent) return;
284
287
  if (config.json) {
285
- console.log(JSON.stringify({
288
+ console.error(JSON.stringify({
286
289
  status: 'success',
287
290
  message,
288
291
  ...data
@@ -301,7 +304,7 @@ export function result(message) {
301
304
  if (config.silent) return;
302
305
  const elapsed = getElapsedTime();
303
306
  if (config.json) {
304
- console.log(JSON.stringify({
307
+ console.error(JSON.stringify({
305
308
  status: 'complete',
306
309
  message,
307
310
  elapsed
@@ -319,7 +322,7 @@ export function info(message, data = {}) {
319
322
  writeLog('info', message, data);
320
323
  if (!shouldLog('info')) return;
321
324
  if (config.json) {
322
- console.log(JSON.stringify({
325
+ console.error(JSON.stringify({
323
326
  status: 'info',
324
327
  message,
325
328
  ...data
@@ -513,7 +516,7 @@ export function stopSpinner() {
513
516
  export function progress(message, current = 0, total = 0) {
514
517
  if (config.silent) return;
515
518
  if (config.json) {
516
- console.log(JSON.stringify({
519
+ console.error(JSON.stringify({
517
520
  status: 'progress',
518
521
  message,
519
522
  progress: {
@@ -941,7 +944,7 @@ export function complete(message, options = {}) {
941
944
  } = options;
942
945
  let detailStr = detail ? ` ${colors.brand.textMuted(detail)}` : '';
943
946
  if (config.json) {
944
- console.log(JSON.stringify({
947
+ console.error(JSON.stringify({
945
948
  status: 'complete',
946
949
  message,
947
950
  detail
@@ -1,21 +1,63 @@
1
+ /**
2
+ * Screenshot option names that are part of the SDK/config contract, not the
3
+ * user's arbitrary metadata bag.
4
+ */
5
+ export let RESERVED_PROPERTY_OPTIONS = Object.freeze({
6
+ threshold: {
7
+ message: 'Move "threshold" out of properties; properties is only for user metadata.'
8
+ },
9
+ minClusterSize: {
10
+ message: 'Move "minClusterSize" out of properties; properties is only for user metadata.'
11
+ },
12
+ fullPage: {
13
+ message: 'Move "fullPage" out of properties; properties is only for user metadata.'
14
+ },
15
+ buildId: {
16
+ message: 'Move "buildId" out of properties; properties is only for user metadata.'
17
+ },
18
+ requestTimeout: {
19
+ message: 'Move "requestTimeout" out of properties; properties is only for user metadata.'
20
+ }
21
+ });
22
+ function createReservedPropertyWarning(option) {
23
+ return {
24
+ code: 'reserved-property-option',
25
+ option,
26
+ message: RESERVED_PROPERTY_OPTIONS[option].message
27
+ };
28
+ }
29
+
1
30
  /**
2
31
  * Normalize screenshot SDK options into the properties payload consumed by the
3
32
  * local TDD server and cloud-compatible comparison path.
4
33
  */
5
- export function createScreenshotProperties(options = {}) {
34
+ export function normalizeScreenshotOptions(options = {}) {
6
35
  let {
7
- buildId: _buildId,
36
+ buildId,
8
37
  properties = {},
9
- requestTimeout: _requestTimeout,
38
+ requestTimeout,
10
39
  threshold,
11
40
  minClusterSize,
12
- fullPage,
13
- ...topLevelProperties
41
+ fullPage
14
42
  } = options;
15
- let normalizedProperties = {
16
- ...topLevelProperties,
17
- ...properties
18
- };
43
+ let warnings = [];
44
+ let normalizedProperties = {};
45
+ for (let [key, value] of Object.entries(properties || {})) {
46
+ if (RESERVED_PROPERTY_OPTIONS[key]) {
47
+ 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
+ continue;
58
+ }
59
+ normalizedProperties[key] = value;
60
+ }
19
61
  if (threshold !== undefined) {
20
62
  normalizedProperties.threshold = threshold;
21
63
  }
@@ -25,5 +67,13 @@ export function createScreenshotProperties(options = {}) {
25
67
  if (fullPage !== undefined) {
26
68
  normalizedProperties.fullPage = fullPage;
27
69
  }
28
- return normalizedProperties;
70
+ return {
71
+ buildId,
72
+ requestTimeout,
73
+ properties: normalizedProperties,
74
+ warnings
75
+ };
76
+ }
77
+ export function createScreenshotProperties(options = {}) {
78
+ return normalizeScreenshotOptions(options).properties;
29
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -23,16 +23,24 @@ If a task names a screen, component, or screenshot, inspect that screenshot's hi
23
23
  vizzly context screenshot "<screenshot-name>" --source local --json
24
24
  ```
25
25
 
26
- When you make or verify UI changes, run the focused user workflow that owns the surface:
26
+ When you make or verify UI changes, use the local TDD server as the active feedback loop:
27
27
 
28
28
  ```bash
29
- vizzly tdd run "<test command>" --no-open
29
+ vizzly tdd start &
30
+ npm run test:e2e -- tests/e2e/path.spec.js
31
+ vizzly context build current --source local --agent --json
30
32
  ```
31
33
 
32
- Then inspect what changed:
34
+ The user may choose whether the TDD server runs in the foreground or background. Once it is
35
+ running, run tests normally from the repo. The SDK discovers `.vizzly/server.json`, posts
36
+ screenshots to the active TDD server, and refreshes `.vizzly/current`, `.vizzly/diffs`, report
37
+ data, and local context.
38
+
39
+ Use `vizzly tdd run` for true one-off runs where you want the CLI to own starting the visual
40
+ session and running the command:
33
41
 
34
42
  ```bash
35
- vizzly context build current --source local --agent --json
43
+ vizzly tdd run "<test command>" --no-open
36
44
  ```
37
45
 
38
46
  For specific evidence, drill in:
@@ -5,13 +5,19 @@ Use these commands to gather Vizzly evidence before making UI assumptions.
5
5
  ## Local TDD
6
6
 
7
7
  ```bash
8
- vizzly tdd start
8
+ vizzly tdd start --open
9
+ vizzly tdd status
10
+ vizzly tdd stop
9
11
  vizzly tdd run "<test command>" --no-open
10
12
  vizzly context build current --source local --agent
11
13
  vizzly context build current --source local --agent --json
12
14
  ```
13
15
 
14
- Local context reads `.vizzly` state and does not require cloud auth.
16
+ If `tdd start` prints a non-default port, pass that same port to
17
+ `tdd status --port <port>` and `tdd stop --port <port>`.
18
+
19
+ Local context reads `.vizzly` state and does not require cloud auth. After a
20
+ one-shot `tdd run`, prefer the printed `contextCommand` before editing UI.
15
21
 
16
22
  ## Cloud Builds
17
23
 
@@ -6,11 +6,13 @@ Use Vizzly locally for fast feedback and in CI for shared review.
6
6
 
7
7
  ```bash
8
8
  vizzly init
9
- vizzly tdd start
9
+ vizzly tdd start --open
10
10
  vizzly tdd run "<test command>" --no-open
11
11
  ```
12
12
 
13
13
  The local SDK discovers `.vizzly/server.json` and sends screenshots to the TDD server when it is running.
14
+ For one-off checks, `vizzly tdd run` writes local review data under `.vizzly/`
15
+ and prints a context command for follow-up inspection.
14
16
 
15
17
  ## Cloud Setup
16
18