@vizzly-testing/cli 0.19.2 → 0.20.1-beta.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/client.js +134 -0
- package/dist/api/core.js +341 -0
- package/dist/api/endpoints.js +314 -0
- package/dist/api/index.js +19 -0
- package/dist/auth/client.js +91 -0
- package/dist/auth/core.js +176 -0
- package/dist/auth/index.js +30 -0
- package/dist/auth/operations.js +148 -0
- package/dist/cli.js +1 -1
- package/dist/client/index.js +0 -1
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/finalize.js +41 -15
- package/dist/commands/login.js +7 -6
- package/dist/commands/logout.js +4 -4
- package/dist/commands/project.js +5 -4
- package/dist/commands/run.js +158 -90
- package/dist/commands/status.js +22 -18
- package/dist/commands/tdd.js +105 -78
- package/dist/commands/upload.js +61 -26
- package/dist/commands/whoami.js +4 -4
- package/dist/config/core.js +438 -0
- package/dist/config/index.js +13 -0
- package/dist/config/operations.js +327 -0
- package/dist/index.js +1 -1
- package/dist/project/core.js +295 -0
- package/dist/project/index.js +13 -0
- package/dist/project/operations.js +393 -0
- package/dist/report-generator/core.js +315 -0
- package/dist/report-generator/index.js +8 -0
- package/dist/report-generator/operations.js +196 -0
- package/dist/reporter/reporter-bundle.iife.js +16 -16
- package/dist/screenshot-server/core.js +157 -0
- package/dist/screenshot-server/index.js +11 -0
- package/dist/screenshot-server/operations.js +183 -0
- package/dist/sdk/index.js +3 -2
- package/dist/server/handlers/api-handler.js +14 -5
- package/dist/server/handlers/tdd-handler.js +80 -48
- package/dist/server-manager/core.js +183 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +208 -0
- package/dist/services/build-manager.js +2 -69
- package/dist/services/index.js +21 -48
- package/dist/services/screenshot-server.js +40 -74
- package/dist/services/server-manager.js +45 -80
- package/dist/services/static-report-generator.js +21 -163
- package/dist/services/test-runner.js +90 -249
- package/dist/services/uploader.js +56 -358
- package/dist/tdd/core/hotspot-coverage.js +112 -0
- package/dist/tdd/core/signature.js +101 -0
- package/dist/tdd/index.js +19 -0
- package/dist/tdd/metadata/baseline-metadata.js +103 -0
- package/dist/tdd/metadata/hotspot-metadata.js +93 -0
- package/dist/tdd/services/baseline-downloader.js +151 -0
- package/dist/tdd/services/baseline-manager.js +166 -0
- package/dist/tdd/services/comparison-service.js +230 -0
- package/dist/tdd/services/hotspot-service.js +71 -0
- package/dist/tdd/services/result-service.js +123 -0
- package/dist/tdd/tdd-service.js +1081 -0
- package/dist/test-runner/core.js +255 -0
- package/dist/test-runner/index.js +13 -0
- package/dist/test-runner/operations.js +483 -0
- package/dist/types/client.d.ts +4 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/config-schema.js +8 -3
- package/package.json +7 -12
- package/dist/services/api-service.js +0 -412
- package/dist/services/auth-service.js +0 -226
- package/dist/services/config-service.js +0 -369
- package/dist/services/html-report-generator.js +0 -455
- package/dist/services/project-service.js +0 -326
- package/dist/services/report-generator/report.css +0 -411
- package/dist/services/report-generator/viewer.js +0 -102
- package/dist/services/tdd-service.js +0 -1429
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vizzly Screenshot Uploader
|
|
3
3
|
* Handles screenshot uploads to the Vizzly platform
|
|
4
|
+
*
|
|
5
|
+
* This module is a thin wrapper around the functional operations in
|
|
6
|
+
* src/uploader/. It maintains backwards compatibility while
|
|
7
|
+
* delegating to pure functions for testability.
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
|
-
import crypto from 'node:crypto';
|
|
7
10
|
import { readFile, stat } from 'node:fs/promises';
|
|
8
|
-
import { basename } from 'node:path';
|
|
9
11
|
import { glob } from 'glob';
|
|
12
|
+
import { checkShas, createApiClient, createBuild } from '../api/index.js';
|
|
10
13
|
import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
|
|
14
|
+
import { resolveBatchSize, resolveTimeout } from '../uploader/index.js';
|
|
15
|
+
import { upload as uploadOperation, waitForBuild as waitForBuildOperation } from '../uploader/operations.js';
|
|
11
16
|
import { getDefaultBranch } from '../utils/git.js';
|
|
12
17
|
import * as output from '../utils/output.js';
|
|
13
|
-
import { ApiService } from './api-service.js';
|
|
14
|
-
const DEFAULT_BATCH_SIZE = 50;
|
|
15
|
-
const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
|
|
16
|
-
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Create a new uploader instance
|
|
@@ -25,378 +26,75 @@ export function createUploader({
|
|
|
25
26
|
command,
|
|
26
27
|
upload: uploadConfig = {}
|
|
27
28
|
} = {}, options = {}) {
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
let signal = options.signal || new AbortController().signal;
|
|
30
|
+
let client = createApiClient({
|
|
30
31
|
baseUrl: apiUrl,
|
|
31
32
|
token: apiKey,
|
|
32
33
|
command: command || 'upload',
|
|
33
|
-
userAgent,
|
|
34
|
+
sdkUserAgent: userAgent,
|
|
34
35
|
allowNoToken: true
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
// Resolve tunable parameters
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// Resolve tunable parameters
|
|
39
|
+
let batchSize = resolveBatchSize(options, uploadConfig);
|
|
40
|
+
let timeout = resolveTimeout(options, uploadConfig);
|
|
41
|
+
|
|
42
|
+
// Dependency injection for testing
|
|
43
|
+
let deps = options.deps || {
|
|
44
|
+
client,
|
|
45
|
+
createBuild,
|
|
46
|
+
getDefaultBranch,
|
|
47
|
+
glob,
|
|
48
|
+
readFile,
|
|
49
|
+
stat,
|
|
50
|
+
checkShas,
|
|
51
|
+
createError: (message, code, context) => {
|
|
52
|
+
let error = new UploadError(message, context);
|
|
53
|
+
error.code = code;
|
|
54
|
+
return error;
|
|
55
|
+
},
|
|
56
|
+
createValidationError: (message, context) => new ValidationError(message, context),
|
|
57
|
+
createUploadError: (message, context) => new UploadError(message, context),
|
|
58
|
+
createTimeoutError: (message, context) => new TimeoutError(message, context),
|
|
59
|
+
output
|
|
60
|
+
};
|
|
40
61
|
|
|
41
62
|
/**
|
|
42
63
|
* Upload screenshots to Vizzly
|
|
43
64
|
*/
|
|
44
|
-
async function upload({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
// Validate required config
|
|
58
|
-
if (!apiKey) {
|
|
59
|
-
throw new ValidationError('API key is required', {
|
|
60
|
-
config: {
|
|
61
|
-
apiKey,
|
|
62
|
-
apiUrl
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
if (!screenshotsDir) {
|
|
67
|
-
throw new ValidationError('Screenshots directory is required');
|
|
68
|
-
}
|
|
69
|
-
const stats = await stat(screenshotsDir);
|
|
70
|
-
if (!stats.isDirectory()) {
|
|
71
|
-
throw new ValidationError(`${screenshotsDir} is not a directory`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Find screenshots
|
|
75
|
-
const files = await findScreenshots(screenshotsDir);
|
|
76
|
-
if (files.length === 0) {
|
|
77
|
-
throw new UploadError('No screenshot files found', {
|
|
78
|
-
directory: screenshotsDir,
|
|
79
|
-
pattern: '**/*.png'
|
|
80
|
-
});
|
|
65
|
+
async function upload(uploadOptions) {
|
|
66
|
+
return uploadOperation({
|
|
67
|
+
uploadOptions,
|
|
68
|
+
config: {
|
|
69
|
+
apiKey,
|
|
70
|
+
apiUrl
|
|
71
|
+
},
|
|
72
|
+
signal,
|
|
73
|
+
batchSize,
|
|
74
|
+
deps: {
|
|
75
|
+
...deps,
|
|
76
|
+
client: deps.client || client
|
|
81
77
|
}
|
|
82
|
-
|
|
83
|
-
phase: 'scanning',
|
|
84
|
-
message: `Found ${files.length} screenshots`,
|
|
85
|
-
total: files.length
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Process files to get metadata
|
|
89
|
-
const fileMetadata = await processFiles(files, signal, current => onProgress({
|
|
90
|
-
phase: 'processing',
|
|
91
|
-
message: `Processing files`,
|
|
92
|
-
current,
|
|
93
|
-
total: files.length
|
|
94
|
-
}));
|
|
95
|
-
|
|
96
|
-
// Create build first to get buildId for SHA checking
|
|
97
|
-
const buildInfo = {
|
|
98
|
-
name: buildName || `Upload ${new Date().toISOString()}`,
|
|
99
|
-
branch: branch || (await getDefaultBranch()) || 'main',
|
|
100
|
-
commit_sha: commit,
|
|
101
|
-
commit_message: message,
|
|
102
|
-
environment,
|
|
103
|
-
threshold,
|
|
104
|
-
github_pull_request_number: pullRequestNumber,
|
|
105
|
-
parallel_id: parallelId
|
|
106
|
-
};
|
|
107
|
-
const build = await api.createBuild(buildInfo);
|
|
108
|
-
const buildId = build.id;
|
|
109
|
-
|
|
110
|
-
// Check which files need uploading (now with buildId)
|
|
111
|
-
const {
|
|
112
|
-
toUpload,
|
|
113
|
-
existing,
|
|
114
|
-
screenshots
|
|
115
|
-
} = await checkExistingFiles(fileMetadata, api, signal, buildId);
|
|
116
|
-
onProgress({
|
|
117
|
-
phase: 'deduplication',
|
|
118
|
-
message: `Checking for duplicates (${toUpload.length} to upload, ${existing.length} existing)`,
|
|
119
|
-
toUpload: toUpload.length,
|
|
120
|
-
existing: existing.length,
|
|
121
|
-
total: files.length
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Upload remaining files
|
|
125
|
-
const result = await uploadFiles({
|
|
126
|
-
toUpload,
|
|
127
|
-
existing,
|
|
128
|
-
screenshots,
|
|
129
|
-
buildId,
|
|
130
|
-
buildInfo,
|
|
131
|
-
api,
|
|
132
|
-
signal,
|
|
133
|
-
batchSize: batchSize,
|
|
134
|
-
onProgress: current => onProgress({
|
|
135
|
-
phase: 'uploading',
|
|
136
|
-
message: `Uploading screenshots`,
|
|
137
|
-
current,
|
|
138
|
-
total: toUpload.length
|
|
139
|
-
})
|
|
140
|
-
});
|
|
141
|
-
onProgress({
|
|
142
|
-
phase: 'completed',
|
|
143
|
-
message: `Upload completed`,
|
|
144
|
-
buildId: result.buildId,
|
|
145
|
-
url: result.url
|
|
146
|
-
});
|
|
147
|
-
return {
|
|
148
|
-
success: true,
|
|
149
|
-
buildId: result.buildId,
|
|
150
|
-
url: result.url,
|
|
151
|
-
stats: {
|
|
152
|
-
total: files.length,
|
|
153
|
-
uploaded: toUpload.length,
|
|
154
|
-
skipped: existing.length
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
} catch (error) {
|
|
158
|
-
output.debug('upload', 'failed', {
|
|
159
|
-
error: error.message
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// Re-throw if already a VizzlyError
|
|
163
|
-
if (error.name?.includes('Error') && error.code) {
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Wrap unknown errors
|
|
168
|
-
throw new UploadError(`Upload failed: ${error.message}`, {
|
|
169
|
-
originalError: error.message,
|
|
170
|
-
stack: error.stack
|
|
171
|
-
});
|
|
172
|
-
}
|
|
78
|
+
});
|
|
173
79
|
}
|
|
174
80
|
|
|
175
81
|
/**
|
|
176
82
|
* Wait for a build to complete
|
|
177
83
|
*/
|
|
178
|
-
async function waitForBuild(buildId,
|
|
179
|
-
|
|
180
|
-
while (Date.now() - startTime < timeout) {
|
|
181
|
-
if (signal.aborted) {
|
|
182
|
-
throw new UploadError('Operation cancelled', {
|
|
183
|
-
buildId
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
let resp;
|
|
187
|
-
try {
|
|
188
|
-
resp = await api.request(`/api/sdk/builds/${buildId}`, {
|
|
189
|
-
signal
|
|
190
|
-
});
|
|
191
|
-
} catch (err) {
|
|
192
|
-
const match = String(err?.message || '').match(/API request failed: (\d+)/);
|
|
193
|
-
const code = match ? match[1] : 'unknown';
|
|
194
|
-
throw new UploadError(`Failed to check build status: ${code}`);
|
|
195
|
-
}
|
|
196
|
-
const build = resp?.build ?? resp;
|
|
197
|
-
if (build.status === 'completed') {
|
|
198
|
-
// Extract comparison data for the response
|
|
199
|
-
const result = {
|
|
200
|
-
status: 'completed',
|
|
201
|
-
build
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Add comparison summary if available
|
|
205
|
-
if (typeof build.comparisonsTotal === 'number') {
|
|
206
|
-
result.comparisons = build.comparisonsTotal;
|
|
207
|
-
result.passedComparisons = build.comparisonsPassed || 0;
|
|
208
|
-
result.failedComparisons = build.comparisonsFailed || 0;
|
|
209
|
-
} else {
|
|
210
|
-
// Ensure failedComparisons is always a number, even when comparison data is missing
|
|
211
|
-
// This prevents the run command exit code check from failing
|
|
212
|
-
result.passedComparisons = 0;
|
|
213
|
-
result.failedComparisons = 0;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Add build URL if available
|
|
217
|
-
if (build.url) {
|
|
218
|
-
result.url = build.url;
|
|
219
|
-
}
|
|
220
|
-
return result;
|
|
221
|
-
}
|
|
222
|
-
if (build.status === 'failed') {
|
|
223
|
-
throw new UploadError(`Build failed: ${build.error || 'Unknown error'}`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
throw new TimeoutError(`Build timed out after ${timeout}ms`, {
|
|
84
|
+
async function waitForBuild(buildId, waitTimeout = timeout) {
|
|
85
|
+
return waitForBuildOperation({
|
|
227
86
|
buildId,
|
|
228
|
-
timeout,
|
|
229
|
-
|
|
87
|
+
timeout: waitTimeout,
|
|
88
|
+
signal,
|
|
89
|
+
client: deps.client || client,
|
|
90
|
+
deps: {
|
|
91
|
+
createError: deps.createError,
|
|
92
|
+
createTimeoutError: deps.createTimeoutError
|
|
93
|
+
}
|
|
230
94
|
});
|
|
231
95
|
}
|
|
232
96
|
return {
|
|
233
97
|
upload,
|
|
234
98
|
waitForBuild
|
|
235
99
|
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Find all PNG screenshots in a directory
|
|
240
|
-
*/
|
|
241
|
-
async function findScreenshots(directory) {
|
|
242
|
-
const pattern = `${directory}/**/*.png`;
|
|
243
|
-
return glob(pattern, {
|
|
244
|
-
absolute: true
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Process files to extract metadata and compute hashes
|
|
250
|
-
*/
|
|
251
|
-
async function* processFilesGenerator(files, signal) {
|
|
252
|
-
for (const filePath of files) {
|
|
253
|
-
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
254
|
-
const buffer = await readFile(filePath);
|
|
255
|
-
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
256
|
-
yield {
|
|
257
|
-
path: filePath,
|
|
258
|
-
filename: basename(filePath),
|
|
259
|
-
buffer,
|
|
260
|
-
sha256
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
async function processFiles(files, signal, onProgress) {
|
|
265
|
-
const results = [];
|
|
266
|
-
let count = 0;
|
|
267
|
-
for await (const file of processFilesGenerator(files, signal)) {
|
|
268
|
-
results.push(file);
|
|
269
|
-
count++;
|
|
270
|
-
if (count % 10 === 0 || count === files.length) {
|
|
271
|
-
onProgress(count);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return results;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Check which files already exist on the server using signature-based deduplication
|
|
279
|
-
*/
|
|
280
|
-
async function checkExistingFiles(fileMetadata, api, signal, buildId) {
|
|
281
|
-
const existingShas = new Set();
|
|
282
|
-
const allScreenshots = [];
|
|
283
|
-
|
|
284
|
-
// Check in batches using the new signature-based format
|
|
285
|
-
for (let i = 0; i < fileMetadata.length; i += DEFAULT_SHA_CHECK_BATCH_SIZE) {
|
|
286
|
-
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
287
|
-
const batch = fileMetadata.slice(i, i + DEFAULT_SHA_CHECK_BATCH_SIZE);
|
|
288
|
-
|
|
289
|
-
// Convert file metadata to screenshot objects with signature data
|
|
290
|
-
const screenshotBatch = batch.map(file => ({
|
|
291
|
-
sha256: file.sha256,
|
|
292
|
-
name: file.filename.replace(/\.png$/, ''),
|
|
293
|
-
// Remove .png extension for name
|
|
294
|
-
// Extract browser from filename if available (e.g., "homepage-chrome.png" -> "chrome")
|
|
295
|
-
browser: extractBrowserFromFilename(file.filename) || 'chrome',
|
|
296
|
-
// Default to chrome
|
|
297
|
-
// Default viewport dimensions (these could be extracted from filename or metadata if available)
|
|
298
|
-
viewport_width: 1920,
|
|
299
|
-
viewport_height: 1080
|
|
300
|
-
}));
|
|
301
|
-
try {
|
|
302
|
-
const res = await api.checkShas(screenshotBatch, buildId);
|
|
303
|
-
const {
|
|
304
|
-
existing = [],
|
|
305
|
-
screenshots = []
|
|
306
|
-
} = res || {};
|
|
307
|
-
for (let sha of existing) {
|
|
308
|
-
existingShas.add(sha);
|
|
309
|
-
}
|
|
310
|
-
allScreenshots.push(...screenshots);
|
|
311
|
-
} catch (error) {
|
|
312
|
-
// Continue without deduplication on error
|
|
313
|
-
console.debug('SHA check failed, continuing without deduplication:', error.message);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
return {
|
|
317
|
-
toUpload: fileMetadata.filter(f => !existingShas.has(f.sha256)),
|
|
318
|
-
existing: fileMetadata.filter(f => existingShas.has(f.sha256)),
|
|
319
|
-
screenshots: allScreenshots
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Extract browser name from filename
|
|
325
|
-
* @param {string} filename - The screenshot filename
|
|
326
|
-
* @returns {string|null} Browser name or null if not found
|
|
327
|
-
*/
|
|
328
|
-
function extractBrowserFromFilename(filename) {
|
|
329
|
-
const browsers = ['chrome', 'firefox', 'safari', 'edge', 'webkit'];
|
|
330
|
-
const lowerFilename = filename.toLowerCase();
|
|
331
|
-
for (const browser of browsers) {
|
|
332
|
-
if (lowerFilename.includes(browser)) {
|
|
333
|
-
return browser;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Upload files to Vizzly
|
|
341
|
-
*/
|
|
342
|
-
async function uploadFiles({
|
|
343
|
-
toUpload,
|
|
344
|
-
buildId,
|
|
345
|
-
api,
|
|
346
|
-
signal,
|
|
347
|
-
batchSize,
|
|
348
|
-
onProgress
|
|
349
|
-
}) {
|
|
350
|
-
let result = null;
|
|
351
|
-
|
|
352
|
-
// If all files exist, screenshot records were already created during SHA check
|
|
353
|
-
if (toUpload.length === 0) {
|
|
354
|
-
return {
|
|
355
|
-
buildId,
|
|
356
|
-
url: null
|
|
357
|
-
}; // Build was already created
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Upload in batches
|
|
361
|
-
for (let i = 0; i < toUpload.length; i += batchSize) {
|
|
362
|
-
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
363
|
-
const batch = toUpload.slice(i, i + batchSize);
|
|
364
|
-
const form = new FormData();
|
|
365
|
-
|
|
366
|
-
// All batches add to existing build (build was created earlier)
|
|
367
|
-
form.append('build_id', buildId);
|
|
368
|
-
|
|
369
|
-
// Add files
|
|
370
|
-
for (const file of batch) {
|
|
371
|
-
const blob = new Blob([file.buffer], {
|
|
372
|
-
type: 'image/png'
|
|
373
|
-
});
|
|
374
|
-
form.append('screenshots', blob, file.filename);
|
|
375
|
-
}
|
|
376
|
-
try {
|
|
377
|
-
result = await api.request('/api/sdk/upload', {
|
|
378
|
-
method: 'POST',
|
|
379
|
-
body: form,
|
|
380
|
-
signal,
|
|
381
|
-
headers: {}
|
|
382
|
-
});
|
|
383
|
-
} catch (err) {
|
|
384
|
-
throw new UploadError(`Upload failed: ${err.message}`, {
|
|
385
|
-
batch: i / batchSize + 1
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
onProgress(i + batch.length);
|
|
389
|
-
}
|
|
390
|
-
return {
|
|
391
|
-
buildId,
|
|
392
|
-
url: result?.build?.url || result?.url
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// createBuildWithExisting function removed - no longer needed since
|
|
397
|
-
// builds are created first and /check-shas automatically creates screenshot records
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Uploader class for handling screenshot uploads
|
|
401
|
-
*/
|
|
402
|
-
// Legacy Uploader class removed — all functionality lives in createUploader.
|
|
100
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hotspot Coverage Calculation
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for calculating how much of a visual diff falls within
|
|
5
|
+
* "hotspot" regions - areas of the UI that frequently change due to dynamic
|
|
6
|
+
* content (timestamps, animations, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Uses 1D Y-coordinate matching (same algorithm as cloud).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calculate what percentage of diff falls within hotspot regions
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} diffClusters - Array of diff clusters from honeydiff
|
|
15
|
+
* @param {Object} hotspotAnalysis - Hotspot data with regions array
|
|
16
|
+
* @returns {{ coverage: number, linesInHotspots: number, totalLines: number }}
|
|
17
|
+
*/
|
|
18
|
+
export function calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
|
|
19
|
+
if (!diffClusters || diffClusters.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
coverage: 0,
|
|
22
|
+
linesInHotspots: 0,
|
|
23
|
+
totalLines: 0
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
coverage: 0,
|
|
29
|
+
linesInHotspots: 0,
|
|
30
|
+
totalLines: 0
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Extract Y-coordinates (diff lines) from clusters
|
|
35
|
+
// Each cluster has a boundingBox with y and height
|
|
36
|
+
let diffLines = [];
|
|
37
|
+
for (let cluster of diffClusters) {
|
|
38
|
+
if (cluster.boundingBox) {
|
|
39
|
+
let {
|
|
40
|
+
y,
|
|
41
|
+
height
|
|
42
|
+
} = cluster.boundingBox;
|
|
43
|
+
// Add all Y lines covered by this cluster
|
|
44
|
+
for (let line = y; line < y + height; line++) {
|
|
45
|
+
diffLines.push(line);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (diffLines.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
coverage: 0,
|
|
52
|
+
linesInHotspots: 0,
|
|
53
|
+
totalLines: 0
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remove duplicates and sort
|
|
58
|
+
diffLines = [...new Set(diffLines)].sort((a, b) => a - b);
|
|
59
|
+
|
|
60
|
+
// Check how many diff lines fall within hotspot regions
|
|
61
|
+
let linesInHotspots = 0;
|
|
62
|
+
for (let line of diffLines) {
|
|
63
|
+
let inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
|
|
64
|
+
if (inHotspot) {
|
|
65
|
+
linesInHotspots++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let coverage = linesInHotspots / diffLines.length;
|
|
69
|
+
return {
|
|
70
|
+
coverage,
|
|
71
|
+
linesInHotspots,
|
|
72
|
+
totalLines: diffLines.length
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Determine if a comparison should be filtered as "passed" based on hotspot coverage
|
|
78
|
+
*
|
|
79
|
+
* A diff is filtered when:
|
|
80
|
+
* 1. Coverage is >= 80% (most diff in hotspot regions)
|
|
81
|
+
* 2. Confidence is "high" or confidence score > 0.7
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} hotspotAnalysis - Hotspot data with confidence info
|
|
84
|
+
* @param {{ coverage: number }} coverageResult - Result from calculateHotspotCoverage
|
|
85
|
+
* @returns {boolean} True if diff should be filtered as hotspot noise
|
|
86
|
+
*/
|
|
87
|
+
export function shouldFilterAsHotspot(hotspotAnalysis, coverageResult) {
|
|
88
|
+
if (!hotspotAnalysis || !coverageResult) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
let {
|
|
92
|
+
coverage
|
|
93
|
+
} = coverageResult;
|
|
94
|
+
|
|
95
|
+
// Need at least 80% of diff in hotspot regions
|
|
96
|
+
if (coverage < 0.8) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Need high confidence in the hotspot analysis
|
|
101
|
+
let {
|
|
102
|
+
confidence,
|
|
103
|
+
confidenceScore
|
|
104
|
+
} = hotspotAnalysis;
|
|
105
|
+
if (confidence === 'high') {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (confidenceScore !== undefined && confidenceScore > 0.7) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Identity - Signature and Filename Generation
|
|
3
|
+
*
|
|
4
|
+
* CRITICAL: These functions MUST stay in sync with the cloud!
|
|
5
|
+
*
|
|
6
|
+
* Cloud counterpart: vizzly/src/utils/screenshot-identity.js
|
|
7
|
+
* - generateScreenshotSignature()
|
|
8
|
+
* - generateBaselineFilename()
|
|
9
|
+
*
|
|
10
|
+
* Contract tests: Both repos have golden tests that must produce identical values:
|
|
11
|
+
* - Cloud: tests/contracts/signature-parity.test.js
|
|
12
|
+
* - CLI: tests/contracts/signature-parity.spec.js
|
|
13
|
+
*
|
|
14
|
+
* If you modify signature or filename generation here, you MUST:
|
|
15
|
+
* 1. Make the same change in the cloud repo
|
|
16
|
+
* 2. Update golden test values in BOTH repos
|
|
17
|
+
* 3. Run contract tests in both repos to verify parity
|
|
18
|
+
*
|
|
19
|
+
* The signature format is: name|viewport_width|browser|custom1|custom2|...
|
|
20
|
+
* The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate a screenshot signature for baseline matching
|
|
27
|
+
*
|
|
28
|
+
* SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
|
|
29
|
+
*
|
|
30
|
+
* Uses same logic as cloud: name + viewport_width + browser + custom properties
|
|
31
|
+
*
|
|
32
|
+
* @param {string} name - Screenshot name
|
|
33
|
+
* @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
|
|
34
|
+
* @param {Array<string>} customProperties - Custom property names from project settings
|
|
35
|
+
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
|
|
36
|
+
*/
|
|
37
|
+
export function generateScreenshotSignature(name, properties = {}, customProperties = []) {
|
|
38
|
+
// Match cloud screenshot-identity.js behavior exactly:
|
|
39
|
+
// Always include all default properties (name, viewport_width, browser)
|
|
40
|
+
// even if null/undefined, using empty string as placeholder
|
|
41
|
+
let defaultProperties = ['name', 'viewport_width', 'browser'];
|
|
42
|
+
let allProperties = [...defaultProperties, ...customProperties];
|
|
43
|
+
let parts = allProperties.map(propName => {
|
|
44
|
+
let value;
|
|
45
|
+
if (propName === 'name') {
|
|
46
|
+
value = name;
|
|
47
|
+
} else if (propName === 'viewport_width') {
|
|
48
|
+
// Check for viewport_width as top-level property first (backend format)
|
|
49
|
+
value = properties.viewport_width;
|
|
50
|
+
// Fallback to nested viewport.width (SDK format)
|
|
51
|
+
if (value === null || value === undefined) {
|
|
52
|
+
value = properties.viewport?.width;
|
|
53
|
+
}
|
|
54
|
+
} else if (propName === 'browser') {
|
|
55
|
+
value = properties.browser;
|
|
56
|
+
} else {
|
|
57
|
+
// Custom property - check multiple locations
|
|
58
|
+
value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Handle null/undefined values consistently (match cloud behavior)
|
|
62
|
+
if (value === null || value === undefined) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Convert to string and normalize
|
|
67
|
+
return String(value).trim();
|
|
68
|
+
});
|
|
69
|
+
return parts.join('|');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a stable, filesystem-safe filename for a screenshot baseline
|
|
74
|
+
* Uses a hash of the signature to avoid character encoding issues
|
|
75
|
+
* Matches the cloud's generateBaselineFilename implementation exactly
|
|
76
|
+
*
|
|
77
|
+
* @param {string} name - Screenshot name
|
|
78
|
+
* @param {string} signature - Full signature string
|
|
79
|
+
* @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
|
|
80
|
+
*/
|
|
81
|
+
export function generateBaselineFilename(name, signature) {
|
|
82
|
+
let hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
|
|
83
|
+
|
|
84
|
+
// Sanitize the name for filesystem safety
|
|
85
|
+
let safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
|
|
86
|
+
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
87
|
+
.slice(0, 50); // Limit length
|
|
88
|
+
|
|
89
|
+
return `${safeName}_${hash}.png`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a stable unique ID from signature for TDD comparisons
|
|
94
|
+
* This allows UI to reference specific variants without database IDs
|
|
95
|
+
*
|
|
96
|
+
* @param {string} signature - Full signature string
|
|
97
|
+
* @returns {string} 16-char hex hash
|
|
98
|
+
*/
|
|
99
|
+
export function generateComparisonId(signature) {
|
|
100
|
+
return crypto.createHash('sha256').update(signature).digest('hex').slice(0, 16);
|
|
101
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD Module Exports
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all TDD functionality for clean imports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { calculateHotspotCoverage, shouldFilterAsHotspot } from './core/hotspot-coverage.js';
|
|
8
|
+
// Core pure functions
|
|
9
|
+
export { generateBaselineFilename, generateComparisonId, generateScreenshotSignature } from './core/signature.js';
|
|
10
|
+
|
|
11
|
+
// Metadata I/O
|
|
12
|
+
export { createEmptyBaselineMetadata, findScreenshotBySignature, loadBaselineMetadata, saveBaselineMetadata, upsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
|
|
13
|
+
export { createHotspotCache, getHotspotForScreenshot, loadHotspotMetadata, saveHotspotMetadata } from './metadata/hotspot-metadata.js';
|
|
14
|
+
export { baselineMatchesSha, buildBaselineMetadataEntry, downloadBaselineImage, downloadBaselinesInBatches } from './services/baseline-downloader.js';
|
|
15
|
+
// Services
|
|
16
|
+
export { baselineExists, clearBaselineData, getBaselinePath, getCurrentPath, getDiffPath, initializeDirectories, promoteCurrentToBaseline, readBaseline, readCurrent, saveBaseline, saveCurrent } from './services/baseline-manager.js';
|
|
17
|
+
export { buildErrorComparison, buildFailedComparison, buildNewComparison, buildPassedComparison, compareImages, isDimensionMismatchError } from './services/comparison-service.js';
|
|
18
|
+
export { downloadHotspots, extractScreenshotNames } from './services/hotspot-service.js';
|
|
19
|
+
export { buildResults, calculateSummary, findComparison, findComparisonById, getErrorComparisons, getFailedComparisons, getNewComparisons, isSuccessful } from './services/result-service.js';
|