@vizzly-testing/cli 0.29.5 → 0.30.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/dist/sdk/index.js CHANGED
@@ -230,7 +230,7 @@ export class VizzlySDK extends EventEmitter {
230
230
  * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
231
231
  */
232
232
  async screenshot(name, imageBuffer, options = {}) {
233
- if (!this.server || !this.server.isRunning()) {
233
+ if (!this.server?.isRunning()) {
234
234
  throw new VizzlyError('Server not running. Call start() first.', 'SERVER_NOT_RUNNING');
235
235
  }
236
236
 
@@ -72,6 +72,10 @@ export function createAssetsRouter() {
72
72
  if (existsSync(fullImagePath)) {
73
73
  try {
74
74
  const imageData = readFileSync(fullImagePath);
75
+ // Images are rewritten in place between TDD runs, so disable browser caching.
76
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
77
+ res.setHeader('Pragma', 'no-cache');
78
+ res.setHeader('Expires', '0');
75
79
  sendFile(res, imageData, 'image/png');
76
80
  return true;
77
81
  } catch (error) {
@@ -3,8 +3,12 @@
3
3
  * Server-Sent Events endpoint for real-time dashboard updates
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, watch } from 'node:fs';
6
+ import { existsSync, readFileSync, statSync, watch } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ let FILE_WATCH_DEBOUNCE_MS = 25;
9
+ let FILE_POLL_INTERVAL_MS = 50;
10
+ let REPORT_READ_RETRY_MS = 25;
11
+ let MAX_REPORT_READ_RETRIES = 3;
8
12
 
9
13
  /**
10
14
  * Create events router for SSE
@@ -152,9 +156,21 @@ export function createEventsRouter(context) {
152
156
  // Debounce file change events (fs.watch can fire multiple times)
153
157
  let debounceTimer = null;
154
158
  let watcher = null;
155
- const sendUpdate = () => {
159
+ let filePollInterval = null;
160
+ const scheduleUpdate = () => {
161
+ if (debounceTimer) {
162
+ clearTimeout(debounceTimer);
163
+ }
164
+ debounceTimer = setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
165
+ };
166
+ const sendUpdate = (retryCount = 0) => {
156
167
  const newData = readReportData();
157
- if (!newData) return;
168
+ if (!newData) {
169
+ if (existsSync(reportDataPath) && retryCount < MAX_REPORT_READ_RETRIES) {
170
+ debounceTimer = setTimeout(() => sendUpdate(retryCount + 1), REPORT_READ_RETRY_MS);
171
+ }
172
+ return;
173
+ }
158
174
  if (!lastSentData) {
159
175
  // No previous data — send full payload
160
176
  sendEvent(res, 'reportData', newData);
@@ -174,19 +190,37 @@ export function createEventsRouter(context) {
174
190
  watcher = watch(vizzlyDir, {
175
191
  recursive: false
176
192
  }, (_eventType, filename) => {
177
- // Only react to report-data.json changes
178
- if (filename === 'report-data.json') {
179
- // Debounce: wait 100ms after last change before sending
180
- if (debounceTimer) {
181
- clearTimeout(debounceTimer);
182
- }
183
- debounceTimer = setTimeout(sendUpdate, 100);
193
+ // Some platforms occasionally omit the filename for directory watch
194
+ // events. In that case, fall back to re-reading report data.
195
+ if (!filename || filename === 'report-data.json') {
196
+ scheduleUpdate();
197
+ }
198
+ });
199
+ watcher.on('error', () => {
200
+ if (watcher) {
201
+ watcher.close();
202
+ watcher = null;
184
203
  }
185
204
  });
186
205
  } catch {
187
206
  // File watching not available, client will fall back to polling
188
207
  }
189
208
  }
209
+ let lastPolledMtime = existsSync(reportDataPath) ? statSync(reportDataPath).mtimeMs : null;
210
+ filePollInterval = setInterval(() => {
211
+ let nextMtime = null;
212
+ if (existsSync(reportDataPath)) {
213
+ try {
214
+ nextMtime = statSync(reportDataPath).mtimeMs;
215
+ } catch {
216
+ nextMtime = null;
217
+ }
218
+ }
219
+ if (nextMtime !== lastPolledMtime) {
220
+ lastPolledMtime = nextMtime;
221
+ scheduleUpdate();
222
+ }
223
+ }, FILE_POLL_INTERVAL_MS);
190
224
 
191
225
  // Heartbeat to keep connection alive (every 30 seconds)
192
226
  const heartbeatInterval = setInterval(() => {
@@ -201,10 +235,16 @@ export function createEventsRouter(context) {
201
235
  const cleanup = () => {
202
236
  if (debounceTimer) {
203
237
  clearTimeout(debounceTimer);
238
+ debounceTimer = null;
204
239
  }
205
240
  clearInterval(heartbeatInterval);
206
241
  if (watcher) {
207
242
  watcher.close();
243
+ watcher = null;
244
+ }
245
+ if (filePollInterval) {
246
+ clearInterval(filePollInterval);
247
+ filePollInterval = null;
208
248
  }
209
249
  };
210
250
  req.on('close', cleanup);
@@ -23,7 +23,7 @@ export function calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
23
23
  totalLines: 0
24
24
  };
25
25
  }
26
- if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
26
+ if (!hotspotAnalysis?.regions || hotspotAnalysis.regions.length === 0) {
27
27
  return {
28
28
  coverage: 0,
29
29
  linesInHotspots: 0,
@@ -31,7 +31,7 @@ export async function downloadHotspots(options) {
31
31
  }
32
32
  try {
33
33
  let response = await api.getHotspots(screenshotNames);
34
- if (!response || !response.hotspots) {
34
+ if (!response?.hotspots) {
35
35
  return {
36
36
  success: false,
37
37
  error: 'API returned no hotspot data'
@@ -35,7 +35,7 @@ export async function downloadRegions(options) {
35
35
  let response = await api.getRegions(screenshotNames, {
36
36
  includeCandidates
37
37
  });
38
- if (!response || !response.regions) {
38
+ if (!response?.regions) {
39
39
  return {
40
40
  success: false,
41
41
  error: 'API returned no region data'
@@ -8,16 +8,16 @@ import { EventEmitter } from 'node:events';
8
8
 
9
9
  // Re-export common types
10
10
  export {
11
- VizzlyConfig,
12
- ScreenshotOptions,
13
- UploadOptions,
14
- UploadResult,
11
+ BaselineData,
15
12
  ComparisonResult,
13
+ OutputUtils,
14
+ ScreenshotOptions,
16
15
  TddResults,
17
- BaselineData,
18
- Uploader,
19
16
  TddService,
20
- OutputUtils,
17
+ Uploader,
18
+ UploadOptions,
19
+ UploadResult,
20
+ VizzlyConfig,
21
21
  } from './index';
22
22
 
23
23
  /**
@@ -1,7 +1,7 @@
1
1
  // Color utility using ansis for rich terminal styling.
2
2
  // Detects terminal color support and provides chainable color functions.
3
3
 
4
- import ansis from 'ansis';
4
+ import { Ansis } from 'ansis';
5
5
 
6
6
  // =============================================================================
7
7
  // Vizzly Observatory Design System Colors
@@ -72,7 +72,7 @@ function supportsColorDefault() {
72
72
  }
73
73
 
74
74
  // If stdout is not a TTY, assume no color
75
- if (!process.stdout || !process.stdout.isTTY) return false;
75
+ if (!process.stdout?.isTTY) return false;
76
76
 
77
77
  // Prefer getColorDepth when available
78
78
  try {
@@ -92,6 +92,7 @@ function supportsColorDefault() {
92
92
  */
93
93
  export function createColors(options = {}) {
94
94
  let enabled = options.useColor !== undefined ? !!options.useColor : supportsColorDefault();
95
+ let level = options.useColor !== undefined ? enabled ? 3 : 0 : enabled ? 3 : 0;
95
96
  if (!enabled) {
96
97
  // Return no-op functions when color disabled
97
98
  let noop = (input = '') => String(input);
@@ -152,60 +153,61 @@ export function createColors(options = {}) {
152
153
  }
153
154
  };
154
155
  }
156
+ let colors = new Ansis(level);
155
157
  return {
156
158
  // Modifiers
157
- reset: ansis.reset,
158
- bold: ansis.bold,
159
- dim: ansis.dim,
160
- italic: ansis.italic,
161
- underline: ansis.underline,
162
- strikethrough: ansis.strikethrough,
159
+ reset: colors.reset,
160
+ bold: colors.bold,
161
+ dim: colors.dim,
162
+ italic: colors.italic,
163
+ underline: colors.underline,
164
+ strikethrough: colors.strikethrough,
163
165
  // Basic ANSI colors (fallback)
164
- red: ansis.red,
165
- green: ansis.green,
166
- yellow: ansis.yellow,
167
- blue: ansis.blue,
168
- magenta: ansis.magenta,
169
- cyan: ansis.cyan,
170
- white: ansis.white,
171
- gray: ansis.gray,
172
- black: ansis.black,
166
+ red: colors.red,
167
+ green: colors.green,
168
+ yellow: colors.yellow,
169
+ blue: colors.blue,
170
+ magenta: colors.magenta,
171
+ cyan: colors.cyan,
172
+ white: colors.white,
173
+ gray: colors.gray,
174
+ black: colors.black,
173
175
  // Semantic aliases (basic)
174
- success: ansis.green,
175
- error: ansis.red,
176
- warning: ansis.yellow,
177
- info: ansis.blue,
176
+ success: colors.green,
177
+ error: colors.red,
178
+ warning: colors.yellow,
179
+ info: colors.blue,
178
180
  // Extended colors for rich styling
179
- rgb: ansis.rgb,
180
- hex: ansis.hex,
181
- bgRgb: ansis.bgRgb,
182
- bgHex: ansis.bgHex,
181
+ rgb: colors.rgb.bind(colors),
182
+ hex: colors.hex.bind(colors),
183
+ bgRgb: colors.bgRgb.bind(colors),
184
+ bgHex: colors.bgHex.bind(colors),
183
185
  // Observatory brand colors (Truecolor) - aligned with design system
184
186
  brand: {
185
187
  // Primary brand color
186
- amber: ansis.hex(brand.amber),
187
- amberLight: ansis.hex(brand.amberLight),
188
+ amber: colors.hex(brand.amber),
189
+ amberLight: colors.hex(brand.amberLight),
188
190
  // Semantic accents
189
- success: ansis.hex(brand.success),
190
- warning: ansis.hex(brand.warning),
191
- danger: ansis.hex(brand.danger),
192
- info: ansis.hex(brand.info),
191
+ success: colors.hex(brand.success),
192
+ warning: colors.hex(brand.warning),
193
+ danger: colors.hex(brand.danger),
194
+ info: colors.hex(brand.info),
193
195
  // Text hierarchy
194
- textPrimary: ansis.hex(brand.textPrimary),
195
- textSecondary: ansis.hex(brand.textSecondary),
196
- textTertiary: ansis.hex(brand.textTertiary),
197
- textMuted: ansis.hex(brand.textMuted),
196
+ textPrimary: colors.hex(brand.textPrimary),
197
+ textSecondary: colors.hex(brand.textSecondary),
198
+ textTertiary: colors.hex(brand.textTertiary),
199
+ textMuted: colors.hex(brand.textMuted),
198
200
  // Background variants
199
- bgAmber: ansis.bgHex(brand.amber),
200
- bgSuccess: ansis.bgHex(brand.success),
201
- bgWarning: ansis.bgHex(brand.warning),
202
- bgDanger: ansis.bgHex(brand.danger),
203
- bgInfo: ansis.bgHex(brand.info),
201
+ bgAmber: colors.bgHex(brand.amber),
202
+ bgSuccess: colors.bgHex(brand.success),
203
+ bgWarning: colors.bgHex(brand.warning),
204
+ bgDanger: colors.bgHex(brand.danger),
205
+ bgInfo: colors.bgHex(brand.info),
204
206
  // Legacy aliases (backward compatibility)
205
- green: ansis.hex(brand.green),
206
- red: ansis.hex(brand.red),
207
- cyan: ansis.hex(brand.cyan),
208
- slate: ansis.hex(brand.slate)
207
+ green: colors.hex(brand.green),
208
+ red: colors.hex(brand.red),
209
+ cyan: colors.hex(brand.cyan),
210
+ slate: colors.hex(brand.slate)
209
211
  }
210
212
  };
211
213
  }
@@ -132,7 +132,7 @@ export async function getUserPath() {
132
132
  */
133
133
  export async function getAuthTokens() {
134
134
  const config = await loadGlobalConfig();
135
- if (!config.auth || !config.auth.accessToken) {
135
+ if (!config.auth?.accessToken) {
136
136
  return null;
137
137
  }
138
138
  return config.auth;
@@ -170,7 +170,7 @@ export async function clearAuthTokens() {
170
170
  */
171
171
  export async function hasValidTokens() {
172
172
  const auth = await getAuthTokens();
173
- if (!auth || !auth.accessToken) {
173
+ if (!auth?.accessToken) {
174
174
  return false;
175
175
  }
176
176
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ensure each comparison has a timestamp for image cache-busting.
3
+ */
4
+ export function normalizeReportData(reportData) {
5
+ if (!reportData || !Array.isArray(reportData.comparisons)) {
6
+ return reportData;
7
+ }
8
+ let needsNormalization = reportData.comparisons.some(comparison => comparison && comparison.timestamp == null);
9
+ if (!needsNormalization) {
10
+ return reportData;
11
+ }
12
+ let fallbackTimestamp = reportData.timestamp ?? Date.now();
13
+ let comparisons = reportData.comparisons.map(comparison => normalizeComparisonUpdate(comparison, fallbackTimestamp));
14
+ return {
15
+ ...reportData,
16
+ comparisons
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Ensure a single comparison includes a timestamp.
22
+ */
23
+ export function normalizeComparisonUpdate(comparison, fallbackTimestamp) {
24
+ if (!comparison || comparison.timestamp != null) {
25
+ return comparison;
26
+ }
27
+ return {
28
+ ...comparison,
29
+ timestamp: fallbackTimestamp ?? Date.now()
30
+ };
31
+ }
@@ -206,8 +206,9 @@ export function validateScreenshotProperties(properties = {}) {
206
206
 
207
207
  if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
208
208
  if (typeof value === 'string' && value.length <= 200) {
209
- // Store sanitized version of string values
210
- validated[key] = value.replace(/[<>&"']/g, ''); // Basic HTML entity prevention
209
+ // Preserve safe URL/query characters like '&' in metadata values.
210
+ // Rendering layers should escape for HTML instead of mutating payload data here.
211
+ validated[key] = value.replace(/[<>"']/g, '');
211
212
  } else if (typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value)) {
212
213
  validated[key] = value;
213
214
  } else if (typeof value === 'boolean') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.29.5",
3
+ "version": "0.30.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",
@@ -119,7 +119,7 @@
119
119
  "@tailwindcss/postcss": "^4.1.13",
120
120
  "@tanstack/react-query": "^5.90.11",
121
121
  "@types/node": "^25.0.2",
122
- "@vitejs/plugin-react": "^5.0.3",
122
+ "@vitejs/plugin-react": "^6.0.1",
123
123
  "@vizzly-testing/observatory": "^0.3.3",
124
124
  "autoprefixer": "^10.4.21",
125
125
  "babel-plugin-react-compiler": "^1.0.0",
@@ -131,8 +131,8 @@
131
131
  "tailwindcss": "^4.1.13",
132
132
  "tsd": "^0.33.0",
133
133
  "tui-driver": "github:vizzly-testing/tui-driver",
134
- "typescript": "^5.0.4",
135
- "vite": "^7.1.7",
134
+ "typescript": "^6.0.3",
135
+ "vite": "^8.0.0",
136
136
  "wouter": "^3.7.1"
137
137
  }
138
138
  }