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