@reshotdev/screenshot 0.0.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/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// diff-engine.js - Client-side visual diffing engine
|
|
2
|
+
const fs = require("fs-extra");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { PNG } = require("pngjs");
|
|
5
|
+
const pixelmatch = require("pixelmatch");
|
|
6
|
+
const axios = require("axios");
|
|
7
|
+
const chalk = require("chalk");
|
|
8
|
+
|
|
9
|
+
const CACHE_DIR = path.join(process.cwd(), ".reshot", "cache", "baselines");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ensure the cache directory exists
|
|
13
|
+
*/
|
|
14
|
+
function ensureCacheDir() {
|
|
15
|
+
fs.ensureDirSync(CACHE_DIR);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Download baseline image from CDN
|
|
20
|
+
* @param {string} url - CDN URL
|
|
21
|
+
* @param {string} savePath - Local path to save
|
|
22
|
+
* @returns {Promise<string>} Path to downloaded file
|
|
23
|
+
*/
|
|
24
|
+
async function downloadBaseline(url, savePath) {
|
|
25
|
+
try {
|
|
26
|
+
fs.ensureDirSync(path.dirname(savePath));
|
|
27
|
+
const response = await axios.get(url, {
|
|
28
|
+
responseType: "arraybuffer",
|
|
29
|
+
timeout: 30000,
|
|
30
|
+
});
|
|
31
|
+
await fs.writeFile(savePath, response.data);
|
|
32
|
+
return savePath;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Failed to download baseline from ${url}: ${error.message}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Download multiple baselines in parallel with offline fallback
|
|
42
|
+
* When downloads fail, falls back to cached versions if available
|
|
43
|
+
* @param {Object} baselines - Map of keys to URLs
|
|
44
|
+
* @returns {Promise<Object>} Map of keys to local paths
|
|
45
|
+
*/
|
|
46
|
+
async function downloadBaselines(baselines) {
|
|
47
|
+
ensureCacheDir();
|
|
48
|
+
const localPaths = {};
|
|
49
|
+
const downloads = [];
|
|
50
|
+
let networkFailures = 0;
|
|
51
|
+
let cacheHits = 0;
|
|
52
|
+
|
|
53
|
+
for (const [key, url] of Object.entries(baselines)) {
|
|
54
|
+
const safeName = key.replace(/[^a-zA-Z0-9-_]/g, "_");
|
|
55
|
+
const localPath = path.join(CACHE_DIR, `${safeName}.png`);
|
|
56
|
+
|
|
57
|
+
downloads.push(
|
|
58
|
+
downloadBaseline(url, localPath)
|
|
59
|
+
.then(() => {
|
|
60
|
+
localPaths[key] = localPath;
|
|
61
|
+
})
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
networkFailures++;
|
|
64
|
+
// Check if we have a cached version to fall back to
|
|
65
|
+
if (fs.existsSync(localPath)) {
|
|
66
|
+
cacheHits++;
|
|
67
|
+
console.log(
|
|
68
|
+
chalk.yellow(
|
|
69
|
+
` ⚠ Network error for ${key}, using cached baseline`
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
localPaths[key] = localPath;
|
|
73
|
+
} else {
|
|
74
|
+
console.log(
|
|
75
|
+
chalk.yellow(
|
|
76
|
+
` ⚠ Failed to download baseline for ${key}: ${err.message}`
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await Promise.all(downloads);
|
|
85
|
+
|
|
86
|
+
// Warn if we're in offline mode
|
|
87
|
+
if (networkFailures > 0 && cacheHits > 0) {
|
|
88
|
+
console.log(
|
|
89
|
+
chalk.yellow(
|
|
90
|
+
`\n ⚠ Offline mode: ${cacheHits}/${networkFailures} baselines loaded from cache`
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return localPaths;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compare two images and generate diff
|
|
100
|
+
* @param {string} newPath - Path to new image
|
|
101
|
+
* @param {string} baselinePath - Path to baseline image
|
|
102
|
+
* @param {string} diffPath - Path to save diff output
|
|
103
|
+
* @param {Object} options - Comparison options
|
|
104
|
+
* @returns {Promise<Object>} { score, hasDiff, numDiffPixels, totalPixels, reason? }
|
|
105
|
+
*/
|
|
106
|
+
async function compareImages(newPath, baselinePath, diffPath, options = {}) {
|
|
107
|
+
const { threshold = 0.1, includeAA = false } = options;
|
|
108
|
+
|
|
109
|
+
// Check if files exist
|
|
110
|
+
if (!fs.existsSync(newPath)) {
|
|
111
|
+
return {
|
|
112
|
+
hasDiff: true,
|
|
113
|
+
score: 1.0,
|
|
114
|
+
reason: "new_image_missing",
|
|
115
|
+
error: `New image not found: ${newPath}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(baselinePath)) {
|
|
120
|
+
return {
|
|
121
|
+
hasDiff: true,
|
|
122
|
+
score: 1.0,
|
|
123
|
+
reason: "baseline_missing",
|
|
124
|
+
error: `Baseline not found: ${baselinePath}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const img1Data = await fs.readFile(newPath);
|
|
130
|
+
const img2Data = await fs.readFile(baselinePath);
|
|
131
|
+
|
|
132
|
+
const img1 = PNG.sync.read(img1Data);
|
|
133
|
+
const img2 = PNG.sync.read(img2Data);
|
|
134
|
+
|
|
135
|
+
// Dimension mismatch - fail fast
|
|
136
|
+
if (img1.width !== img2.width || img1.height !== img2.height) {
|
|
137
|
+
return {
|
|
138
|
+
hasDiff: true,
|
|
139
|
+
score: 1.0,
|
|
140
|
+
reason: "dimension_mismatch",
|
|
141
|
+
dimensions: {
|
|
142
|
+
new: { width: img1.width, height: img1.height },
|
|
143
|
+
baseline: { width: img2.width, height: img2.height },
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { width, height } = img1;
|
|
149
|
+
const diff = new PNG({ width, height });
|
|
150
|
+
|
|
151
|
+
const numDiffPixels = pixelmatch(
|
|
152
|
+
img1.data,
|
|
153
|
+
img2.data,
|
|
154
|
+
diff.data,
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
{ threshold, includeAA }
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const totalPixels = width * height;
|
|
161
|
+
const score = numDiffPixels / totalPixels;
|
|
162
|
+
const hasDiff = numDiffPixels > 0;
|
|
163
|
+
|
|
164
|
+
if (hasDiff && diffPath) {
|
|
165
|
+
fs.ensureDirSync(path.dirname(diffPath));
|
|
166
|
+
await fs.writeFile(diffPath, PNG.sync.write(diff));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { score, hasDiff, numDiffPixels, totalPixels };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return {
|
|
172
|
+
hasDiff: true,
|
|
173
|
+
score: 1.0,
|
|
174
|
+
reason: "comparison_error",
|
|
175
|
+
error: error.message,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Generate manifest for an asset bundle
|
|
182
|
+
* @param {string} assetDir - Asset output directory
|
|
183
|
+
* @param {Object} diffResults - Map of step to diff results
|
|
184
|
+
*/
|
|
185
|
+
async function writeManifest(assetDir, diffResults) {
|
|
186
|
+
const manifestPath = path.join(assetDir, "manifest.json");
|
|
187
|
+
const manifest = {
|
|
188
|
+
generatedAt: new Date().toISOString(),
|
|
189
|
+
cliVersion: require("../../package.json").version,
|
|
190
|
+
sentinels: [],
|
|
191
|
+
diffs: [],
|
|
192
|
+
summary: {
|
|
193
|
+
total: 0,
|
|
194
|
+
changed: 0,
|
|
195
|
+
unchanged: 0,
|
|
196
|
+
errors: 0,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
for (const [step, result] of Object.entries(diffResults)) {
|
|
201
|
+
manifest.summary.total++;
|
|
202
|
+
|
|
203
|
+
manifest.sentinels.push({
|
|
204
|
+
step,
|
|
205
|
+
path: `sentinels/${step}.png`,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (result.error) {
|
|
209
|
+
manifest.summary.errors++;
|
|
210
|
+
manifest.diffs.push({
|
|
211
|
+
step,
|
|
212
|
+
error: result.error,
|
|
213
|
+
reason: result.reason,
|
|
214
|
+
});
|
|
215
|
+
} else if (result.hasDiff) {
|
|
216
|
+
manifest.summary.changed++;
|
|
217
|
+
manifest.diffs.push({
|
|
218
|
+
step,
|
|
219
|
+
path: `diffs/${step}.diff.png`,
|
|
220
|
+
score: result.score,
|
|
221
|
+
numDiffPixels: result.numDiffPixels,
|
|
222
|
+
reason: result.reason,
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
manifest.summary.unchanged++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
|
|
230
|
+
return manifest;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Clear the baseline cache
|
|
235
|
+
*/
|
|
236
|
+
async function clearCache() {
|
|
237
|
+
if (fs.existsSync(CACHE_DIR)) {
|
|
238
|
+
await fs.remove(CACHE_DIR);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get cache stats
|
|
244
|
+
* @returns {Object} { files, size }
|
|
245
|
+
*/
|
|
246
|
+
function getCacheStats() {
|
|
247
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
248
|
+
return { files: 0, size: 0 };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const files = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith(".png"));
|
|
252
|
+
let size = 0;
|
|
253
|
+
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
const stat = fs.statSync(path.join(CACHE_DIR, file));
|
|
256
|
+
size += stat.size;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { files: files.length, size };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ============================================
|
|
263
|
+
// LOCAL VERSION-TO-VERSION DIFFING
|
|
264
|
+
// ============================================
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check if a folder name is a valid timestamp folder
|
|
268
|
+
* @param {string} name - Folder name
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
function isTimestampFolder(name) {
|
|
272
|
+
return /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get all version timestamps for a scenario, sorted newest first
|
|
277
|
+
* @param {string} outputDir - Base output directory (e.g., .reshot/output)
|
|
278
|
+
* @param {string} scenarioKey - Scenario key
|
|
279
|
+
* @returns {string[]} Array of timestamp folder names, sorted newest first
|
|
280
|
+
*/
|
|
281
|
+
function getVersionsForScenario(outputDir, scenarioKey) {
|
|
282
|
+
const scenarioDir = path.join(outputDir, scenarioKey);
|
|
283
|
+
|
|
284
|
+
if (!fs.existsSync(scenarioDir)) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
|
|
290
|
+
const fullPath = path.join(scenarioDir, item);
|
|
291
|
+
try {
|
|
292
|
+
return fs.statSync(fullPath).isDirectory();
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return subFolders
|
|
299
|
+
.filter((f) => isTimestampFolder(f))
|
|
300
|
+
.sort()
|
|
301
|
+
.reverse();
|
|
302
|
+
} catch {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get the previous version timestamp for a scenario (excluding the current one)
|
|
309
|
+
* @param {string} outputDir - Base output directory
|
|
310
|
+
* @param {string} scenarioKey - Scenario key
|
|
311
|
+
* @param {string} currentTimestamp - Current timestamp to exclude
|
|
312
|
+
* @returns {string|null} Previous timestamp or null if none
|
|
313
|
+
*/
|
|
314
|
+
function getPreviousVersion(outputDir, scenarioKey, currentTimestamp) {
|
|
315
|
+
const versions = getVersionsForScenario(outputDir, scenarioKey);
|
|
316
|
+
|
|
317
|
+
// Filter out current version and get the next most recent
|
|
318
|
+
const previousVersions = versions.filter((v) => v !== currentTimestamp);
|
|
319
|
+
|
|
320
|
+
return previousVersions.length > 0 ? previousVersions[0] : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Find all visual assets in a version folder (recursively, handles variant subfolders)
|
|
325
|
+
* Prioritizes sentinel images in sentinels/ folder for diffing
|
|
326
|
+
* @param {string} versionDir - Version directory path
|
|
327
|
+
* @returns {Object} Map of asset key -> { path, relativePath, filename, isVideo, isSentinel }
|
|
328
|
+
*/
|
|
329
|
+
function findAssetsInVersion(versionDir) {
|
|
330
|
+
const assets = {};
|
|
331
|
+
const imageExtensions = [".png", ".jpg", ".jpeg", ".webp"];
|
|
332
|
+
const videoExtensions = [".mp4", ".webm"];
|
|
333
|
+
const allExtensions = [...imageExtensions, ...videoExtensions];
|
|
334
|
+
|
|
335
|
+
function walk(dir, relativePath = "") {
|
|
336
|
+
if (!fs.existsSync(dir)) return;
|
|
337
|
+
|
|
338
|
+
// Skip 'diffs' subdirectory to avoid picking up diff images
|
|
339
|
+
if (path.basename(dir) === "diffs") return;
|
|
340
|
+
|
|
341
|
+
const isSentinelDir = path.basename(dir) === "sentinels";
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const items = fs.readdirSync(dir);
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
const fullPath = path.join(dir, item);
|
|
347
|
+
const relPath = relativePath ? path.join(relativePath, item) : item;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const stat = fs.statSync(fullPath);
|
|
351
|
+
if (stat.isDirectory()) {
|
|
352
|
+
walk(fullPath, relPath);
|
|
353
|
+
} else {
|
|
354
|
+
const ext = path.extname(item).toLowerCase();
|
|
355
|
+
if (allExtensions.includes(ext)) {
|
|
356
|
+
const isVideo = videoExtensions.includes(ext);
|
|
357
|
+
|
|
358
|
+
// For videos, skip - we compare sentinels instead
|
|
359
|
+
if (isVideo) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Use relative path as key to properly namespace by variant
|
|
364
|
+
// e.g., "locale-en/sentinels/step-0-initial"
|
|
365
|
+
const key = relPath.replace(/\.[^/.]+$/, ""); // Remove extension
|
|
366
|
+
assets[key] = {
|
|
367
|
+
path: fullPath,
|
|
368
|
+
relativePath: relPath,
|
|
369
|
+
filename: item,
|
|
370
|
+
isVideo: false,
|
|
371
|
+
isSentinel: isSentinelDir,
|
|
372
|
+
size: stat.size,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
// Skip files we can't stat
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// Skip directories we can't read
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
walk(versionDir);
|
|
386
|
+
return assets;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Compare current version against previous version locally
|
|
391
|
+
* @param {string} outputDir - Base output directory (e.g., .reshot/output)
|
|
392
|
+
* @param {string} scenarioKey - Scenario key
|
|
393
|
+
* @param {string} currentTimestamp - Current version timestamp
|
|
394
|
+
* @param {Object} options - Comparison options (threshold, includeAA)
|
|
395
|
+
* @returns {Promise<Object>} { results, previousVersion, summary }
|
|
396
|
+
*/
|
|
397
|
+
async function compareWithPreviousVersion(
|
|
398
|
+
outputDir,
|
|
399
|
+
scenarioKey,
|
|
400
|
+
currentTimestamp,
|
|
401
|
+
options = {}
|
|
402
|
+
) {
|
|
403
|
+
const previousTimestamp = getPreviousVersion(
|
|
404
|
+
outputDir,
|
|
405
|
+
scenarioKey,
|
|
406
|
+
currentTimestamp
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (!previousTimestamp) {
|
|
410
|
+
return {
|
|
411
|
+
results: {},
|
|
412
|
+
previousVersion: null,
|
|
413
|
+
summary: {
|
|
414
|
+
total: 0,
|
|
415
|
+
compared: 0,
|
|
416
|
+
changed: 0,
|
|
417
|
+
unchanged: 0,
|
|
418
|
+
newAssets: 0,
|
|
419
|
+
errors: 0,
|
|
420
|
+
message: "No previous version to compare against (first run)",
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const currentDir = path.join(outputDir, scenarioKey, currentTimestamp);
|
|
426
|
+
const previousDir = path.join(outputDir, scenarioKey, previousTimestamp);
|
|
427
|
+
|
|
428
|
+
const currentAssets = findAssetsInVersion(currentDir);
|
|
429
|
+
const previousAssets = findAssetsInVersion(previousDir);
|
|
430
|
+
|
|
431
|
+
const results = {};
|
|
432
|
+
const summary = {
|
|
433
|
+
total: Object.keys(currentAssets).length,
|
|
434
|
+
compared: 0,
|
|
435
|
+
changed: 0,
|
|
436
|
+
unchanged: 0,
|
|
437
|
+
newAssets: 0,
|
|
438
|
+
errors: 0,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// Compare each current asset against its previous version
|
|
442
|
+
for (const [assetKey, currentAsset] of Object.entries(currentAssets)) {
|
|
443
|
+
const previousAsset = previousAssets[assetKey];
|
|
444
|
+
|
|
445
|
+
if (!previousAsset) {
|
|
446
|
+
// New asset, no previous version
|
|
447
|
+
results[assetKey] = {
|
|
448
|
+
status: "new",
|
|
449
|
+
hasDiff: true,
|
|
450
|
+
score: 1.0,
|
|
451
|
+
reason: "new_asset",
|
|
452
|
+
currentPath: currentAsset.path,
|
|
453
|
+
};
|
|
454
|
+
summary.newAssets++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Create diff output path - use just the filename, not the full assetKey which includes variant path
|
|
459
|
+
const diffDir = path.join(path.dirname(currentAsset.path), "diffs");
|
|
460
|
+
const baseFilename = path.basename(assetKey); // Just "step-0-initial", not "locale-en/step-0-initial"
|
|
461
|
+
const diffPath = path.join(diffDir, `${baseFilename}.diff.png`);
|
|
462
|
+
|
|
463
|
+
const diffResult = await compareImages(
|
|
464
|
+
currentAsset.path,
|
|
465
|
+
previousAsset.path,
|
|
466
|
+
diffPath,
|
|
467
|
+
options
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
results[assetKey] = {
|
|
471
|
+
...diffResult,
|
|
472
|
+
status: diffResult.hasDiff ? "changed" : "unchanged",
|
|
473
|
+
currentPath: currentAsset.path,
|
|
474
|
+
previousPath: previousAsset.path,
|
|
475
|
+
diffPath: diffResult.hasDiff ? diffPath : null,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
summary.compared++;
|
|
479
|
+
|
|
480
|
+
if (diffResult.error) {
|
|
481
|
+
summary.errors++;
|
|
482
|
+
} else if (diffResult.hasDiff) {
|
|
483
|
+
summary.changed++;
|
|
484
|
+
} else {
|
|
485
|
+
summary.unchanged++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
results,
|
|
491
|
+
previousVersion: previousTimestamp,
|
|
492
|
+
currentVersion: currentTimestamp,
|
|
493
|
+
summary,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Write local diff manifest for a version
|
|
499
|
+
* @param {string} versionDir - Version directory
|
|
500
|
+
* @param {Object} diffData - Result from compareWithPreviousVersion
|
|
501
|
+
*/
|
|
502
|
+
async function writeLocalDiffManifest(versionDir, diffData) {
|
|
503
|
+
const manifestPath = path.join(versionDir, "diff-manifest.json");
|
|
504
|
+
|
|
505
|
+
const manifest = {
|
|
506
|
+
generatedAt: new Date().toISOString(),
|
|
507
|
+
cliVersion: require("../../package.json").version,
|
|
508
|
+
comparedAgainst: diffData.previousVersion || diffData.baselineSource,
|
|
509
|
+
currentVersion: diffData.currentVersion,
|
|
510
|
+
baselineSource: diffData.baselineSource || "local",
|
|
511
|
+
summary: diffData.summary,
|
|
512
|
+
assets: {},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
for (const [key, result] of Object.entries(diffData.results)) {
|
|
516
|
+
manifest.assets[key] = {
|
|
517
|
+
status: result.status,
|
|
518
|
+
hasDiff: result.hasDiff,
|
|
519
|
+
score: result.score,
|
|
520
|
+
reason: result.reason,
|
|
521
|
+
diffPath: result.diffPath
|
|
522
|
+
? path.relative(versionDir, result.diffPath)
|
|
523
|
+
: null,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
|
|
528
|
+
return manifest;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Compare current assets against cloud baselines (approved visuals from platform)
|
|
533
|
+
* @param {string} currentVersionDir - Full path to current version output directory
|
|
534
|
+
* @param {string} scenarioKey - Scenario key
|
|
535
|
+
* @param {Object} cloudBaselines - Map of "scenarioKey/captureKey" to local baseline paths
|
|
536
|
+
* @param {Object} options - Diff options
|
|
537
|
+
* @returns {Object} { baselineSource, results, summary }
|
|
538
|
+
*/
|
|
539
|
+
async function compareWithCloudBaselines(currentVersionDir, scenarioKey, cloudBaselines, options = {}) {
|
|
540
|
+
const diffConfig = {
|
|
541
|
+
threshold: options.threshold || 0.1,
|
|
542
|
+
includeAA: options.includeAA || false,
|
|
543
|
+
generateDiffImages: options.generateDiffImages !== false,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const results = {};
|
|
547
|
+
const summary = { total: 0, new: 0, changed: 0, unchanged: 0, errors: 0 };
|
|
548
|
+
|
|
549
|
+
// Find all assets in current version (returns Object of assetKey -> asset info)
|
|
550
|
+
const currentAssets = findAssetsInVersion(currentVersionDir);
|
|
551
|
+
|
|
552
|
+
for (const [assetKey, currentAsset] of Object.entries(currentAssets)) {
|
|
553
|
+
const assetPath = currentAsset.path;
|
|
554
|
+
const baseFilename = path.basename(assetKey);
|
|
555
|
+
const fullAssetKey = `${scenarioKey}/${assetKey}`;
|
|
556
|
+
summary.total++;
|
|
557
|
+
|
|
558
|
+
// Check if we have a cloud baseline for this asset
|
|
559
|
+
const baselinePath = cloudBaselines[fullAssetKey];
|
|
560
|
+
|
|
561
|
+
if (!baselinePath) {
|
|
562
|
+
// New asset - no baseline exists
|
|
563
|
+
results[assetKey] = {
|
|
564
|
+
status: "new",
|
|
565
|
+
hasDiff: true,
|
|
566
|
+
score: 1.0,
|
|
567
|
+
reason: "no_cloud_baseline",
|
|
568
|
+
};
|
|
569
|
+
summary.new++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Compare with cloud baseline
|
|
574
|
+
const diffDir = path.join(path.dirname(assetPath), "diffs");
|
|
575
|
+
fs.ensureDirSync(diffDir);
|
|
576
|
+
const diffPath = path.join(diffDir, `${baseFilename}.diff.png`);
|
|
577
|
+
|
|
578
|
+
const diffResult = await compareImages(
|
|
579
|
+
assetPath,
|
|
580
|
+
baselinePath,
|
|
581
|
+
diffConfig.generateDiffImages ? diffPath : null,
|
|
582
|
+
diffConfig
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
if (diffResult.error) {
|
|
586
|
+
results[assetKey] = {
|
|
587
|
+
status: "error",
|
|
588
|
+
hasDiff: true,
|
|
589
|
+
score: 1.0,
|
|
590
|
+
reason: diffResult.reason,
|
|
591
|
+
error: diffResult.error,
|
|
592
|
+
};
|
|
593
|
+
summary.errors++;
|
|
594
|
+
} else if (diffResult.hasDiff) {
|
|
595
|
+
results[assetKey] = {
|
|
596
|
+
status: "changed",
|
|
597
|
+
hasDiff: true,
|
|
598
|
+
score: diffResult.score,
|
|
599
|
+
numDiffPixels: diffResult.numDiffPixels,
|
|
600
|
+
diffPath: diffConfig.generateDiffImages ? diffPath : null,
|
|
601
|
+
};
|
|
602
|
+
summary.changed++;
|
|
603
|
+
} else {
|
|
604
|
+
results[assetKey] = {
|
|
605
|
+
status: "unchanged",
|
|
606
|
+
hasDiff: false,
|
|
607
|
+
score: 0,
|
|
608
|
+
};
|
|
609
|
+
summary.unchanged++;
|
|
610
|
+
// Remove empty diff file if no diff
|
|
611
|
+
if (fs.existsSync(diffPath)) {
|
|
612
|
+
fs.removeSync(diffPath);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
baselineSource: "cloud",
|
|
619
|
+
currentVersion: path.basename(currentVersionDir),
|
|
620
|
+
results,
|
|
621
|
+
summary,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
module.exports = {
|
|
626
|
+
downloadBaseline,
|
|
627
|
+
downloadBaselines,
|
|
628
|
+
compareImages,
|
|
629
|
+
writeManifest,
|
|
630
|
+
clearCache,
|
|
631
|
+
getCacheStats,
|
|
632
|
+
CACHE_DIR,
|
|
633
|
+
// Local diffing functions
|
|
634
|
+
getVersionsForScenario,
|
|
635
|
+
getPreviousVersion,
|
|
636
|
+
findAssetsInVersion,
|
|
637
|
+
compareWithPreviousVersion,
|
|
638
|
+
writeLocalDiffManifest,
|
|
639
|
+
isTimestampFolder,
|
|
640
|
+
// Cloud baseline diffing
|
|
641
|
+
compareWithCloudBaselines,
|
|
642
|
+
};
|
package/src/lib/hash.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// hash.js - Content hash calculation for asset deduplication
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calculate SHA256 hash of a file
|
|
8
|
+
* @param {string} filePath - Path to the file
|
|
9
|
+
* @returns {Promise<string>} - Hex-encoded hash
|
|
10
|
+
*/
|
|
11
|
+
async function hashFile(filePath) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const hash = crypto.createHash('sha256');
|
|
14
|
+
const stream = fs.createReadStream(filePath);
|
|
15
|
+
|
|
16
|
+
stream.on('data', (data) => hash.update(data));
|
|
17
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
18
|
+
stream.on('error', reject);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Calculate hash synchronously (for smaller files)
|
|
24
|
+
* @param {Buffer} buffer - File contents
|
|
25
|
+
* @returns {string} - Hex-encoded hash
|
|
26
|
+
*/
|
|
27
|
+
function hashBuffer(buffer) {
|
|
28
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get MIME type from file extension
|
|
33
|
+
* @param {string} filePath - Path to the file
|
|
34
|
+
* @returns {string} - MIME type
|
|
35
|
+
*/
|
|
36
|
+
function getMimeType(filePath) {
|
|
37
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
38
|
+
const mimeTypes = {
|
|
39
|
+
'.png': 'image/png',
|
|
40
|
+
'.jpg': 'image/jpeg',
|
|
41
|
+
'.jpeg': 'image/jpeg',
|
|
42
|
+
'.gif': 'image/gif',
|
|
43
|
+
'.webp': 'image/webp',
|
|
44
|
+
'.mp4': 'video/mp4',
|
|
45
|
+
'.webm': 'video/webm',
|
|
46
|
+
'.svg': 'image/svg+xml',
|
|
47
|
+
};
|
|
48
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get file extension from MIME type (for generating paths)
|
|
53
|
+
* @param {string} mimeType - MIME type
|
|
54
|
+
* @returns {string} - File extension without dot
|
|
55
|
+
*/
|
|
56
|
+
function getExtFromMimeType(mimeType) {
|
|
57
|
+
const extMap = {
|
|
58
|
+
'image/png': 'png',
|
|
59
|
+
'image/jpeg': 'jpg',
|
|
60
|
+
'image/gif': 'gif',
|
|
61
|
+
'image/webp': 'webp',
|
|
62
|
+
'video/mp4': 'mp4',
|
|
63
|
+
'video/webm': 'webm',
|
|
64
|
+
'image/svg+xml': 'svg',
|
|
65
|
+
};
|
|
66
|
+
return extMap[mimeType] || 'bin';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
hashFile,
|
|
71
|
+
hashBuffer,
|
|
72
|
+
getMimeType,
|
|
73
|
+
getExtFromMimeType,
|
|
74
|
+
};
|