@vizzly-testing/cli 0.22.1 → 0.23.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.
@@ -311,4 +311,47 @@ export async function finalizeParallelBuild(client, parallelId) {
311
311
  'Content-Type': 'application/json'
312
312
  }
313
313
  });
314
+ }
315
+
316
+ // ============================================================================
317
+ // Preview Endpoints
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Upload preview ZIP file for a build
322
+ * @param {Object} client - API client
323
+ * @param {string} buildId - Build ID
324
+ * @param {Buffer} zipBuffer - ZIP file contents
325
+ * @returns {Promise<Object>} Upload result with preview URL
326
+ */
327
+ export async function uploadPreviewZip(client, buildId, zipBuffer) {
328
+ // Create form data with the ZIP file
329
+ let FormData = (await import('form-data')).default;
330
+ let formData = new FormData();
331
+ formData.append('file', zipBuffer, {
332
+ filename: 'preview.zip',
333
+ contentType: 'application/zip'
334
+ });
335
+ return client.request(`/api/sdk/builds/${buildId}/preview/upload-zip`, {
336
+ method: 'POST',
337
+ body: formData,
338
+ headers: formData.getHeaders()
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Get preview info for a build
344
+ * @param {Object} client - API client
345
+ * @param {string} buildId - Build ID
346
+ * @returns {Promise<Object>} Preview info or null if not found
347
+ */
348
+ export async function getPreviewInfo(client, buildId) {
349
+ try {
350
+ return await client.request(`/api/sdk/builds/${buildId}/preview`);
351
+ } catch (error) {
352
+ if (error.status === 404) {
353
+ return null;
354
+ }
355
+ throw error;
356
+ }
314
357
  }
package/dist/api/index.js CHANGED
@@ -16,4 +16,4 @@ export { createApiClient, DEFAULT_API_URL } from './client.js';
16
16
  export { buildApiUrl, buildAuthHeader, buildBuildPayload, buildEndpointWithParams, buildQueryParams, buildRequestHeaders, buildScreenshotCheckObject, buildScreenshotPayload, buildShaCheckPayload, buildUserAgent, computeSha256, extractErrorBody, findScreenshotBySha, isAuthError, isRateLimited, parseApiError, partitionByShaExistence, shaExists, shouldRetryWithRefresh } from './core.js';
17
17
 
18
18
  // Endpoint functions
19
- export { checkShas, createBuild, finalizeBuild, finalizeParallelBuild, getBatchHotspots, getBuild, getBuilds, getComparison, getScreenshotHotspots, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, uploadScreenshot } from './endpoints.js';
19
+ export { checkShas, createBuild, finalizeBuild, finalizeParallelBuild, getBatchHotspots, getBuild, getBuilds, getComparison, getPreviewInfo, getScreenshotHotspots, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, uploadPreviewZip, uploadScreenshot } from './endpoints.js';
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js
6
6
  import { init } from './commands/init.js';
7
7
  import { loginCommand, validateLoginOptions } from './commands/login.js';
8
8
  import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
9
+ import { previewCommand, validatePreviewOptions } from './commands/preview.js';
9
10
  import { projectListCommand, projectRemoveCommand, projectSelectCommand, projectTokenCommand, validateProjectOptions } from './commands/project.js';
10
11
  import { runCommand, validateRunOptions } from './commands/run.js';
11
12
  import { statusCommand, validateStatusOptions } from './commands/status.js';
@@ -411,6 +412,20 @@ program.command('finalize').description('Finalize a parallel build after all sha
411
412
  }
412
413
  await finalizeCommand(parallelId, options, globalOptions);
413
414
  });
415
+ program.command('preview').description('Upload static files as a preview for a build').argument('<path>', 'Path to static files (dist/, build/, out/)').option('-b, --build <id>', 'Build ID to attach preview to').option('-p, --parallel-id <id>', 'Look up build by parallel ID').option('--base <path>', 'Override auto-detected base path').option('--open', 'Open preview URL in browser after upload').action(async (path, options) => {
416
+ const globalOptions = program.opts();
417
+
418
+ // Validate options
419
+ const validationErrors = validatePreviewOptions(path, options);
420
+ if (validationErrors.length > 0) {
421
+ output.error('Validation errors:');
422
+ for (let error of validationErrors) {
423
+ output.printErr(` - ${error}`);
424
+ }
425
+ process.exit(1);
426
+ }
427
+ await previewCommand(path, options, globalOptions);
428
+ });
414
429
  program.command('doctor').description('Run diagnostics to check your environment and configuration').option('--api', 'Include API connectivity checks').action(async options => {
415
430
  const globalOptions = program.opts();
416
431
 
@@ -188,7 +188,9 @@ function createSimpleClient(serverUrl) {
188
188
  try {
189
189
  // If it's a string, assume it's a file path and send directly
190
190
  // Otherwise it's a Buffer, so convert to base64
191
- const image = typeof imageBuffer === 'string' ? imageBuffer : imageBuffer.toString('base64');
191
+ let isFilePath = typeof imageBuffer === 'string';
192
+ let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
193
+ let type = isFilePath ? 'file-path' : 'base64';
192
194
  const {
193
195
  status,
194
196
  json
@@ -196,6 +198,7 @@ function createSimpleClient(serverUrl) {
196
198
  buildId: getBuildId(),
197
199
  name,
198
200
  image,
201
+ type,
199
202
  properties: options,
200
203
  fullPage: options.fullPage || false
201
204
  }, DEFAULT_TIMEOUT_MS);
@@ -6,6 +6,7 @@
6
6
  import { createApiClient as defaultCreateApiClient, finalizeParallelBuild as defaultFinalizeParallelBuild } from '../api/index.js';
7
7
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
8
8
  import * as defaultOutput from '../utils/output.js';
9
+ import { writeSession as defaultWriteSession } from '../utils/session.js';
9
10
 
10
11
  /**
11
12
  * Finalize command implementation
@@ -20,6 +21,7 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
20
21
  createApiClient = defaultCreateApiClient,
21
22
  finalizeParallelBuild = defaultFinalizeParallelBuild,
22
23
  output = defaultOutput,
24
+ writeSession = defaultWriteSession,
23
25
  exit = code => process.exit(code)
24
26
  } = deps;
25
27
  output.configure({
@@ -61,6 +63,14 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
61
63
  });
62
64
  let result = await finalizeParallelBuild(client, parallelId);
63
65
  output.stopSpinner();
66
+
67
+ // Write session for subsequent commands (like preview)
68
+ if (result.build?.id) {
69
+ writeSession({
70
+ buildId: result.build.id,
71
+ parallelId
72
+ });
73
+ }
64
74
  if (globalOptions.json) {
65
75
  output.data(result);
66
76
  } else {
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Preview command implementation
3
+ *
4
+ * Uploads static files as a preview for a Vizzly build.
5
+ * The build is automatically detected from session file or environment.
6
+ */
7
+
8
+ import { exec, execSync } from 'node:child_process';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { existsSync, statSync } from 'node:fs';
11
+ import { readFile, realpath, stat, unlink } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join, resolve } from 'node:path';
14
+ import { promisify } from 'node:util';
15
+ import { createApiClient as defaultCreateApiClient, uploadPreviewZip as defaultUploadPreviewZip } from '../api/index.js';
16
+ import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
17
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
18
+ import { detectBranch as defaultDetectBranch } from '../utils/git.js';
19
+ import * as defaultOutput from '../utils/output.js';
20
+ import { formatSessionAge as defaultFormatSessionAge, readSession as defaultReadSession } from '../utils/session.js';
21
+ let execAsync = promisify(exec);
22
+
23
+ /**
24
+ * Validate path for shell safety - prevents command injection
25
+ * @param {string} path - Path to validate
26
+ * @returns {boolean} true if path is safe for shell use
27
+ */
28
+ function isPathSafe(path) {
29
+ // Reject paths with shell metacharacters that could enable command injection
30
+ let dangerousChars = /[`$;&|<>(){}[\]\\!*?'"]/;
31
+ return !dangerousChars.test(path);
32
+ }
33
+
34
+ /**
35
+ * Check if a command exists on the system
36
+ * @param {string} command - Command to check
37
+ * @returns {boolean}
38
+ */
39
+ function commandExists(command) {
40
+ try {
41
+ let checkCmd = process.platform === 'win32' ? 'where' : 'which';
42
+ execSync(`${checkCmd} ${command}`, {
43
+ stdio: 'ignore'
44
+ });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Get the appropriate zip command for the current platform
53
+ * @returns {{ command: string, available: boolean }}
54
+ */
55
+ function getZipCommand() {
56
+ // Check for standard zip command (macOS, Linux, Windows with Git Bash)
57
+ if (commandExists('zip')) {
58
+ return {
59
+ command: 'zip',
60
+ available: true
61
+ };
62
+ }
63
+
64
+ // Windows: Check for PowerShell Compress-Archive
65
+ if (process.platform === 'win32') {
66
+ return {
67
+ command: 'powershell',
68
+ available: true
69
+ };
70
+ }
71
+ return {
72
+ command: null,
73
+ available: false
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Create a ZIP file from a directory using system commands
79
+ * @param {string} sourceDir - Directory to zip
80
+ * @param {string} outputPath - Path for output ZIP file
81
+ * @returns {Promise<void>}
82
+ */
83
+ async function createZipWithSystem(sourceDir, outputPath) {
84
+ let {
85
+ command,
86
+ available
87
+ } = getZipCommand();
88
+ if (!available) {
89
+ throw new Error('No zip command found. Please install zip or use PowerShell on Windows.');
90
+ }
91
+
92
+ // Validate paths to prevent command injection
93
+ // Note: outputPath is internally generated (tmpdir + random), so always safe
94
+ // sourceDir comes from user input, so we validate it
95
+ if (!isPathSafe(sourceDir)) {
96
+ throw new Error('Path contains unsupported characters. Please use a path without special shell characters.');
97
+ }
98
+ if (command === 'zip') {
99
+ // Standard zip command - create ZIP from directory contents
100
+ // Using cwd option is safe as it's not part of the command string
101
+ // -r: recursive, -q: quiet
102
+ await execAsync(`zip -r -q "${outputPath}" .`, {
103
+ cwd: sourceDir,
104
+ maxBuffer: 1024 * 1024 * 100 // 100MB buffer
105
+ });
106
+ } else if (command === 'powershell') {
107
+ // Windows PowerShell - use -LiteralPath for safer path handling
108
+ // Escape single quotes in paths by doubling them
109
+ let safeSrcDir = sourceDir.replace(/'/g, "''");
110
+ let safeOutPath = outputPath.replace(/'/g, "''");
111
+ await execAsync(`powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, {
112
+ maxBuffer: 1024 * 1024 * 100
113
+ });
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Count files in a directory recursively
119
+ * Skips symlinks to prevent path traversal attacks
120
+ * @param {string} dir - Directory path
121
+ * @returns {Promise<{ count: number, totalSize: number }>}
122
+ */
123
+ async function countFiles(dir) {
124
+ let {
125
+ readdir
126
+ } = await import('node:fs/promises');
127
+ let count = 0;
128
+ let totalSize = 0;
129
+
130
+ // Resolve the base directory to an absolute path for traversal checks
131
+ let baseDir = await realpath(resolve(dir));
132
+ async function walk(currentDir) {
133
+ let entries = await readdir(currentDir, {
134
+ withFileTypes: true
135
+ });
136
+ for (let entry of entries) {
137
+ let fullPath = join(currentDir, entry.name);
138
+
139
+ // Skip symlinks to prevent traversal attacks
140
+ if (entry.isSymbolicLink()) {
141
+ continue;
142
+ }
143
+ if (entry.isDirectory()) {
144
+ // Skip hidden directories and common non-content directories
145
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
146
+ continue;
147
+ }
148
+
149
+ // Verify we're still within the base directory (prevent traversal)
150
+ let realSubDir = await realpath(fullPath);
151
+ if (!realSubDir.startsWith(baseDir)) {
152
+ continue;
153
+ }
154
+ await walk(fullPath);
155
+ } else if (entry.isFile()) {
156
+ // Skip hidden files
157
+ if (entry.name.startsWith('.')) {
158
+ continue;
159
+ }
160
+ count++;
161
+ let fileStat = await stat(fullPath);
162
+ totalSize += fileStat.size;
163
+ }
164
+ }
165
+ }
166
+ await walk(baseDir);
167
+ return {
168
+ count,
169
+ totalSize
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Format bytes for display
175
+ * @param {number} bytes - Bytes to format
176
+ * @returns {string}
177
+ */
178
+ function formatBytes(bytes) {
179
+ if (bytes < 1024) return `${bytes} B`;
180
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
181
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
182
+ }
183
+
184
+ /**
185
+ * Preview command implementation
186
+ * @param {string} path - Path to static files
187
+ * @param {Object} options - Command options
188
+ * @param {Object} globalOptions - Global CLI options
189
+ * @param {Object} deps - Dependencies for testing
190
+ */
191
+ export async function previewCommand(path, options = {}, globalOptions = {}, deps = {}) {
192
+ let {
193
+ loadConfig = defaultLoadConfig,
194
+ createApiClient = defaultCreateApiClient,
195
+ uploadPreviewZip = defaultUploadPreviewZip,
196
+ readSession = defaultReadSession,
197
+ formatSessionAge = defaultFormatSessionAge,
198
+ detectBranch = defaultDetectBranch,
199
+ openBrowser = defaultOpenBrowser,
200
+ output = defaultOutput,
201
+ exit = code => process.exit(code)
202
+ } = deps;
203
+ output.configure({
204
+ json: globalOptions.json,
205
+ verbose: globalOptions.verbose,
206
+ color: !globalOptions.noColor
207
+ });
208
+ try {
209
+ // Load configuration
210
+ let allOptions = {
211
+ ...globalOptions,
212
+ ...options
213
+ };
214
+ let config = await loadConfig(globalOptions.config, allOptions);
215
+
216
+ // Validate API token
217
+ if (!config.apiKey) {
218
+ output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
219
+ exit(1);
220
+ return {
221
+ success: false,
222
+ reason: 'no-api-key'
223
+ };
224
+ }
225
+
226
+ // Validate path exists and is a directory
227
+ if (!existsSync(path)) {
228
+ output.error(`Path does not exist: ${path}`);
229
+ exit(1);
230
+ return {
231
+ success: false,
232
+ reason: 'path-not-found'
233
+ };
234
+ }
235
+ let pathStat = statSync(path);
236
+ if (!pathStat.isDirectory()) {
237
+ output.error(`Path is not a directory: ${path}`);
238
+ exit(1);
239
+ return {
240
+ success: false,
241
+ reason: 'not-a-directory'
242
+ };
243
+ }
244
+
245
+ // Resolve build ID
246
+ let buildId = options.build;
247
+ let buildSource = 'flag';
248
+ if (!buildId && options.parallelId) {
249
+ // TODO: Look up build by parallel ID
250
+ output.error('Parallel ID lookup not yet implemented. Use --build to specify build ID directly.');
251
+ exit(1);
252
+ return {
253
+ success: false,
254
+ reason: 'parallel-id-not-implemented'
255
+ };
256
+ }
257
+ if (!buildId) {
258
+ // Try to read from session
259
+ let currentBranch = await detectBranch();
260
+ let session = readSession({
261
+ currentBranch
262
+ });
263
+ if (session?.buildId && !session.expired) {
264
+ if (session.branchMismatch) {
265
+ output.warn(`Session build is from different branch (${session.branch})`);
266
+ output.hint(`Use --build to specify a build ID, or run tests on this branch first.`);
267
+ exit(1);
268
+ return {
269
+ success: false,
270
+ reason: 'branch-mismatch'
271
+ };
272
+ }
273
+ buildId = session.buildId;
274
+ buildSource = session.source;
275
+ if (globalOptions.verbose) {
276
+ output.info(`Using build ${buildId} from ${buildSource} (${formatSessionAge(session.age)})`);
277
+ }
278
+ }
279
+ }
280
+ if (!buildId) {
281
+ output.error('No build found');
282
+ output.blank();
283
+ output.print(' Run visual tests first, then upload your preview:');
284
+ output.blank();
285
+ output.print(' vizzly run "npm test"');
286
+ output.print(' vizzly preview ./dist');
287
+ output.blank();
288
+ output.print(' Or specify a build explicitly:');
289
+ output.blank();
290
+ output.print(' vizzly preview ./dist --build <build-id>');
291
+ output.blank();
292
+ exit(1);
293
+ return {
294
+ success: false,
295
+ reason: 'no-build'
296
+ };
297
+ }
298
+
299
+ // Check for zip command availability
300
+ let zipInfo = getZipCommand();
301
+ if (!zipInfo.available) {
302
+ output.error('No zip command found. Please install zip (macOS/Linux) or ensure PowerShell is available (Windows).');
303
+ exit(1);
304
+ return {
305
+ success: false,
306
+ reason: 'no-zip-command'
307
+ };
308
+ }
309
+
310
+ // Count files and calculate size
311
+ output.startSpinner('Scanning files...');
312
+ let {
313
+ count: fileCount,
314
+ totalSize
315
+ } = await countFiles(path);
316
+ if (fileCount === 0) {
317
+ output.stopSpinner();
318
+ output.error(`No files found in ${path}`);
319
+ exit(1);
320
+ return {
321
+ success: false,
322
+ reason: 'no-files'
323
+ };
324
+ }
325
+ output.updateSpinner(`Found ${fileCount} files (${formatBytes(totalSize)})`);
326
+
327
+ // Create ZIP using system command
328
+ output.updateSpinner('Compressing files...');
329
+ // Use timestamp + random bytes for unique temp file (prevents race conditions)
330
+ let randomSuffix = randomBytes(8).toString('hex');
331
+ let zipPath = join(tmpdir(), `vizzly-preview-${Date.now()}-${randomSuffix}.zip`);
332
+ let zipBuffer;
333
+ try {
334
+ await createZipWithSystem(path, zipPath);
335
+ zipBuffer = await readFile(zipPath);
336
+ } catch (zipError) {
337
+ output.stopSpinner();
338
+ output.error(`Failed to create ZIP: ${zipError.message}`);
339
+ await unlink(zipPath).catch(() => {});
340
+ exit(1);
341
+ return {
342
+ success: false,
343
+ reason: 'zip-failed',
344
+ error: zipError
345
+ };
346
+ } finally {
347
+ // Always clean up temp file
348
+ await unlink(zipPath).catch(() => {});
349
+ }
350
+ let compressionRatio = ((1 - zipBuffer.length / totalSize) * 100).toFixed(0);
351
+ output.updateSpinner(`Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)`);
352
+
353
+ // Upload
354
+ output.updateSpinner('Uploading preview...');
355
+ let client = createApiClient({
356
+ baseUrl: config.apiUrl,
357
+ token: config.apiKey,
358
+ command: 'preview'
359
+ });
360
+ let result = await uploadPreviewZip(client, buildId, zipBuffer);
361
+ output.stopSpinner();
362
+
363
+ // Success output
364
+ if (globalOptions.json) {
365
+ output.data({
366
+ success: true,
367
+ buildId,
368
+ previewUrl: result.previewUrl,
369
+ files: result.uploaded,
370
+ totalBytes: result.totalBytes,
371
+ newBytes: result.newBytes,
372
+ deduplicationRatio: result.deduplicationRatio
373
+ });
374
+ } else {
375
+ output.complete('Preview uploaded');
376
+ output.blank();
377
+ let colors = output.getColors();
378
+ output.print(` ${colors.brand.textTertiary('Files')} ${colors.white(result.uploaded)} (${formatBytes(result.totalBytes)} compressed)`);
379
+ if (result.reusedBlobs > 0) {
380
+ let savedBytes = result.totalBytes - result.newBytes;
381
+ output.print(` ${colors.brand.textTertiary('Deduped')} ${colors.green(result.reusedBlobs)} files (saved ${formatBytes(savedBytes)})`);
382
+ }
383
+ if (result.basePath) {
384
+ output.print(` ${colors.brand.textTertiary('Base path')} ${colors.dim(result.basePath)}`);
385
+ }
386
+ output.blank();
387
+ output.print(` ${colors.brand.textTertiary('Preview')} ${colors.cyan(colors.underline(result.previewUrl))}`);
388
+
389
+ // Open in browser if requested
390
+ if (options.open) {
391
+ let opened = await openBrowser(result.previewUrl);
392
+ if (opened) {
393
+ output.print(` ${colors.dim('Opened in browser')}`);
394
+ }
395
+ }
396
+ }
397
+ output.cleanup();
398
+ return {
399
+ success: true,
400
+ result
401
+ };
402
+ } catch (error) {
403
+ output.stopSpinner();
404
+
405
+ // Handle specific error types
406
+ if (error.status === 404) {
407
+ output.error(`Build not found: ${options.build || 'from session'}`);
408
+ } else if (error.status === 403) {
409
+ if (error.message?.includes('Starter')) {
410
+ output.error('Preview hosting requires Starter plan or above');
411
+ output.hint('Upgrade your plan at https://app.vizzly.dev/settings/billing');
412
+ } else {
413
+ output.error('Access denied', error);
414
+ }
415
+ } else {
416
+ output.error('Preview upload failed', error);
417
+ }
418
+ exit(1);
419
+ return {
420
+ success: false,
421
+ error
422
+ };
423
+ } finally {
424
+ output.cleanup();
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Validate preview options
430
+ * @param {string} path - Path to static files
431
+ * @param {Object} options - Command options
432
+ */
433
+ export function validatePreviewOptions(path, _options) {
434
+ let errors = [];
435
+ if (!path || path.trim() === '') {
436
+ errors.push('Path to static files is required');
437
+ }
438
+ return errors;
439
+ }
@@ -13,6 +13,7 @@ import { finalizeBuild as defaultFinalizeBuild, runTests as defaultRunTests } fr
13
13
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
14
14
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
15
15
  import * as defaultOutput from '../utils/output.js';
16
+ import { writeSession as defaultWriteSession } from '../utils/session.js';
16
17
 
17
18
  /**
18
19
  * Run command implementation
@@ -41,6 +42,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
41
42
  generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
42
43
  spawn = defaultSpawn,
43
44
  output = defaultOutput,
45
+ writeSession = defaultWriteSession,
44
46
  exit = code => process.exit(code),
45
47
  processOn = (event, handler) => process.on(event, handler),
46
48
  processRemoveListener = (event, handler) => process.removeListener(event, handler)
@@ -239,6 +241,14 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
239
241
  onBuildCreated: data => {
240
242
  buildUrl = data.url;
241
243
  buildId = data.buildId;
244
+
245
+ // Write session for subsequent commands (like preview)
246
+ writeSession({
247
+ buildId: data.buildId,
248
+ branch,
249
+ commit,
250
+ parallelId: runOptions.parallelId
251
+ });
242
252
  if (globalOptions.verbose) {
243
253
  output.info(`Build created: ${data.buildId}`);
244
254
  }
@@ -3,7 +3,7 @@
3
3
  * Uses functional API operations directly
4
4
  */
5
5
 
6
- import { createApiClient, getBuild } from '../api/index.js';
6
+ import { createApiClient, getBuild, getPreviewInfo } from '../api/index.js';
7
7
  import { loadConfig } from '../utils/config-loader.js';
8
8
  import { getApiUrl } from '../utils/environment-config.js';
9
9
  import * as output from '../utils/output.js';
@@ -42,6 +42,9 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
42
42
  command: 'status'
43
43
  });
44
44
  let buildStatus = await getBuild(client, buildId);
45
+
46
+ // Also fetch preview info (if exists)
47
+ let previewInfo = await getPreviewInfo(client, buildId);
45
48
  output.stopSpinner();
46
49
 
47
50
  // Extract build data from API response
@@ -68,7 +71,13 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
68
71
  approvalStatus: build.approval_status,
69
72
  executionTime: build.execution_time_ms,
70
73
  isBaseline: build.is_baseline,
71
- userAgent: build.user_agent
74
+ userAgent: build.user_agent,
75
+ preview: previewInfo ? {
76
+ url: previewInfo.preview_url,
77
+ status: previewInfo.status,
78
+ fileCount: previewInfo.file_count,
79
+ expiresAt: previewInfo.expires_at
80
+ } : null
72
81
  };
73
82
  output.data(statusData);
74
83
  output.cleanup();
@@ -139,6 +148,15 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) {
139
148
  output.labelValue('View', output.link('Build', buildUrl));
140
149
  }
141
150
 
151
+ // Show preview URL if available
152
+ if (previewInfo?.preview_url) {
153
+ output.labelValue('Preview', output.link('Preview', previewInfo.preview_url));
154
+ if (previewInfo.expires_at) {
155
+ let expiresDate = new Date(previewInfo.expires_at);
156
+ output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`);
157
+ }
158
+ }
159
+
142
160
  // Show additional info in verbose mode
143
161
  if (globalOptions.verbose) {
144
162
  output.blank();
@@ -3,6 +3,7 @@ import { createUploader as defaultCreateUploader } from '../services/uploader.js
3
3
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
4
4
  import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
5
5
  import * as defaultOutput from '../utils/output.js';
6
+ import { writeSession as defaultWriteSession } from '../utils/session.js';
6
7
 
7
8
  /**
8
9
  * Construct proper build URL with org/project context
@@ -60,6 +61,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
60
61
  detectPullRequestNumber = defaultDetectPullRequestNumber,
61
62
  generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
62
63
  output = defaultOutput,
64
+ writeSession = defaultWriteSession,
63
65
  exit = code => process.exit(code),
64
66
  buildUrlConstructor = constructBuildUrl
65
67
  } = deps;
@@ -158,6 +160,16 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
158
160
  const result = await uploader.upload(uploadOptions);
159
161
  buildId = result.buildId; // Ensure we have the buildId
160
162
 
163
+ // Write session for subsequent commands (like preview)
164
+ if (buildId) {
165
+ writeSession({
166
+ buildId,
167
+ branch,
168
+ commit,
169
+ parallelId: config.parallelId
170
+ });
171
+ }
172
+
161
173
  // Mark build as completed
162
174
  if (result.buildId) {
163
175
  output.progress('Finalizing build...');
package/dist/sdk/index.js CHANGED
@@ -247,6 +247,7 @@ export class VizzlySDK extends EventEmitter {
247
247
  buildId,
248
248
  name,
249
249
  image: imageBase64,
250
+ type: 'base64',
250
251
  properties: options.properties || {}
251
252
  };
252
253
 
@@ -47,7 +47,7 @@ export const createApiHandler = (client, {
47
47
  let vizzlyDisabled = false;
48
48
  let screenshotCount = 0;
49
49
  let uploadPromises = [];
50
- const handleScreenshot = async (buildId, name, image, properties = {}) => {
50
+ const handleScreenshot = async (buildId, name, image, properties = {}, type) => {
51
51
  if (vizzlyDisabled) {
52
52
  output.debug('upload', `${name} (disabled)`);
53
53
  return {
@@ -73,8 +73,11 @@ export const createApiHandler = (client, {
73
73
  }
74
74
 
75
75
  // Support both base64 encoded images and file paths
76
+ // Use explicit type from client if provided (fast path), otherwise detect (slow path)
77
+ // Only accept valid type values to prevent invalid types from bypassing detection
76
78
  let imageBuffer;
77
- const inputType = detectImageInputType(image);
79
+ let validTypes = ['base64', 'file-path'];
80
+ const inputType = type && validTypes.includes(type) ? type : detectImageInputType(image);
78
81
  if (inputType === 'file-path') {
79
82
  // It's a file path - resolve and read the file
80
83
  const filePath = resolve(image.replace('file://', ''));
@@ -267,7 +267,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
267
267
  output.debug('tdd', `baseline: ${baseline.buildName}`);
268
268
  }
269
269
  };
270
- const handleScreenshot = async (_buildId, name, image, properties = {}) => {
270
+ const handleScreenshot = async (_buildId, name, image, properties = {}, type) => {
271
271
  // Validate and sanitize screenshot name
272
272
  let sanitizedName;
273
273
  try {
@@ -306,8 +306,11 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
306
306
 
307
307
  // Support both base64 encoded images and file paths
308
308
  // Vitest browser mode returns file paths, so we need to handle both
309
+ // Use explicit type from client if provided (fast path), otherwise detect (slow path)
310
+ // Only accept valid type values to prevent invalid types from bypassing detection
309
311
  let imageBuffer;
310
- const inputType = detectImageInputType(image);
312
+ let validTypes = ['base64', 'file-path'];
313
+ const inputType = type && validTypes.includes(type) ? type : detectImageInputType(image);
311
314
  if (inputType === 'file-path') {
312
315
  // It's a file path - resolve and read the file
313
316
  const filePath = resolve(image.replace('file://', ''));
@@ -31,7 +31,8 @@ export function createScreenshotRouter({
31
31
  buildId,
32
32
  name,
33
33
  properties,
34
- image
34
+ image,
35
+ type
35
36
  } = body;
36
37
  if (!name || !image) {
37
38
  sendError(res, 400, 'name and image are required');
@@ -40,7 +41,7 @@ export function createScreenshotRouter({
40
41
 
41
42
  // Use buildId from request body, or fall back to server's buildId
42
43
  const effectiveBuildId = buildId || defaultBuildId;
43
- const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, properties);
44
+ const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, properties, type);
44
45
  sendJson(res, result.statusCode, result.body);
45
46
  return true;
46
47
  } catch (error) {
@@ -81,41 +81,46 @@ export function looksLikeFilePath(str) {
81
81
  return false;
82
82
  }
83
83
 
84
- // 0. Explicitly reject data URIs first (they contain : and / which would match path patterns)
84
+ // 0. Length check - file paths are short, base64 screenshots are huge
85
+ // Even the longest realistic file path is < 500 chars
86
+ // This makes detection O(1) for large base64 strings
87
+ // Use same threshold (1000) as detectImageInputType for consistency
88
+ if (str.length > 1000) {
89
+ return false;
90
+ }
91
+
92
+ // 1. Explicitly reject data URIs (they contain : and / which would match path patterns)
85
93
  if (str.startsWith('data:')) {
86
94
  return false;
87
95
  }
88
96
 
89
- // 1. Check for file:// URI scheme
97
+ // 2. Check for file:// URI scheme
90
98
  if (str.startsWith('file://')) {
91
99
  return true;
92
100
  }
93
101
 
94
- // 2. Check for absolute paths (Unix or Windows)
95
- // Unix: starts with /
96
- // Windows: starts with drive letter like C:\ or C:/
97
- if (str.startsWith('/') || /^[A-Za-z]:[/\\]/.test(str)) {
102
+ // 3. Windows absolute paths (C:\ or C:/) - base64 never starts with drive letter
103
+ if (/^[A-Za-z]:[/\\]/.test(str)) {
98
104
  return true;
99
105
  }
100
106
 
101
- // 3. Check for relative path indicators
102
- // ./ or ../ or .\ or ..\
107
+ // 4. Relative path indicators (./ or ../) - base64 never starts with dot
103
108
  if (/^\.\.?[/\\]/.test(str)) {
104
109
  return true;
105
110
  }
106
111
 
107
- // 4. Check for path separators (forward or back slash)
108
- // This catches paths like: subdirectory/file.png or subdirectory\file.png
109
- if (/[/\\]/.test(str)) {
110
- return true;
111
- }
112
-
113
112
  // 5. Check for common image file extensions
114
- // This catches simple filenames like: screenshot.png
115
- // Common extensions: png, jpg, jpeg, gif, webp, bmp, svg, tiff, ico
113
+ // This is the safest check - base64 never ends with .png/.jpg/etc
114
+ // Catches: /path/file.png, subdir/file.png, file.png
116
115
  if (/\.(png|jpe?g|gif|webp|bmp|svg|tiff?|ico)$/i.test(str)) {
117
116
  return true;
118
117
  }
118
+
119
+ // Note: We intentionally don't check for bare "/" prefix or "/" anywhere
120
+ // because JPEG base64 starts with "/9j/" which would false-positive
121
+ // File paths without extensions are rare for images and will fall through
122
+ // to base64 detection, which is acceptable for backwards compat
123
+
119
124
  return false;
120
125
  }
121
126
 
@@ -127,14 +132,13 @@ export function looksLikeFilePath(str) {
127
132
  * - 'file-path': A file path (relative or absolute)
128
133
  * - 'unknown': Cannot determine (ambiguous or invalid)
129
134
  *
130
- * Strategy:
131
- * 1. First check if it's valid base64 (can contain / which might look like paths)
132
- * 2. Then check if it looks like a file path (more specific patterns)
133
- * 3. Otherwise return 'unknown'
135
+ * Strategy (optimized for performance):
136
+ * 1. Check for data URI prefix first (O(1), definitive)
137
+ * 2. Check file path patterns (O(1) prefix/suffix checks)
138
+ * 3. For large non-path strings, assume base64 (skip expensive validation)
139
+ * 4. Only run full base64 validation on small ambiguous strings
134
140
  *
135
- * This order prevents base64 strings (which can contain /) from being
136
- * misidentified as file paths. Base64 validation is stricter and should
137
- * be checked first.
141
+ * This avoids O(n) regex validation on large screenshot buffers.
138
142
  *
139
143
  * @param {string} str - String to detect
140
144
  * @returns {'base64' | 'file-path' | 'unknown'} Detected input type
@@ -151,15 +155,26 @@ export function detectImageInputType(str) {
151
155
  return 'unknown';
152
156
  }
153
157
 
154
- // Check base64 FIRST - base64 strings can contain / which looks like paths
155
- // Base64 validation is stricter and more deterministic
156
- if (isBase64(str)) {
158
+ // 1. Data URIs are definitively base64 (O(1) check)
159
+ if (str.startsWith('data:')) {
157
160
  return 'base64';
158
161
  }
159
162
 
160
- // Then check file path - catch patterns that aren't valid base64
163
+ // 2. Check file path patterns (O(1) prefix/suffix checks)
161
164
  if (looksLikeFilePath(str)) {
162
165
  return 'file-path';
163
166
  }
167
+
168
+ // 3. For large strings that aren't file paths, assume base64
169
+ // Screenshots are typically 100KB+ as base64, file paths are <1KB
170
+ // Skip expensive O(n) validation for large strings
171
+ if (str.length > 1000) {
172
+ return 'base64';
173
+ }
174
+
175
+ // 4. Full validation only for small ambiguous strings
176
+ if (isBase64(str)) {
177
+ return 'base64';
178
+ }
164
179
  return 'unknown';
165
180
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Session management for build context
3
+ *
4
+ * Tracks the current build ID so subsequent commands (like `vizzly preview`)
5
+ * can automatically attach to the right build without passing IDs around.
6
+ *
7
+ * Two mechanisms:
8
+ * 1. Session file (.vizzly/session.json) - for local dev and same CI job
9
+ * 2. GitHub Actions env ($GITHUB_ENV) - for cross-step persistence in GHA
10
+ */
11
+
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ let SESSION_DIR = '.vizzly';
15
+ let SESSION_FILE = 'session.json';
16
+ let SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
17
+
18
+ /**
19
+ * Get the session file path
20
+ * @param {string} [cwd] - Working directory (defaults to process.cwd())
21
+ * @returns {string} Path to session file
22
+ */
23
+ export function getSessionPath(cwd = process.cwd()) {
24
+ return join(cwd, SESSION_DIR, SESSION_FILE);
25
+ }
26
+
27
+ /**
28
+ * Write build context to session file and GitHub Actions env
29
+ *
30
+ * @param {Object} context - Build context
31
+ * @param {string} context.buildId - The build ID
32
+ * @param {string} [context.branch] - Git branch
33
+ * @param {string} [context.commit] - Git commit SHA
34
+ * @param {string} [context.parallelId] - Parallel build ID
35
+ * @param {Object} [options] - Options
36
+ * @param {string} [options.cwd] - Working directory
37
+ * @param {Object} [options.env] - Environment variables (defaults to process.env)
38
+ */
39
+ export function writeSession(context, options = {}) {
40
+ let {
41
+ cwd = process.cwd(),
42
+ env = process.env
43
+ } = options;
44
+ let session = {
45
+ buildId: context.buildId,
46
+ branch: context.branch || null,
47
+ commit: context.commit || null,
48
+ parallelId: context.parallelId || null,
49
+ createdAt: new Date().toISOString()
50
+ };
51
+
52
+ // Write session file
53
+ let sessionPath = getSessionPath(cwd);
54
+ let sessionDir = dirname(sessionPath);
55
+ if (!existsSync(sessionDir)) {
56
+ mkdirSync(sessionDir, {
57
+ recursive: true
58
+ });
59
+ }
60
+ writeFileSync(sessionPath, `${JSON.stringify(session, null, 2)}\n`, {
61
+ mode: 0o600
62
+ });
63
+
64
+ // Write to GitHub Actions environment
65
+ if (env.GITHUB_ENV) {
66
+ appendFileSync(env.GITHUB_ENV, `VIZZLY_BUILD_ID=${context.buildId}\n`);
67
+ }
68
+
69
+ // Also write to GitHub Actions output if we're in a step
70
+ if (env.GITHUB_OUTPUT) {
71
+ appendFileSync(env.GITHUB_OUTPUT, `build-id=${context.buildId}\n`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Read build context from session file or environment
77
+ *
78
+ * Priority:
79
+ * 1. VIZZLY_BUILD_ID environment variable
80
+ * 2. Session file (if recent and optionally matching branch)
81
+ *
82
+ * @param {Object} [options] - Options
83
+ * @param {string} [options.cwd] - Working directory
84
+ * @param {string} [options.currentBranch] - Current git branch (for validation)
85
+ * @param {Object} [options.env] - Environment variables
86
+ * @param {number} [options.maxAgeMs] - Max session age in ms
87
+ * @returns {Object|null} Session context or null if not found/invalid
88
+ */
89
+ export function readSession(options = {}) {
90
+ let {
91
+ cwd = process.cwd(),
92
+ currentBranch = null,
93
+ env = process.env,
94
+ maxAgeMs = SESSION_MAX_AGE_MS
95
+ } = options;
96
+
97
+ // Check environment variable first
98
+ if (env.VIZZLY_BUILD_ID) {
99
+ return {
100
+ buildId: env.VIZZLY_BUILD_ID,
101
+ source: 'environment'
102
+ };
103
+ }
104
+
105
+ // Try session file
106
+ let sessionPath = getSessionPath(cwd);
107
+ if (!existsSync(sessionPath)) {
108
+ return null;
109
+ }
110
+ try {
111
+ let content = readFileSync(sessionPath, 'utf-8');
112
+ let session = JSON.parse(content);
113
+
114
+ // Validate required field
115
+ if (!session.buildId) {
116
+ return null;
117
+ }
118
+
119
+ // Check age
120
+ let createdAt = new Date(session.createdAt);
121
+ let age = Date.now() - createdAt.getTime();
122
+ if (age > maxAgeMs) {
123
+ return {
124
+ ...session,
125
+ source: 'session_file',
126
+ expired: true,
127
+ age
128
+ };
129
+ }
130
+
131
+ // Check branch match (if current branch provided)
132
+ let branchMismatch = false;
133
+ if (currentBranch && session.branch && session.branch !== currentBranch) {
134
+ branchMismatch = true;
135
+ }
136
+ return {
137
+ ...session,
138
+ source: 'session_file',
139
+ expired: false,
140
+ branchMismatch,
141
+ age
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Clear the session file
150
+ *
151
+ * @param {Object} [options] - Options
152
+ * @param {string} [options.cwd] - Working directory
153
+ */
154
+ export function clearSession(options = {}) {
155
+ let {
156
+ cwd = process.cwd()
157
+ } = options;
158
+ let sessionPath = getSessionPath(cwd);
159
+ try {
160
+ if (existsSync(sessionPath)) {
161
+ writeFileSync(sessionPath, '');
162
+ }
163
+ } catch {
164
+ // Ignore errors
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Format session age for display
170
+ *
171
+ * @param {number} ageMs - Age in milliseconds
172
+ * @returns {string} Human-readable age
173
+ */
174
+ export function formatSessionAge(ageMs) {
175
+ let seconds = Math.floor(ageMs / 1000);
176
+ let minutes = Math.floor(seconds / 60);
177
+ let hours = Math.floor(minutes / 60);
178
+ if (hours > 0) {
179
+ return `${hours}h ${minutes % 60}m ago`;
180
+ }
181
+ if (minutes > 0) {
182
+ return `${minutes}m ago`;
183
+ }
184
+ return `${seconds}s ago`;
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",
@@ -88,7 +88,6 @@
88
88
  },
89
89
  "dependencies": {
90
90
  "@vizzly-testing/honeydiff": "^0.8.0",
91
- "@vizzly-testing/static-site": "^0.0.11",
92
91
  "ansis": "^4.2.0",
93
92
  "commander": "^14.0.0",
94
93
  "cosmiconfig": "^9.0.0",