diff --git a/src/api/endpoints.js b/src/api/endpoints.js index 13a0f127..f0e56100 100644 --- a/src/api/endpoints.js +++ b/src/api/endpoints.js @@ -320,3 +320,47 @@ export async function finalizeParallelBuild(client, parallelId) { headers: { 'Content-Type': 'application/json' }, }); } + +// ============================================================================ +// Preview Endpoints +// ============================================================================ + +/** + * Upload preview ZIP file for a build + * @param {Object} client - API client + * @param {string} buildId - Build ID + * @param {Buffer} zipBuffer - ZIP file contents + * @returns {Promise} Upload result with preview URL + */ +export async function uploadPreviewZip(client, buildId, zipBuffer) { + // Create form data with the ZIP file + let FormData = (await import('form-data')).default; + let formData = new FormData(); + formData.append('file', zipBuffer, { + filename: 'preview.zip', + contentType: 'application/zip', + }); + + return client.request(`/api/sdk/builds/${buildId}/preview/upload-zip`, { + method: 'POST', + body: formData, + headers: formData.getHeaders(), + }); +} + +/** + * Get preview info for a build + * @param {Object} client - API client + * @param {string} buildId - Build ID + * @returns {Promise} Preview info or null if not found + */ +export async function getPreviewInfo(client, buildId) { + try { + return await client.request(`/api/sdk/builds/${buildId}/preview`); + } catch (error) { + if (error.status === 404) { + return null; + } + throw error; + } +} diff --git a/src/api/index.js b/src/api/index.js index f7ff7855..8dcc9422 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -45,10 +45,12 @@ export { getBuild, getBuilds, getComparison, + getPreviewInfo, getScreenshotHotspots, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, + uploadPreviewZip, uploadScreenshot, } from './endpoints.js'; diff --git a/src/cli.js b/src/cli.js index 9e9aa574..255d4d3a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -9,6 +9,7 @@ import { import { init } from './commands/init.js'; import { loginCommand, validateLoginOptions } from './commands/login.js'; import { logoutCommand, validateLogoutOptions } from './commands/logout.js'; +import { previewCommand, validatePreviewOptions } from './commands/preview.js'; import { projectListCommand, projectRemoveCommand, @@ -568,6 +569,30 @@ program await finalizeCommand(parallelId, options, globalOptions); }); +program + .command('preview') + .description('Upload static files as a preview for a build') + .argument('', 'Path to static files (dist/, build/, out/)') + .option('-b, --build ', 'Build ID to attach preview to') + .option('-p, --parallel-id ', 'Look up build by parallel ID') + .option('--base ', 'Override auto-detected base path') + .option('--open', 'Open preview URL in browser after upload') + .action(async (path, options) => { + const globalOptions = program.opts(); + + // Validate options + const validationErrors = validatePreviewOptions(path, options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await previewCommand(path, options, globalOptions); + }); + program .command('doctor') .description('Run diagnostics to check your environment and configuration') diff --git a/src/commands/finalize.js b/src/commands/finalize.js index 1da31420..905b6b6c 100644 --- a/src/commands/finalize.js +++ b/src/commands/finalize.js @@ -9,6 +9,7 @@ import { } from '../api/index.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import * as defaultOutput from '../utils/output.js'; +import { writeSession as defaultWriteSession } from '../utils/session.js'; /** * Finalize command implementation @@ -28,6 +29,7 @@ export async function finalizeCommand( createApiClient = defaultCreateApiClient, finalizeParallelBuild = defaultFinalizeParallelBuild, output = defaultOutput, + writeSession = defaultWriteSession, exit = code => process.exit(code), } = deps; @@ -69,6 +71,14 @@ export async function finalizeCommand( let result = await finalizeParallelBuild(client, parallelId); output.stopSpinner(); + // Write session for subsequent commands (like preview) + if (result.build?.id) { + writeSession({ + buildId: result.build.id, + parallelId, + }); + } + if (globalOptions.json) { output.data(result); } else { diff --git a/src/commands/preview.js b/src/commands/preview.js new file mode 100644 index 00000000..b946a084 --- /dev/null +++ b/src/commands/preview.js @@ -0,0 +1,458 @@ +/** + * Preview command implementation + * + * Uploads static files as a preview for a Vizzly build. + * The build is automatically detected from session file or environment. + */ + +import { exec, execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { existsSync, statSync } from 'node:fs'; +import { readFile, realpath, stat, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { + createApiClient as defaultCreateApiClient, + uploadPreviewZip as defaultUploadPreviewZip, +} from '../api/index.js'; +import { openBrowser as defaultOpenBrowser } from '../utils/browser.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { detectBranch as defaultDetectBranch } from '../utils/git.js'; +import * as defaultOutput from '../utils/output.js'; +import { + formatSessionAge as defaultFormatSessionAge, + readSession as defaultReadSession, +} from '../utils/session.js'; + +let execAsync = promisify(exec); + +/** + * Validate path for shell safety - prevents command injection + * @param {string} path - Path to validate + * @returns {boolean} true if path is safe for shell use + */ +function isPathSafe(path) { + // Reject paths with shell metacharacters that could enable command injection + let dangerousChars = /[`$;&|<>(){}[\]\\!*?'"]/; + return !dangerousChars.test(path); +} + +/** + * Check if a command exists on the system + * @param {string} command - Command to check + * @returns {boolean} + */ +function commandExists(command) { + try { + let checkCmd = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${checkCmd} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Get the appropriate zip command for the current platform + * @returns {{ command: string, available: boolean }} + */ +function getZipCommand() { + // Check for standard zip command (macOS, Linux, Windows with Git Bash) + if (commandExists('zip')) { + return { command: 'zip', available: true }; + } + + // Windows: Check for PowerShell Compress-Archive + if (process.platform === 'win32') { + return { command: 'powershell', available: true }; + } + + return { command: null, available: false }; +} + +/** + * Create a ZIP file from a directory using system commands + * @param {string} sourceDir - Directory to zip + * @param {string} outputPath - Path for output ZIP file + * @returns {Promise} + */ +async function createZipWithSystem(sourceDir, outputPath) { + let { command, available } = getZipCommand(); + + if (!available) { + throw new Error( + 'No zip command found. Please install zip or use PowerShell on Windows.' + ); + } + + // Validate paths to prevent command injection + // Note: outputPath is internally generated (tmpdir + random), so always safe + // sourceDir comes from user input, so we validate it + if (!isPathSafe(sourceDir)) { + throw new Error( + 'Path contains unsupported characters. Please use a path without special shell characters.' + ); + } + + if (command === 'zip') { + // Standard zip command - create ZIP from directory contents + // Using cwd option is safe as it's not part of the command string + // -r: recursive, -q: quiet + await execAsync(`zip -r -q "${outputPath}" .`, { + cwd: sourceDir, + maxBuffer: 1024 * 1024 * 100, // 100MB buffer + }); + } else if (command === 'powershell') { + // Windows PowerShell - use -LiteralPath for safer path handling + // Escape single quotes in paths by doubling them + let safeSrcDir = sourceDir.replace(/'/g, "''"); + let safeOutPath = outputPath.replace(/'/g, "''"); + await execAsync( + `powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, + { maxBuffer: 1024 * 1024 * 100 } + ); + } +} + +/** + * Count files in a directory recursively + * Skips symlinks to prevent path traversal attacks + * @param {string} dir - Directory path + * @returns {Promise<{ count: number, totalSize: number }>} + */ +async function countFiles(dir) { + let { readdir } = await import('node:fs/promises'); + let count = 0; + let totalSize = 0; + + // Resolve the base directory to an absolute path for traversal checks + let baseDir = await realpath(resolve(dir)); + + async function walk(currentDir) { + let entries = await readdir(currentDir, { withFileTypes: true }); + + for (let entry of entries) { + let fullPath = join(currentDir, entry.name); + + // Skip symlinks to prevent traversal attacks + if (entry.isSymbolicLink()) { + continue; + } + + if (entry.isDirectory()) { + // Skip hidden directories and common non-content directories + if ( + entry.name.startsWith('.') || + entry.name === 'node_modules' || + entry.name === '__pycache__' + ) { + continue; + } + + // Verify we're still within the base directory (prevent traversal) + let realSubDir = await realpath(fullPath); + if (!realSubDir.startsWith(baseDir)) { + continue; + } + + await walk(fullPath); + } else if (entry.isFile()) { + // Skip hidden files + if (entry.name.startsWith('.')) { + continue; + } + count++; + let fileStat = await stat(fullPath); + totalSize += fileStat.size; + } + } + } + + await walk(baseDir); + return { count, totalSize }; +} + +/** + * Format bytes for display + * @param {number} bytes - Bytes to format + * @returns {string} + */ +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * Preview command implementation + * @param {string} path - Path to static files + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + * @param {Object} deps - Dependencies for testing + */ +export async function previewCommand( + path, + options = {}, + globalOptions = {}, + deps = {} +) { + let { + loadConfig = defaultLoadConfig, + createApiClient = defaultCreateApiClient, + uploadPreviewZip = defaultUploadPreviewZip, + readSession = defaultReadSession, + formatSessionAge = defaultFormatSessionAge, + detectBranch = defaultDetectBranch, + openBrowser = defaultOpenBrowser, + output = defaultOutput, + exit = code => process.exit(code), + } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + // Load configuration + let allOptions = { ...globalOptions, ...options }; + let config = await loadConfig(globalOptions.config, allOptions); + + // Validate API token + if (!config.apiKey) { + output.error( + 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + ); + exit(1); + return { success: false, reason: 'no-api-key' }; + } + + // Validate path exists and is a directory + if (!existsSync(path)) { + output.error(`Path does not exist: ${path}`); + exit(1); + return { success: false, reason: 'path-not-found' }; + } + + let pathStat = statSync(path); + if (!pathStat.isDirectory()) { + output.error(`Path is not a directory: ${path}`); + exit(1); + return { success: false, reason: 'not-a-directory' }; + } + + // Resolve build ID + let buildId = options.build; + let buildSource = 'flag'; + + if (!buildId && options.parallelId) { + // TODO: Look up build by parallel ID + output.error( + 'Parallel ID lookup not yet implemented. Use --build to specify build ID directly.' + ); + exit(1); + return { success: false, reason: 'parallel-id-not-implemented' }; + } + + if (!buildId) { + // Try to read from session + let currentBranch = await detectBranch(); + let session = readSession({ currentBranch }); + + if (session?.buildId && !session.expired) { + if (session.branchMismatch) { + output.warn( + `Session build is from different branch (${session.branch})` + ); + output.hint( + `Use --build to specify a build ID, or run tests on this branch first.` + ); + exit(1); + return { success: false, reason: 'branch-mismatch' }; + } + + buildId = session.buildId; + buildSource = session.source; + + if (globalOptions.verbose) { + output.info( + `Using build ${buildId} from ${buildSource} (${formatSessionAge(session.age)})` + ); + } + } + } + + if (!buildId) { + output.error('No build found'); + output.blank(); + output.print(' Run visual tests first, then upload your preview:'); + output.blank(); + output.print(' vizzly run "npm test"'); + output.print(' vizzly preview ./dist'); + output.blank(); + output.print(' Or specify a build explicitly:'); + output.blank(); + output.print(' vizzly preview ./dist --build '); + output.blank(); + exit(1); + return { success: false, reason: 'no-build' }; + } + + // Check for zip command availability + let zipInfo = getZipCommand(); + if (!zipInfo.available) { + output.error( + 'No zip command found. Please install zip (macOS/Linux) or ensure PowerShell is available (Windows).' + ); + exit(1); + return { success: false, reason: 'no-zip-command' }; + } + + // Count files and calculate size + output.startSpinner('Scanning files...'); + let { count: fileCount, totalSize } = await countFiles(path); + + if (fileCount === 0) { + output.stopSpinner(); + output.error(`No files found in ${path}`); + exit(1); + return { success: false, reason: 'no-files' }; + } + + output.updateSpinner( + `Found ${fileCount} files (${formatBytes(totalSize)})` + ); + + // Create ZIP using system command + output.updateSpinner('Compressing files...'); + // Use timestamp + random bytes for unique temp file (prevents race conditions) + let randomSuffix = randomBytes(8).toString('hex'); + let zipPath = join( + tmpdir(), + `vizzly-preview-${Date.now()}-${randomSuffix}.zip` + ); + + let zipBuffer; + try { + await createZipWithSystem(path, zipPath); + zipBuffer = await readFile(zipPath); + } catch (zipError) { + output.stopSpinner(); + output.error(`Failed to create ZIP: ${zipError.message}`); + await unlink(zipPath).catch(() => {}); + exit(1); + return { success: false, reason: 'zip-failed', error: zipError }; + } finally { + // Always clean up temp file + await unlink(zipPath).catch(() => {}); + } + + let compressionRatio = ((1 - zipBuffer.length / totalSize) * 100).toFixed( + 0 + ); + output.updateSpinner( + `Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)` + ); + + // Upload + output.updateSpinner('Uploading preview...'); + let client = createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'preview', + }); + + let result = await uploadPreviewZip(client, buildId, zipBuffer); + output.stopSpinner(); + + // Success output + if (globalOptions.json) { + output.data({ + success: true, + buildId, + previewUrl: result.previewUrl, + files: result.uploaded, + totalBytes: result.totalBytes, + newBytes: result.newBytes, + deduplicationRatio: result.deduplicationRatio, + }); + } else { + output.complete('Preview uploaded'); + output.blank(); + + let colors = output.getColors(); + output.print( + ` ${colors.brand.textTertiary('Files')} ${colors.white(result.uploaded)} (${formatBytes(result.totalBytes)} compressed)` + ); + + if (result.reusedBlobs > 0) { + let savedBytes = result.totalBytes - result.newBytes; + output.print( + ` ${colors.brand.textTertiary('Deduped')} ${colors.green(result.reusedBlobs)} files (saved ${formatBytes(savedBytes)})` + ); + } + + if (result.basePath) { + output.print( + ` ${colors.brand.textTertiary('Base path')} ${colors.dim(result.basePath)}` + ); + } + + output.blank(); + output.print( + ` ${colors.brand.textTertiary('Preview')} ${colors.cyan(colors.underline(result.previewUrl))}` + ); + + // Open in browser if requested + if (options.open) { + let opened = await openBrowser(result.previewUrl); + if (opened) { + output.print(` ${colors.dim('Opened in browser')}`); + } + } + } + + output.cleanup(); + return { success: true, result }; + } catch (error) { + output.stopSpinner(); + + // Handle specific error types + if (error.status === 404) { + output.error(`Build not found: ${options.build || 'from session'}`); + } else if (error.status === 403) { + if (error.message?.includes('Starter')) { + output.error('Preview hosting requires Starter plan or above'); + output.hint( + 'Upgrade your plan at https://app.vizzly.dev/settings/billing' + ); + } else { + output.error('Access denied', error); + } + } else { + output.error('Preview upload failed', error); + } + + exit(1); + return { success: false, error }; + } finally { + output.cleanup(); + } +} + +/** + * Validate preview options + * @param {string} path - Path to static files + * @param {Object} options - Command options + */ +export function validatePreviewOptions(path, _options) { + let errors = []; + + if (!path || path.trim() === '') { + errors.push('Path to static files is required'); + } + + return errors; +} diff --git a/src/commands/run.js b/src/commands/run.js index 84edf08a..545f2eaa 100644 --- a/src/commands/run.js +++ b/src/commands/run.js @@ -28,6 +28,7 @@ import { generateBuildNameWithGit as defaultGenerateBuildNameWithGit, } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; +import { writeSession as defaultWriteSession } from '../utils/session.js'; /** * Run command implementation @@ -61,6 +62,7 @@ export async function runCommand( generateBuildNameWithGit = defaultGenerateBuildNameWithGit, spawn = defaultSpawn, output = defaultOutput, + writeSession = defaultWriteSession, exit = code => process.exit(code), processOn = (event, handler) => process.on(event, handler), processRemoveListener = (event, handler) => @@ -272,6 +274,15 @@ export async function runCommand( onBuildCreated: data => { buildUrl = data.url; buildId = data.buildId; + + // Write session for subsequent commands (like preview) + writeSession({ + buildId: data.buildId, + branch, + commit, + parallelId: runOptions.parallelId, + }); + if (globalOptions.verbose) { output.info(`Build created: ${data.buildId}`); } diff --git a/src/commands/status.js b/src/commands/status.js index 714d59c8..8e6d2626 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -3,7 +3,7 @@ * Uses functional API operations directly */ -import { createApiClient, getBuild } from '../api/index.js'; +import { createApiClient, getBuild, getPreviewInfo } from '../api/index.js'; import { loadConfig } from '../utils/config-loader.js'; import { getApiUrl } from '../utils/environment-config.js'; import * as output from '../utils/output.js'; @@ -42,6 +42,9 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { command: 'status', }); let buildStatus = await getBuild(client, buildId); + + // Also fetch preview info (if exists) + let previewInfo = await getPreviewInfo(client, buildId); output.stopSpinner(); // Extract build data from API response @@ -69,6 +72,14 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { executionTime: build.execution_time_ms, isBaseline: build.is_baseline, userAgent: build.user_agent, + preview: previewInfo + ? { + url: previewInfo.preview_url, + status: previewInfo.status, + fileCount: previewInfo.file_count, + expiresAt: previewInfo.expires_at, + } + : null, }; output.data(statusData); output.cleanup(); @@ -156,6 +167,18 @@ export async function statusCommand(buildId, options = {}, globalOptions = {}) { output.labelValue('View', output.link('Build', buildUrl)); } + // Show preview URL if available + if (previewInfo?.preview_url) { + output.labelValue( + 'Preview', + output.link('Preview', previewInfo.preview_url) + ); + if (previewInfo.expires_at) { + let expiresDate = new Date(previewInfo.expires_at); + output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`); + } + } + // Show additional info in verbose mode if (globalOptions.verbose) { output.blank(); diff --git a/src/commands/upload.js b/src/commands/upload.js index 9523f563..11c518f0 100644 --- a/src/commands/upload.js +++ b/src/commands/upload.js @@ -13,6 +13,7 @@ import { generateBuildNameWithGit as defaultGenerateBuildNameWithGit, } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; +import { writeSession as defaultWriteSession } from '../utils/session.js'; /** * Construct proper build URL with org/project context @@ -78,6 +79,7 @@ export async function uploadCommand( detectPullRequestNumber = defaultDetectPullRequestNumber, generateBuildNameWithGit = defaultGenerateBuildNameWithGit, output = defaultOutput, + writeSession = defaultWriteSession, exit = code => process.exit(code), buildUrlConstructor = constructBuildUrl, } = deps; @@ -175,6 +177,16 @@ export async function uploadCommand( const result = await uploader.upload(uploadOptions); buildId = result.buildId; // Ensure we have the buildId + // Write session for subsequent commands (like preview) + if (buildId) { + writeSession({ + buildId, + branch, + commit, + parallelId: config.parallelId, + }); + } + // Mark build as completed if (result.buildId) { output.progress('Finalizing build...'); diff --git a/src/utils/session.js b/src/utils/session.js new file mode 100644 index 00000000..1f5d9afa --- /dev/null +++ b/src/utils/session.js @@ -0,0 +1,194 @@ +/** + * Session management for build context + * + * Tracks the current build ID so subsequent commands (like `vizzly preview`) + * can automatically attach to the right build without passing IDs around. + * + * Two mechanisms: + * 1. Session file (.vizzly/session.json) - for local dev and same CI job + * 2. GitHub Actions env ($GITHUB_ENV) - for cross-step persistence in GHA + */ + +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; + +let SESSION_DIR = '.vizzly'; +let SESSION_FILE = 'session.json'; +let SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour + +/** + * Get the session file path + * @param {string} [cwd] - Working directory (defaults to process.cwd()) + * @returns {string} Path to session file + */ +export function getSessionPath(cwd = process.cwd()) { + return join(cwd, SESSION_DIR, SESSION_FILE); +} + +/** + * Write build context to session file and GitHub Actions env + * + * @param {Object} context - Build context + * @param {string} context.buildId - The build ID + * @param {string} [context.branch] - Git branch + * @param {string} [context.commit] - Git commit SHA + * @param {string} [context.parallelId] - Parallel build ID + * @param {Object} [options] - Options + * @param {string} [options.cwd] - Working directory + * @param {Object} [options.env] - Environment variables (defaults to process.env) + */ +export function writeSession(context, options = {}) { + let { cwd = process.cwd(), env = process.env } = options; + + let session = { + buildId: context.buildId, + branch: context.branch || null, + commit: context.commit || null, + parallelId: context.parallelId || null, + createdAt: new Date().toISOString(), + }; + + // Write session file + let sessionPath = getSessionPath(cwd); + let sessionDir = dirname(sessionPath); + + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + writeFileSync(sessionPath, `${JSON.stringify(session, null, 2)}\n`, { + mode: 0o600, + }); + + // Write to GitHub Actions environment + if (env.GITHUB_ENV) { + appendFileSync(env.GITHUB_ENV, `VIZZLY_BUILD_ID=${context.buildId}\n`); + } + + // Also write to GitHub Actions output if we're in a step + if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `build-id=${context.buildId}\n`); + } +} + +/** + * Read build context from session file or environment + * + * Priority: + * 1. VIZZLY_BUILD_ID environment variable + * 2. Session file (if recent and optionally matching branch) + * + * @param {Object} [options] - Options + * @param {string} [options.cwd] - Working directory + * @param {string} [options.currentBranch] - Current git branch (for validation) + * @param {Object} [options.env] - Environment variables + * @param {number} [options.maxAgeMs] - Max session age in ms + * @returns {Object|null} Session context or null if not found/invalid + */ +export function readSession(options = {}) { + let { + cwd = process.cwd(), + currentBranch = null, + env = process.env, + maxAgeMs = SESSION_MAX_AGE_MS, + } = options; + + // Check environment variable first + if (env.VIZZLY_BUILD_ID) { + return { + buildId: env.VIZZLY_BUILD_ID, + source: 'environment', + }; + } + + // Try session file + let sessionPath = getSessionPath(cwd); + + if (!existsSync(sessionPath)) { + return null; + } + + try { + let content = readFileSync(sessionPath, 'utf-8'); + let session = JSON.parse(content); + + // Validate required field + if (!session.buildId) { + return null; + } + + // Check age + let createdAt = new Date(session.createdAt); + let age = Date.now() - createdAt.getTime(); + + if (age > maxAgeMs) { + return { + ...session, + source: 'session_file', + expired: true, + age, + }; + } + + // Check branch match (if current branch provided) + let branchMismatch = false; + if (currentBranch && session.branch && session.branch !== currentBranch) { + branchMismatch = true; + } + + return { + ...session, + source: 'session_file', + expired: false, + branchMismatch, + age, + }; + } catch { + return null; + } +} + +/** + * Clear the session file + * + * @param {Object} [options] - Options + * @param {string} [options.cwd] - Working directory + */ +export function clearSession(options = {}) { + let { cwd = process.cwd() } = options; + let sessionPath = getSessionPath(cwd); + + try { + if (existsSync(sessionPath)) { + writeFileSync(sessionPath, ''); + } + } catch { + // Ignore errors + } +} + +/** + * Format session age for display + * + * @param {number} ageMs - Age in milliseconds + * @returns {string} Human-readable age + */ +export function formatSessionAge(ageMs) { + let seconds = Math.floor(ageMs / 1000); + let minutes = Math.floor(seconds / 60); + let hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ago`; + } + if (minutes > 0) { + return `${minutes}m ago`; + } + return `${seconds}s ago`; +} diff --git a/tests/api/endpoints.test.js b/tests/api/endpoints.test.js index 02b2cf09..be67904c 100644 --- a/tests/api/endpoints.test.js +++ b/tests/api/endpoints.test.js @@ -16,11 +16,13 @@ import { getBuild, getBuilds, getComparison, + getPreviewInfo, getScreenshotHotspots, getTddBaselines, getTokenContext, searchComparisons, updateBuildStatus, + uploadPreviewZip, uploadScreenshot, } from '../../src/api/endpoints.js'; @@ -495,4 +497,85 @@ describe('api/endpoints', () => { assert.strictEqual(call.options.method, 'POST'); }); }); + + describe('uploadPreviewZip', () => { + it('uploads ZIP to correct endpoint', async () => { + let client = createMockClient({ + success: true, + previewUrl: 'https://preview.test', + uploaded: 10, + }); + + let zipBuffer = Buffer.from('fake zip data'); + let result = await uploadPreviewZip(client, 'build-123', zipBuffer); + + let call = client.getLastCall(); + assert.strictEqual( + call.endpoint, + '/api/sdk/builds/build-123/preview/upload-zip' + ); + assert.strictEqual(call.options.method, 'POST'); + assert.strictEqual(result.previewUrl, 'https://preview.test'); + }); + + it('sends ZIP as form data', async () => { + let client = createMockClient({ success: true }); + + let zipBuffer = Buffer.from('fake zip data'); + await uploadPreviewZip(client, 'build-123', zipBuffer); + + let call = client.getLastCall(); + // Should have form-data headers + assert.ok( + call.options.headers['content-type']?.includes('multipart/form-data') + ); + }); + }); + + describe('getPreviewInfo', () => { + it('fetches preview info for build', async () => { + let client = createMockClient({ + preview_id: 'preview-123', + status: 'active', + preview_url: 'https://preview.test', + file_count: 47, + }); + + let result = await getPreviewInfo(client, 'build-123'); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/builds/build-123/preview' + ); + assert.strictEqual(result.preview_url, 'https://preview.test'); + assert.strictEqual(result.file_count, 47); + }); + + it('returns null when preview not found (404)', async () => { + let error = new Error('Not found'); + error.status = 404; + + let client = createMockClient(() => { + throw error; + }); + + let result = await getPreviewInfo(client, 'build-123'); + + assert.strictEqual(result, null); + }); + + it('throws on other errors', async () => { + let error = new Error('Server error'); + error.status = 500; + + let client = createMockClient(() => { + throw error; + }); + + await assert.rejects( + () => getPreviewInfo(client, 'build-123'), + /Server error/ + ); + }); + }); }); diff --git a/tests/commands/preview.test.js b/tests/commands/preview.test.js new file mode 100644 index 00000000..823347d1 --- /dev/null +++ b/tests/commands/preview.test.js @@ -0,0 +1,364 @@ +import assert from 'node:assert'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { + previewCommand, + validatePreviewOptions, +} from '../../src/commands/preview.js'; + +/** + * Create mock output object that tracks calls + */ +function createMockOutput() { + let calls = []; + return { + calls, + configure: opts => calls.push({ method: 'configure', args: [opts] }), + info: msg => calls.push({ method: 'info', args: [msg] }), + debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }), + error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), + success: msg => calls.push({ method: 'success', args: [msg] }), + warn: msg => calls.push({ method: 'warn', args: [msg] }), + hint: msg => calls.push({ method: 'hint', args: [msg] }), + progress: (msg, cur, tot) => + calls.push({ method: 'progress', args: [msg, cur, tot] }), + startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), + updateSpinner: msg => calls.push({ method: 'updateSpinner', args: [msg] }), + stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), + cleanup: () => calls.push({ method: 'cleanup', args: [] }), + complete: (msg, opts) => + calls.push({ method: 'complete', args: [msg, opts] }), + keyValue: (data, opts) => + calls.push({ method: 'keyValue', args: [data, opts] }), + labelValue: (label, value, opts) => + calls.push({ method: 'labelValue', args: [label, value, opts] }), + blank: () => calls.push({ method: 'blank', args: [] }), + print: msg => calls.push({ method: 'print', args: [msg] }), + link: (_label, url) => url, + data: obj => calls.push({ method: 'data', args: [obj] }), + getColors: () => ({ + brand: { + textTertiary: s => s, + success: s => s, + danger: s => s, + }, + white: s => s, + green: s => s, + cyan: s => s, + dim: s => s, + underline: s => s, + }), + }; +} + +describe('validatePreviewOptions', () => { + it('passes with valid path', () => { + let errors = validatePreviewOptions('./dist', {}); + assert.strictEqual(errors.length, 0); + }); + + it('fails with missing path', () => { + let errors = validatePreviewOptions(null, {}); + assert.ok(errors.includes('Path to static files is required')); + }); + + it('fails with empty path', () => { + let errors = validatePreviewOptions('', {}); + assert.ok(errors.includes('Path to static files is required')); + }); + + it('fails with whitespace-only path', () => { + let errors = validatePreviewOptions(' ', {}); + assert.ok(errors.includes('Path to static files is required')); + }); +}); + +describe('previewCommand', () => { + let testDir; + let distDir; + + beforeEach(() => { + testDir = join(tmpdir(), `vizzly-preview-test-${Date.now()}`); + distDir = join(testDir, 'dist'); + mkdirSync(distDir, { recursive: true }); + + // Create some test files + writeFileSync(join(distDir, 'index.html'), ''); + writeFileSync(join(distDir, 'app.js'), 'console.log("hello")'); + mkdirSync(join(distDir, 'assets')); + writeFileSync(join(distDir, 'assets', 'style.css'), 'body {}'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('fails without API token', async () => { + let output = createMockOutput(); + let exitCode = null; + + await previewCommand( + distDir, + {}, + {}, + { + loadConfig: async () => ({ apiKey: null }), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + c => c.method === 'error' && c.args[0].includes('API token') + ), + 'Should show API token error' + ); + }); + + it('fails when path does not exist', async () => { + let output = createMockOutput(); + let exitCode = null; + + await previewCommand( + '/nonexistent/path', + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + c => c.method === 'error' && c.args[0].includes('does not exist') + ), + 'Should show path not found error' + ); + }); + + it('fails when no build ID is found', async () => { + let output = createMockOutput(); + let exitCode = null; + + await previewCommand( + distDir, + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + readSession: () => null, // No session + detectBranch: async () => 'main', + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + c => c.method === 'error' && c.args[0].includes('No build found') + ), + 'Should show no build found error' + ); + }); + + it('uses build ID from session when available', async () => { + let output = createMockOutput(); + let capturedBuildId = null; + + await previewCommand( + distDir, + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + readSession: () => ({ + buildId: 'session-build-123', + source: 'session_file', + expired: false, + branchMismatch: false, + age: 60000, + }), + formatSessionAge: () => '1m ago', + detectBranch: async () => 'main', + createApiClient: () => ({ + request: async () => ({}), + }), + uploadPreviewZip: async (_client, buildId) => { + capturedBuildId = buildId; + return { + previewUrl: 'https://preview.test', + uploaded: 3, + totalBytes: 1000, + newBytes: 800, + reusedBlobs: 0, + }; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(capturedBuildId, 'session-build-123'); + }); + + it('uses explicit build ID from options over session', async () => { + let output = createMockOutput(); + let capturedBuildId = null; + + await previewCommand( + distDir, + { build: 'explicit-build-456' }, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + readSession: () => ({ + buildId: 'session-build-123', + source: 'session_file', + expired: false, + }), + detectBranch: async () => 'main', + createApiClient: () => ({}), + uploadPreviewZip: async (_client, buildId) => { + capturedBuildId = buildId; + return { + previewUrl: 'https://preview.test', + uploaded: 3, + totalBytes: 1000, + newBytes: 800, + }; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(capturedBuildId, 'explicit-build-456'); + }); + + it('warns on branch mismatch', async () => { + let output = createMockOutput(); + let exitCode = null; + + await previewCommand( + distDir, + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + readSession: () => ({ + buildId: 'build-123', + branch: 'main', + source: 'session_file', + expired: false, + branchMismatch: true, + }), + detectBranch: async () => 'feature-branch', + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + c => c.method === 'warn' && c.args[0].includes('different branch') + ), + 'Should warn about branch mismatch' + ); + }); + + it('outputs JSON when --json flag is set', async () => { + let output = createMockOutput(); + + await previewCommand( + distDir, + { build: 'build-123' }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + uploadPreviewZip: async () => ({ + previewUrl: 'https://preview.test', + uploaded: 3, + totalBytes: 1000, + newBytes: 800, + deduplicationRatio: 0.2, + }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(c => c.method === 'data'); + assert.ok(dataCall, 'Should output JSON data'); + assert.strictEqual(dataCall.args[0].success, true); + assert.strictEqual(dataCall.args[0].previewUrl, 'https://preview.test'); + assert.strictEqual(dataCall.args[0].buildId, 'build-123'); + }); + + it('opens browser when --open flag is set', async () => { + let output = createMockOutput(); + let openedUrl = null; + + await previewCommand( + distDir, + { build: 'build-123', open: true }, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + uploadPreviewZip: async () => ({ + previewUrl: 'https://preview.test', + uploaded: 3, + totalBytes: 1000, + newBytes: 800, + }), + openBrowser: async url => { + openedUrl = url; + return true; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(openedUrl, 'https://preview.test'); + }); +}); diff --git a/tests/utils/session.test.js b/tests/utils/session.test.js new file mode 100644 index 00000000..49e415c8 --- /dev/null +++ b/tests/utils/session.test.js @@ -0,0 +1,203 @@ +import assert from 'node:assert'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { + clearSession, + formatSessionAge, + getSessionPath, + readSession, + writeSession, +} from '../../src/utils/session.js'; + +describe('session', () => { + let testDir; + + beforeEach(() => { + testDir = join(tmpdir(), `vizzly-session-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('getSessionPath', () => { + it('returns path within .vizzly directory', () => { + let path = getSessionPath('/some/project'); + assert.strictEqual(path, '/some/project/.vizzly/session.json'); + }); + }); + + describe('writeSession', () => { + it('creates session file with build context', () => { + writeSession( + { buildId: 'build-123', branch: 'main', commit: 'abc123' }, + { cwd: testDir } + ); + + let sessionPath = getSessionPath(testDir); + assert.ok(existsSync(sessionPath), 'Session file should exist'); + }); + + it('creates .vizzly directory if it does not exist', () => { + let newDir = join(testDir, 'subdir'); + mkdirSync(newDir); + + writeSession({ buildId: 'build-123' }, { cwd: newDir }); + + assert.ok( + existsSync(join(newDir, '.vizzly')), + '.vizzly directory should exist' + ); + }); + + it('writes to GITHUB_ENV when available', () => { + let githubEnvPath = join(testDir, 'github-env'); + writeFileSync(githubEnvPath, ''); + + writeSession( + { buildId: 'build-456' }, + { cwd: testDir, env: { GITHUB_ENV: githubEnvPath } } + ); + + let envContent = readFileSync(githubEnvPath, 'utf-8'); + assert.ok( + envContent.includes('VIZZLY_BUILD_ID=build-456'), + 'Should write build ID to GITHUB_ENV' + ); + }); + + it('writes to GITHUB_OUTPUT when available', () => { + let githubOutputPath = join(testDir, 'github-output'); + writeFileSync(githubOutputPath, ''); + + writeSession( + { buildId: 'build-789' }, + { cwd: testDir, env: { GITHUB_OUTPUT: githubOutputPath } } + ); + + let outputContent = readFileSync(githubOutputPath, 'utf-8'); + assert.ok( + outputContent.includes('build-id=build-789'), + 'Should write build ID to GITHUB_OUTPUT' + ); + }); + }); + + describe('readSession', () => { + it('returns null when no session file exists', () => { + let session = readSession({ cwd: testDir }); + assert.strictEqual(session, null); + }); + + it('reads build ID from environment variable first', () => { + // Create a session file with different build ID + writeSession({ buildId: 'file-build' }, { cwd: testDir }); + + // Environment variable should take precedence + let session = readSession({ + cwd: testDir, + env: { VIZZLY_BUILD_ID: 'env-build' }, + }); + + assert.strictEqual(session.buildId, 'env-build'); + assert.strictEqual(session.source, 'environment'); + }); + + it('reads from session file when env var not set', () => { + writeSession( + { buildId: 'file-build', branch: 'feature' }, + { cwd: testDir } + ); + + let session = readSession({ cwd: testDir, env: {} }); + + assert.strictEqual(session.buildId, 'file-build'); + assert.strictEqual(session.branch, 'feature'); + assert.strictEqual(session.source, 'session_file'); + }); + + it('marks session as expired when too old', () => { + // Write session with old timestamp + let sessionDir = join(testDir, '.vizzly'); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, 'session.json'), + JSON.stringify({ + buildId: 'old-build', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago + }) + ); + + let session = readSession({ + cwd: testDir, + env: {}, + maxAgeMs: 60 * 60 * 1000, // 1 hour + }); + + assert.strictEqual(session.buildId, 'old-build'); + assert.strictEqual(session.expired, true); + }); + + it('detects branch mismatch', () => { + writeSession({ buildId: 'build-123', branch: 'main' }, { cwd: testDir }); + + let session = readSession({ + cwd: testDir, + currentBranch: 'feature-branch', + env: {}, + }); + + assert.strictEqual(session.branchMismatch, true); + }); + + it('no branch mismatch when branches match', () => { + writeSession({ buildId: 'build-123', branch: 'main' }, { cwd: testDir }); + + let session = readSession({ + cwd: testDir, + currentBranch: 'main', + env: {}, + }); + + assert.strictEqual(session.branchMismatch, false); + }); + }); + + describe('clearSession', () => { + it('clears existing session file', () => { + writeSession({ buildId: 'build-123' }, { cwd: testDir }); + clearSession({ cwd: testDir }); + + let session = readSession({ cwd: testDir, env: {} }); + assert.strictEqual(session, null); + }); + + it('does not error when no session exists', () => { + // Should not throw + clearSession({ cwd: testDir }); + }); + }); + + describe('formatSessionAge', () => { + it('formats seconds', () => { + assert.strictEqual(formatSessionAge(30 * 1000), '30s ago'); + }); + + it('formats minutes', () => { + assert.strictEqual(formatSessionAge(5 * 60 * 1000), '5m ago'); + }); + + it('formats hours and minutes', () => { + assert.strictEqual(formatSessionAge(90 * 60 * 1000), '1h 30m ago'); + }); + }); +});