@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.
- package/dist/api/endpoints.js +43 -0
- package/dist/api/index.js +1 -1
- package/dist/cli.js +15 -0
- package/dist/commands/finalize.js +10 -0
- package/dist/commands/preview.js +439 -0
- package/dist/commands/run.js +10 -0
- package/dist/commands/status.js +20 -2
- package/dist/commands/upload.js +12 -0
- package/dist/reporter/reporter-bundle.iife.js +19 -19
- package/dist/utils/ci-env.js +114 -16
- package/dist/utils/session.js +185 -0
- package/package.json +1 -1
package/dist/api/endpoints.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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();
|
package/dist/commands/upload.js
CHANGED
|
@@ -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...');
|