@vizzly-testing/cli 0.22.2 → 0.23.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.
@@ -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
 
@@ -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...');