@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/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 +178 -3
- package/dist/client/index.js +144 -77
- package/dist/commands/doctor.js +121 -36
- package/dist/commands/finalize.js +49 -18
- package/dist/commands/init.js +13 -18
- package/dist/commands/login.js +49 -55
- package/dist/commands/logout.js +17 -9
- package/dist/commands/project.js +100 -71
- package/dist/commands/run.js +189 -95
- package/dist/commands/status.js +101 -66
- package/dist/commands/tdd-daemon.js +61 -32
- package/dist/commands/tdd.js +104 -98
- package/dist/commands/upload.js +78 -34
- package/dist/commands/whoami.js +44 -42
- 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/reporter/reporter-bundle.css +1 -1
- 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 +191 -53
- package/dist/server/http-server.js +9 -3
- package/dist/server/routers/baseline.js +58 -0
- package/dist/server/routers/dashboard.js +10 -6
- package/dist/server/routers/screenshot.js +32 -0
- package/dist/server-manager/core.js +186 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +209 -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/test-runner.js +90 -250
- 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 +1145 -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 +25 -2
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/colors.js +187 -39
- package/dist/utils/config-loader.js +3 -6
- package/dist/utils/context.js +228 -0
- package/dist/utils/output.js +449 -14
- package/docs/api-reference.md +173 -8
- package/docs/tui-elements.md +560 -0
- package/package.json +13 -13
- 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/static-report-generator.js +0 -207
- package/dist/services/tdd-service.js +0 -1437
|
@@ -1,1437 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TDD Service - Local Visual Testing
|
|
3
|
-
*
|
|
4
|
-
* ⚠️ CRITICAL: Signature/filename generation 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
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
-
import { join } from 'node:path';
|
|
26
|
-
import { compare } from '@vizzly-testing/honeydiff';
|
|
27
|
-
import { NetworkError } from '../errors/vizzly-error.js';
|
|
28
|
-
import { ApiService } from '../services/api-service.js';
|
|
29
|
-
import { colors } from '../utils/colors.js';
|
|
30
|
-
import { fetchWithTimeout } from '../utils/fetch-utils.js';
|
|
31
|
-
import { getDefaultBranch } from '../utils/git.js';
|
|
32
|
-
import * as output from '../utils/output.js';
|
|
33
|
-
import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
|
|
34
|
-
import { HtmlReportGenerator } from './html-report-generator.js';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Generate a screenshot signature for baseline matching
|
|
38
|
-
*
|
|
39
|
-
* ⚠️ SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
|
|
40
|
-
*
|
|
41
|
-
* Uses same logic as cloud: name + viewport_width + browser + custom properties
|
|
42
|
-
*
|
|
43
|
-
* @param {string} name - Screenshot name
|
|
44
|
-
* @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
|
|
45
|
-
* @param {Array<string>} customProperties - Custom property names from project settings
|
|
46
|
-
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
|
|
47
|
-
*/
|
|
48
|
-
function generateScreenshotSignature(name, properties = {}, customProperties = []) {
|
|
49
|
-
// Match cloud screenshot-identity.js behavior exactly:
|
|
50
|
-
// Always include all default properties (name, viewport_width, browser)
|
|
51
|
-
// even if null/undefined, using empty string as placeholder
|
|
52
|
-
const defaultProperties = ['name', 'viewport_width', 'browser'];
|
|
53
|
-
const allProperties = [...defaultProperties, ...customProperties];
|
|
54
|
-
const parts = allProperties.map(propName => {
|
|
55
|
-
let value;
|
|
56
|
-
if (propName === 'name') {
|
|
57
|
-
value = name;
|
|
58
|
-
} else if (propName === 'viewport_width') {
|
|
59
|
-
// Check for viewport_width as top-level property first (backend format)
|
|
60
|
-
value = properties.viewport_width;
|
|
61
|
-
// Fallback to nested viewport.width (SDK format)
|
|
62
|
-
if (value === null || value === undefined) {
|
|
63
|
-
value = properties.viewport?.width;
|
|
64
|
-
}
|
|
65
|
-
} else if (propName === 'browser') {
|
|
66
|
-
value = properties.browser;
|
|
67
|
-
} else {
|
|
68
|
-
// Custom property - check multiple locations
|
|
69
|
-
value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Handle null/undefined values consistently (match cloud behavior)
|
|
73
|
-
if (value === null || value === undefined) {
|
|
74
|
-
return '';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Convert to string and normalize
|
|
78
|
-
return String(value).trim();
|
|
79
|
-
});
|
|
80
|
-
return parts.join('|');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Generate a stable, filesystem-safe filename for a screenshot baseline
|
|
85
|
-
* Uses a hash of the signature to avoid character encoding issues
|
|
86
|
-
* Matches the cloud's generateBaselineFilename implementation exactly
|
|
87
|
-
*
|
|
88
|
-
* @param {string} name - Screenshot name
|
|
89
|
-
* @param {string} signature - Full signature string
|
|
90
|
-
* @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
|
|
91
|
-
*/
|
|
92
|
-
function generateBaselineFilename(name, signature) {
|
|
93
|
-
const hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
|
|
94
|
-
|
|
95
|
-
// Sanitize the name for filesystem safety
|
|
96
|
-
const safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
|
|
97
|
-
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
98
|
-
.slice(0, 50); // Limit length
|
|
99
|
-
|
|
100
|
-
return `${safeName}_${hash}.png`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Generate a stable unique ID from signature for TDD comparisons
|
|
105
|
-
* This allows UI to reference specific variants without database IDs
|
|
106
|
-
*/
|
|
107
|
-
function generateComparisonId(signature) {
|
|
108
|
-
return crypto.createHash('sha256').update(signature).digest('hex').slice(0, 16);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Create a new TDD service instance
|
|
113
|
-
*/
|
|
114
|
-
export function createTDDService(config, options = {}) {
|
|
115
|
-
return new TddService(config, options.workingDir, options.setBaseline, options.authService);
|
|
116
|
-
}
|
|
117
|
-
export class TddService {
|
|
118
|
-
constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null) {
|
|
119
|
-
this.config = config;
|
|
120
|
-
this.setBaseline = setBaseline;
|
|
121
|
-
this.authService = authService;
|
|
122
|
-
this.api = new ApiService({
|
|
123
|
-
baseUrl: config.apiUrl,
|
|
124
|
-
token: config.apiKey,
|
|
125
|
-
command: 'tdd',
|
|
126
|
-
allowNoToken: true // TDD can run without a token to create new screenshots
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Validate and secure the working directory
|
|
130
|
-
try {
|
|
131
|
-
this.workingDir = validatePathSecurity(workingDir, workingDir);
|
|
132
|
-
} catch (error) {
|
|
133
|
-
output.error(`Invalid working directory: ${error.message}`);
|
|
134
|
-
throw new Error(`Working directory validation failed: ${error.message}`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Use safe path construction for subdirectories
|
|
138
|
-
this.baselinePath = safePath(this.workingDir, '.vizzly', 'baselines');
|
|
139
|
-
this.currentPath = safePath(this.workingDir, '.vizzly', 'current');
|
|
140
|
-
this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
|
|
141
|
-
this.baselineData = null;
|
|
142
|
-
this.comparisons = [];
|
|
143
|
-
this.threshold = config.comparison?.threshold || 2.0;
|
|
144
|
-
this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
|
|
145
|
-
this.signatureProperties = config.signatureProperties ?? []; // Custom properties from project's baseline_signature_properties
|
|
146
|
-
|
|
147
|
-
// Check if we're in baseline update mode
|
|
148
|
-
if (this.setBaseline) {
|
|
149
|
-
output.info('🐻 Baseline update mode - will overwrite existing baselines with new ones');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Ensure directories exist
|
|
153
|
-
[this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
|
|
154
|
-
if (!existsSync(dir)) {
|
|
155
|
-
try {
|
|
156
|
-
mkdirSync(dir, {
|
|
157
|
-
recursive: true
|
|
158
|
-
});
|
|
159
|
-
} catch (error) {
|
|
160
|
-
output.error(`Failed to create directory ${dir}: ${error.message}`);
|
|
161
|
-
throw new Error(`Directory creation failed: ${error.message}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
|
|
167
|
-
// If no branch specified, try to detect the default branch
|
|
168
|
-
if (!branch) {
|
|
169
|
-
branch = await getDefaultBranch();
|
|
170
|
-
if (!branch) {
|
|
171
|
-
// If we can't detect a default branch, use 'main' as fallback
|
|
172
|
-
branch = 'main';
|
|
173
|
-
output.warn(`⚠️ Could not detect default branch, using 'main' as fallback`);
|
|
174
|
-
} else {
|
|
175
|
-
output.debug('tdd', `detected default branch: ${branch}`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
try {
|
|
179
|
-
let baselineBuild;
|
|
180
|
-
if (buildId) {
|
|
181
|
-
// Use the tdd-baselines endpoint which returns pre-computed filenames
|
|
182
|
-
let apiResponse = await this.api.getTddBaselines(buildId);
|
|
183
|
-
if (!apiResponse) {
|
|
184
|
-
throw new Error(`Build ${buildId} not found or API returned null`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// When downloading baselines, always start with a clean slate
|
|
188
|
-
// This handles signature property changes, build switches, and any stale state
|
|
189
|
-
output.info('Clearing local state before downloading baselines...');
|
|
190
|
-
try {
|
|
191
|
-
// Clear everything - baselines, current screenshots, diffs, and metadata
|
|
192
|
-
// This ensures we start fresh with the new baseline build
|
|
193
|
-
rmSync(this.baselinePath, {
|
|
194
|
-
recursive: true,
|
|
195
|
-
force: true
|
|
196
|
-
});
|
|
197
|
-
rmSync(this.currentPath, {
|
|
198
|
-
recursive: true,
|
|
199
|
-
force: true
|
|
200
|
-
});
|
|
201
|
-
rmSync(this.diffPath, {
|
|
202
|
-
recursive: true,
|
|
203
|
-
force: true
|
|
204
|
-
});
|
|
205
|
-
mkdirSync(this.baselinePath, {
|
|
206
|
-
recursive: true
|
|
207
|
-
});
|
|
208
|
-
mkdirSync(this.currentPath, {
|
|
209
|
-
recursive: true
|
|
210
|
-
});
|
|
211
|
-
mkdirSync(this.diffPath, {
|
|
212
|
-
recursive: true
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// Clear baseline metadata file (will be regenerated with new baseline)
|
|
216
|
-
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
217
|
-
if (existsSync(baselineMetadataPath)) {
|
|
218
|
-
rmSync(baselineMetadataPath, {
|
|
219
|
-
force: true
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
} catch (error) {
|
|
223
|
-
output.error(`Failed to clear local state: ${error.message}`);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Extract signature properties from API response (for variant support)
|
|
227
|
-
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
228
|
-
this.signatureProperties = apiResponse.signatureProperties;
|
|
229
|
-
if (this.signatureProperties.length > 0) {
|
|
230
|
-
output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
baselineBuild = apiResponse.build;
|
|
234
|
-
|
|
235
|
-
// Check build status and warn if it's not successful
|
|
236
|
-
if (baselineBuild.status === 'failed') {
|
|
237
|
-
output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
|
|
238
|
-
output.info(`💡 To use remote baselines, specify a successful build ID instead`);
|
|
239
|
-
return await this.handleLocalBaselines();
|
|
240
|
-
} else if (baselineBuild.status !== 'completed') {
|
|
241
|
-
output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Attach screenshots to build for unified processing below
|
|
245
|
-
baselineBuild.screenshots = apiResponse.screenshots;
|
|
246
|
-
} else if (comparisonId) {
|
|
247
|
-
// Use specific comparison ID - download only this comparison's baseline screenshot
|
|
248
|
-
output.info(`Using comparison: ${comparisonId}`);
|
|
249
|
-
const comparison = await this.api.getComparison(comparisonId);
|
|
250
|
-
|
|
251
|
-
// A comparison doesn't have baselineBuild directly - we need to get it
|
|
252
|
-
// The comparison has baseline_screenshot which contains the build_id
|
|
253
|
-
if (!comparison.baseline_screenshot) {
|
|
254
|
-
throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot with no baseline to compare against.`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url
|
|
258
|
-
const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
|
|
259
|
-
if (!baselineUrl) {
|
|
260
|
-
throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Extract properties from the current screenshot to ensure signature matching
|
|
264
|
-
// The baseline should use the same properties (viewport/browser) as the current screenshot
|
|
265
|
-
// so that generateScreenshotSignature produces the correct filename
|
|
266
|
-
// Use current screenshot properties since we're downloading baseline to compare against current
|
|
267
|
-
const screenshotProperties = {};
|
|
268
|
-
|
|
269
|
-
// Build properties from comparison API fields (added in backend update)
|
|
270
|
-
// Use current_* fields since we're matching against the current screenshot being tested
|
|
271
|
-
if (comparison.current_viewport_width || comparison.current_browser) {
|
|
272
|
-
if (comparison.current_viewport_width) {
|
|
273
|
-
screenshotProperties.viewport = {
|
|
274
|
-
width: comparison.current_viewport_width,
|
|
275
|
-
height: comparison.current_viewport_height
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
if (comparison.current_browser) {
|
|
279
|
-
screenshotProperties.browser = comparison.current_browser;
|
|
280
|
-
}
|
|
281
|
-
} else if (comparison.baseline_viewport_width || comparison.baseline_browser) {
|
|
282
|
-
// Fallback to baseline properties if current not available
|
|
283
|
-
if (comparison.baseline_viewport_width) {
|
|
284
|
-
screenshotProperties.viewport = {
|
|
285
|
-
width: comparison.baseline_viewport_width,
|
|
286
|
-
height: comparison.baseline_viewport_height
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
if (comparison.baseline_browser) {
|
|
290
|
-
screenshotProperties.browser = comparison.baseline_browser;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
output.info(`📊 Extracted properties for signature: ${JSON.stringify(screenshotProperties)}`);
|
|
294
|
-
|
|
295
|
-
// Generate filename locally for comparison path (we don't have API-provided filename)
|
|
296
|
-
const screenshotName = comparison.baseline_name || comparison.current_name;
|
|
297
|
-
const signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
|
|
298
|
-
const filename = generateBaselineFilename(screenshotName, signature);
|
|
299
|
-
|
|
300
|
-
// For a specific comparison, we only download that one baseline screenshot
|
|
301
|
-
// Create a mock build structure with just this one screenshot
|
|
302
|
-
baselineBuild = {
|
|
303
|
-
id: comparison.baseline_screenshot.build_id || 'comparison-baseline',
|
|
304
|
-
name: `Comparison ${comparisonId.substring(0, 8)}`,
|
|
305
|
-
screenshots: [{
|
|
306
|
-
id: comparison.baseline_screenshot.id,
|
|
307
|
-
name: screenshotName,
|
|
308
|
-
original_url: baselineUrl,
|
|
309
|
-
metadata: screenshotProperties,
|
|
310
|
-
properties: screenshotProperties,
|
|
311
|
-
filename: filename // Generated locally for comparison path
|
|
312
|
-
}]
|
|
313
|
-
};
|
|
314
|
-
} else {
|
|
315
|
-
// Get the latest passed build for this environment and branch
|
|
316
|
-
const builds = await this.api.getBuilds({
|
|
317
|
-
environment,
|
|
318
|
-
branch,
|
|
319
|
-
status: 'passed',
|
|
320
|
-
limit: 1
|
|
321
|
-
});
|
|
322
|
-
if (!builds.data || builds.data.length === 0) {
|
|
323
|
-
output.warn(`⚠️ No baseline builds found for ${environment}/${branch}`);
|
|
324
|
-
output.info('💡 Run a build in normal mode first to create baselines');
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Use getTddBaselines to get screenshots with pre-computed filenames
|
|
329
|
-
const apiResponse = await this.api.getTddBaselines(builds.data[0].id);
|
|
330
|
-
if (!apiResponse) {
|
|
331
|
-
throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Extract signature properties from API response (for variant support)
|
|
335
|
-
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
336
|
-
this.signatureProperties = apiResponse.signatureProperties;
|
|
337
|
-
if (this.signatureProperties.length > 0) {
|
|
338
|
-
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
baselineBuild = apiResponse.build;
|
|
342
|
-
baselineBuild.screenshots = apiResponse.screenshots;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// For both buildId and getBuilds paths, we now have screenshots with filenames
|
|
346
|
-
// For comparisonId, we created a mock build with just the one screenshot
|
|
347
|
-
let buildDetails = baselineBuild;
|
|
348
|
-
if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
|
|
349
|
-
output.warn('⚠️ No screenshots found in baseline build');
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
output.info(`Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
|
|
353
|
-
output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
|
|
354
|
-
|
|
355
|
-
// Check existing baseline metadata for efficient SHA comparison
|
|
356
|
-
let existingBaseline = await this.loadBaseline();
|
|
357
|
-
let existingShaMap = new Map();
|
|
358
|
-
if (existingBaseline) {
|
|
359
|
-
existingBaseline.screenshots.forEach(s => {
|
|
360
|
-
if (s.sha256 && s.filename) {
|
|
361
|
-
existingShaMap.set(s.filename, s.sha256);
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Download screenshots in batches with progress indication
|
|
367
|
-
let downloadedCount = 0;
|
|
368
|
-
let skippedCount = 0;
|
|
369
|
-
let errorCount = 0;
|
|
370
|
-
const totalScreenshots = buildDetails.screenshots.length;
|
|
371
|
-
const batchSize = 5; // Download up to 5 screenshots concurrently
|
|
372
|
-
|
|
373
|
-
// Filter screenshots that need to be downloaded
|
|
374
|
-
const screenshotsToProcess = [];
|
|
375
|
-
for (const screenshot of buildDetails.screenshots) {
|
|
376
|
-
// Sanitize screenshot name for security
|
|
377
|
-
let sanitizedName;
|
|
378
|
-
try {
|
|
379
|
-
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
380
|
-
} catch (error) {
|
|
381
|
-
output.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
|
|
382
|
-
errorCount++;
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Use API-provided filename (required from tdd-baselines endpoint)
|
|
387
|
-
// This ensures filenames match between cloud and local TDD
|
|
388
|
-
let filename = screenshot.filename;
|
|
389
|
-
if (!filename) {
|
|
390
|
-
output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
|
|
391
|
-
errorCount++;
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
let imagePath = safePath(this.baselinePath, filename);
|
|
395
|
-
|
|
396
|
-
// Check if we already have this file with the same SHA
|
|
397
|
-
if (existsSync(imagePath) && screenshot.sha256) {
|
|
398
|
-
let storedSha = existingShaMap.get(filename);
|
|
399
|
-
if (storedSha === screenshot.sha256) {
|
|
400
|
-
downloadedCount++;
|
|
401
|
-
skippedCount++;
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Use original_url as the download URL
|
|
407
|
-
const downloadUrl = screenshot.original_url || screenshot.url;
|
|
408
|
-
if (!downloadUrl) {
|
|
409
|
-
output.warn(`⚠️ Screenshot ${sanitizedName} has no download URL - skipping`);
|
|
410
|
-
errorCount++;
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
screenshotsToProcess.push({
|
|
414
|
-
screenshot,
|
|
415
|
-
sanitizedName,
|
|
416
|
-
imagePath,
|
|
417
|
-
downloadUrl,
|
|
418
|
-
filename
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Process downloads in batches
|
|
423
|
-
const actualDownloadsNeeded = screenshotsToProcess.length;
|
|
424
|
-
if (actualDownloadsNeeded > 0) {
|
|
425
|
-
output.info(`📥 Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...`);
|
|
426
|
-
for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
|
|
427
|
-
const batch = screenshotsToProcess.slice(i, i + batchSize);
|
|
428
|
-
const batchNum = Math.floor(i / batchSize) + 1;
|
|
429
|
-
const totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
|
|
430
|
-
output.info(`📦 Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)`);
|
|
431
|
-
|
|
432
|
-
// Download batch concurrently
|
|
433
|
-
const downloadPromises = batch.map(async ({
|
|
434
|
-
sanitizedName,
|
|
435
|
-
imagePath,
|
|
436
|
-
downloadUrl
|
|
437
|
-
}) => {
|
|
438
|
-
try {
|
|
439
|
-
const response = await fetchWithTimeout(downloadUrl);
|
|
440
|
-
if (!response.ok) {
|
|
441
|
-
throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
|
|
442
|
-
}
|
|
443
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
444
|
-
const imageBuffer = Buffer.from(arrayBuffer);
|
|
445
|
-
writeFileSync(imagePath, imageBuffer);
|
|
446
|
-
return {
|
|
447
|
-
success: true,
|
|
448
|
-
name: sanitizedName
|
|
449
|
-
};
|
|
450
|
-
} catch (error) {
|
|
451
|
-
output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
|
|
452
|
-
return {
|
|
453
|
-
success: false,
|
|
454
|
-
name: sanitizedName,
|
|
455
|
-
error: error.message
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
const batchResults = await Promise.all(downloadPromises);
|
|
460
|
-
const batchSuccesses = batchResults.filter(r => r.success).length;
|
|
461
|
-
const batchFailures = batchResults.filter(r => !r.success).length;
|
|
462
|
-
downloadedCount += batchSuccesses;
|
|
463
|
-
errorCount += batchFailures;
|
|
464
|
-
|
|
465
|
-
// Show progress
|
|
466
|
-
const totalProcessed = downloadedCount + skippedCount + errorCount;
|
|
467
|
-
const progressPercent = Math.round(totalProcessed / totalScreenshots * 100);
|
|
468
|
-
output.info(`📊 Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch`);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Check if we actually downloaded any screenshots
|
|
473
|
-
if (downloadedCount === 0 && skippedCount === 0) {
|
|
474
|
-
output.error('❌ No screenshots were successfully downloaded from the baseline build');
|
|
475
|
-
if (errorCount > 0) {
|
|
476
|
-
output.info(`💡 ${errorCount} screenshots had errors - check download URLs and network connection`);
|
|
477
|
-
}
|
|
478
|
-
output.info('💡 This usually means the build failed or screenshots have no download URLs');
|
|
479
|
-
output.info('💡 Try using a successful build ID, or run without --baseline-build to create local baselines');
|
|
480
|
-
return null;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Store enhanced baseline metadata with SHA hashes and build info
|
|
484
|
-
this.baselineData = {
|
|
485
|
-
buildId: baselineBuild.id,
|
|
486
|
-
buildName: baselineBuild.name,
|
|
487
|
-
environment,
|
|
488
|
-
branch,
|
|
489
|
-
threshold: this.threshold,
|
|
490
|
-
signatureProperties: this.signatureProperties,
|
|
491
|
-
// Store for TDD comparison
|
|
492
|
-
createdAt: new Date().toISOString(),
|
|
493
|
-
buildInfo: {
|
|
494
|
-
commitSha: baselineBuild.commit_sha,
|
|
495
|
-
commitMessage: baselineBuild.commit_message,
|
|
496
|
-
approvalStatus: baselineBuild.approval_status,
|
|
497
|
-
completedAt: baselineBuild.completed_at
|
|
498
|
-
},
|
|
499
|
-
screenshots: buildDetails.screenshots.filter(s => s.filename) // Only include screenshots with filenames
|
|
500
|
-
.map(s => ({
|
|
501
|
-
name: sanitizeScreenshotName(s.name),
|
|
502
|
-
originalName: s.name,
|
|
503
|
-
sha256: s.sha256,
|
|
504
|
-
id: s.id,
|
|
505
|
-
filename: s.filename,
|
|
506
|
-
path: safePath(this.baselinePath, s.filename),
|
|
507
|
-
browser: s.browser,
|
|
508
|
-
viewport_width: s.viewport_width,
|
|
509
|
-
originalUrl: s.original_url,
|
|
510
|
-
fileSize: s.file_size_bytes,
|
|
511
|
-
dimensions: {
|
|
512
|
-
width: s.width,
|
|
513
|
-
height: s.height
|
|
514
|
-
}
|
|
515
|
-
}))
|
|
516
|
-
};
|
|
517
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
518
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
519
|
-
|
|
520
|
-
// Download hotspot data for noise filtering
|
|
521
|
-
await this.downloadHotspots(buildDetails.screenshots);
|
|
522
|
-
|
|
523
|
-
// Save baseline build metadata for MCP plugin
|
|
524
|
-
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
525
|
-
const buildMetadata = {
|
|
526
|
-
buildId: baselineBuild.id,
|
|
527
|
-
buildName: baselineBuild.name,
|
|
528
|
-
branch: branch,
|
|
529
|
-
environment: environment,
|
|
530
|
-
commitSha: baselineBuild.commit_sha,
|
|
531
|
-
commitMessage: baselineBuild.commit_message,
|
|
532
|
-
approvalStatus: baselineBuild.approval_status,
|
|
533
|
-
completedAt: baselineBuild.completed_at,
|
|
534
|
-
downloadedAt: new Date().toISOString()
|
|
535
|
-
};
|
|
536
|
-
writeFileSync(baselineMetadataPath, JSON.stringify(buildMetadata, null, 2));
|
|
537
|
-
|
|
538
|
-
// Final summary
|
|
539
|
-
const actualDownloads = downloadedCount - skippedCount;
|
|
540
|
-
if (skippedCount > 0) {
|
|
541
|
-
// All skipped (up-to-date)
|
|
542
|
-
if (actualDownloads === 0) {
|
|
543
|
-
output.info(`✅ All ${skippedCount} baselines up-to-date (matching local SHA)`);
|
|
544
|
-
} else {
|
|
545
|
-
// Mixed: some downloaded, some skipped
|
|
546
|
-
output.info(`✅ Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date`);
|
|
547
|
-
}
|
|
548
|
-
} else {
|
|
549
|
-
// Fresh download
|
|
550
|
-
output.info(`✅ Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully`);
|
|
551
|
-
}
|
|
552
|
-
if (errorCount > 0) {
|
|
553
|
-
output.warn(`⚠️ ${errorCount} screenshots failed to download`);
|
|
554
|
-
}
|
|
555
|
-
return this.baselineData;
|
|
556
|
-
} catch (error) {
|
|
557
|
-
output.error(`❌ Failed to download baseline: ${error.message}`);
|
|
558
|
-
throw error;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Download hotspot data for screenshots from the cloud
|
|
564
|
-
* Hotspots identify regions that frequently change (timestamps, IDs, etc.)
|
|
565
|
-
* Used to filter out known dynamic content during comparisons
|
|
566
|
-
* @param {Array} screenshots - Array of screenshot objects with name property
|
|
567
|
-
*/
|
|
568
|
-
async downloadHotspots(screenshots) {
|
|
569
|
-
// Only attempt if we have an API token
|
|
570
|
-
if (!this.config.apiKey) {
|
|
571
|
-
output.debug('tdd', 'Skipping hotspot download - no API token configured');
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
try {
|
|
575
|
-
// Get unique screenshot names
|
|
576
|
-
const screenshotNames = [...new Set(screenshots.map(s => s.name))];
|
|
577
|
-
if (screenshotNames.length === 0) {
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
|
|
581
|
-
|
|
582
|
-
// Use batch endpoint for efficiency
|
|
583
|
-
const response = await this.api.getBatchHotspots(screenshotNames);
|
|
584
|
-
if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
|
|
585
|
-
output.debug('tdd', 'No hotspot data available from cloud');
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Store hotspots in a separate file for easy access during comparisons
|
|
590
|
-
this.hotspotData = response.hotspots;
|
|
591
|
-
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
592
|
-
writeFileSync(hotspotsPath, JSON.stringify({
|
|
593
|
-
downloadedAt: new Date().toISOString(),
|
|
594
|
-
summary: response.summary,
|
|
595
|
-
hotspots: response.hotspots
|
|
596
|
-
}, null, 2));
|
|
597
|
-
const hotspotCount = Object.keys(response.hotspots).length;
|
|
598
|
-
const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
|
|
599
|
-
output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
|
|
600
|
-
} catch (error) {
|
|
601
|
-
// Don't fail baseline download if hotspot fetch fails
|
|
602
|
-
output.debug('tdd', `Hotspot download failed: ${error.message}`);
|
|
603
|
-
output.warn('⚠️ Could not fetch hotspot data - comparisons will run without noise filtering');
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Load hotspot data from disk
|
|
609
|
-
* @returns {Object|null} Hotspot data keyed by screenshot name, or null if not available
|
|
610
|
-
*/
|
|
611
|
-
loadHotspots() {
|
|
612
|
-
try {
|
|
613
|
-
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
614
|
-
if (!existsSync(hotspotsPath)) {
|
|
615
|
-
return null;
|
|
616
|
-
}
|
|
617
|
-
const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
|
|
618
|
-
return data.hotspots || null;
|
|
619
|
-
} catch (error) {
|
|
620
|
-
output.debug('tdd', `Failed to load hotspots: ${error.message}`);
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Get hotspot analysis for a specific screenshot
|
|
627
|
-
* @param {string} screenshotName - Name of the screenshot
|
|
628
|
-
* @returns {Object|null} Hotspot analysis or null if not available
|
|
629
|
-
*/
|
|
630
|
-
getHotspotForScreenshot(screenshotName) {
|
|
631
|
-
// Check memory cache first
|
|
632
|
-
if (this.hotspotData?.[screenshotName]) {
|
|
633
|
-
return this.hotspotData[screenshotName];
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Try loading from disk
|
|
637
|
-
if (!this.hotspotData) {
|
|
638
|
-
this.hotspotData = this.loadHotspots();
|
|
639
|
-
}
|
|
640
|
-
return this.hotspotData?.[screenshotName] || null;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Calculate what percentage of diff falls within hotspot regions
|
|
645
|
-
* Uses 1D Y-coordinate matching (same algorithm as cloud)
|
|
646
|
-
* @param {Array} diffClusters - Array of diff clusters from honeydiff
|
|
647
|
-
* @param {Object} hotspotAnalysis - Hotspot data with regions array
|
|
648
|
-
* @returns {Object} Coverage info { coverage, linesInHotspots, totalLines }
|
|
649
|
-
*/
|
|
650
|
-
calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
|
|
651
|
-
if (!diffClusters || diffClusters.length === 0) {
|
|
652
|
-
return {
|
|
653
|
-
coverage: 0,
|
|
654
|
-
linesInHotspots: 0,
|
|
655
|
-
totalLines: 0
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
|
|
659
|
-
return {
|
|
660
|
-
coverage: 0,
|
|
661
|
-
linesInHotspots: 0,
|
|
662
|
-
totalLines: 0
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Extract Y-coordinates (diff lines) from clusters
|
|
667
|
-
// Each cluster has a boundingBox with y and height
|
|
668
|
-
let diffLines = [];
|
|
669
|
-
for (const cluster of diffClusters) {
|
|
670
|
-
if (cluster.boundingBox) {
|
|
671
|
-
const {
|
|
672
|
-
y,
|
|
673
|
-
height
|
|
674
|
-
} = cluster.boundingBox;
|
|
675
|
-
// Add all Y lines covered by this cluster
|
|
676
|
-
for (let line = y; line < y + height; line++) {
|
|
677
|
-
diffLines.push(line);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
if (diffLines.length === 0) {
|
|
682
|
-
return {
|
|
683
|
-
coverage: 0,
|
|
684
|
-
linesInHotspots: 0,
|
|
685
|
-
totalLines: 0
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Remove duplicates and sort
|
|
690
|
-
diffLines = [...new Set(diffLines)].sort((a, b) => a - b);
|
|
691
|
-
|
|
692
|
-
// Check how many diff lines fall within hotspot regions
|
|
693
|
-
let linesInHotspots = 0;
|
|
694
|
-
for (const line of diffLines) {
|
|
695
|
-
const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
|
|
696
|
-
if (inHotspot) {
|
|
697
|
-
linesInHotspots++;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
const coverage = linesInHotspots / diffLines.length;
|
|
701
|
-
return {
|
|
702
|
-
coverage,
|
|
703
|
-
linesInHotspots,
|
|
704
|
-
totalLines: diffLines.length
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Handle local baseline logic (either load existing or prepare for new baselines)
|
|
710
|
-
* @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
|
|
711
|
-
*/
|
|
712
|
-
async handleLocalBaselines() {
|
|
713
|
-
// Check if we're in baseline update mode - skip loading existing baselines
|
|
714
|
-
if (this.setBaseline) {
|
|
715
|
-
output.info('📁 Ready for new baseline creation - all screenshots will be treated as new baselines');
|
|
716
|
-
|
|
717
|
-
// Reset baseline data since we're creating new ones
|
|
718
|
-
this.baselineData = null;
|
|
719
|
-
return null;
|
|
720
|
-
}
|
|
721
|
-
const baseline = await this.loadBaseline();
|
|
722
|
-
if (!baseline) {
|
|
723
|
-
if (this.config.apiKey) {
|
|
724
|
-
output.info('📥 No local baseline found, but API key available for future remote fetching');
|
|
725
|
-
output.info('🆕 Current run will create new local baselines');
|
|
726
|
-
} else {
|
|
727
|
-
output.info('📝 No local baseline found and no API token - all screenshots will be marked as new');
|
|
728
|
-
}
|
|
729
|
-
return null;
|
|
730
|
-
} else {
|
|
731
|
-
output.info(`✅ Using existing baseline: ${colors.cyan(baseline.buildName)}`);
|
|
732
|
-
return baseline;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
async loadBaseline() {
|
|
736
|
-
// In baseline update mode, never load existing baselines
|
|
737
|
-
if (this.setBaseline) {
|
|
738
|
-
output.debug('tdd', 'baseline update mode - skipping loading');
|
|
739
|
-
return null;
|
|
740
|
-
}
|
|
741
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
742
|
-
if (!existsSync(metadataPath)) {
|
|
743
|
-
return null;
|
|
744
|
-
}
|
|
745
|
-
try {
|
|
746
|
-
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
747
|
-
this.baselineData = metadata;
|
|
748
|
-
this.threshold = metadata.threshold || this.threshold;
|
|
749
|
-
|
|
750
|
-
// Restore signature properties from saved metadata (for variant support)
|
|
751
|
-
this.signatureProperties = metadata.signatureProperties || this.signatureProperties;
|
|
752
|
-
if (this.signatureProperties.length > 0) {
|
|
753
|
-
output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
|
|
754
|
-
}
|
|
755
|
-
return metadata;
|
|
756
|
-
} catch (error) {
|
|
757
|
-
output.error(`❌ Failed to load baseline metadata: ${error.message}`);
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
async compareScreenshot(name, imageBuffer, properties = {}) {
|
|
762
|
-
// Sanitize screenshot name and validate properties
|
|
763
|
-
let sanitizedName;
|
|
764
|
-
try {
|
|
765
|
-
sanitizedName = sanitizeScreenshotName(name);
|
|
766
|
-
} catch (error) {
|
|
767
|
-
output.error(`Invalid screenshot name '${name}': ${error.message}`);
|
|
768
|
-
throw new Error(`Screenshot name validation failed: ${error.message}`);
|
|
769
|
-
}
|
|
770
|
-
let validatedProperties;
|
|
771
|
-
try {
|
|
772
|
-
validatedProperties = validateScreenshotProperties(properties);
|
|
773
|
-
} catch (error) {
|
|
774
|
-
output.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
|
|
775
|
-
validatedProperties = {};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
|
|
779
|
-
// This is needed because signature generation checks properties.metadata.* for custom properties
|
|
780
|
-
if (properties.metadata && typeof properties.metadata === 'object') {
|
|
781
|
-
validatedProperties.metadata = properties.metadata;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Normalize properties to match backend format (viewport_width at top level)
|
|
785
|
-
// This ensures signature generation matches backend's screenshot-identity.js
|
|
786
|
-
if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
|
|
787
|
-
validatedProperties.viewport_width = validatedProperties.viewport.width;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Generate signature for baseline matching (name + viewport_width + browser + custom props)
|
|
791
|
-
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
792
|
-
// Use hash-based filename for reliable matching (matches cloud format)
|
|
793
|
-
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
794
|
-
const currentImagePath = safePath(this.currentPath, filename);
|
|
795
|
-
const baselineImagePath = safePath(this.baselinePath, filename);
|
|
796
|
-
const diffImagePath = safePath(this.diffPath, filename);
|
|
797
|
-
|
|
798
|
-
// Save current screenshot
|
|
799
|
-
writeFileSync(currentImagePath, imageBuffer);
|
|
800
|
-
|
|
801
|
-
// Check if we're in baseline update mode - treat as first run, no comparisons
|
|
802
|
-
if (this.setBaseline) {
|
|
803
|
-
return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// Check if baseline exists
|
|
807
|
-
const baselineExists = existsSync(baselineImagePath);
|
|
808
|
-
if (!baselineExists) {
|
|
809
|
-
// Copy current screenshot to baseline directory for future comparisons
|
|
810
|
-
writeFileSync(baselineImagePath, imageBuffer);
|
|
811
|
-
|
|
812
|
-
// Update or create baseline metadata
|
|
813
|
-
if (!this.baselineData) {
|
|
814
|
-
this.baselineData = {
|
|
815
|
-
buildId: 'local-baseline',
|
|
816
|
-
buildName: 'Local TDD Baseline',
|
|
817
|
-
environment: 'test',
|
|
818
|
-
branch: 'local',
|
|
819
|
-
threshold: this.threshold,
|
|
820
|
-
screenshots: []
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Add screenshot to baseline metadata
|
|
825
|
-
const screenshotEntry = {
|
|
826
|
-
name: sanitizedName,
|
|
827
|
-
properties: validatedProperties,
|
|
828
|
-
path: baselineImagePath,
|
|
829
|
-
signature: signature
|
|
830
|
-
};
|
|
831
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
832
|
-
if (existingIndex >= 0) {
|
|
833
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
834
|
-
} else {
|
|
835
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// Save updated metadata
|
|
839
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
840
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
841
|
-
|
|
842
|
-
// Baseline creation tracked by event handler
|
|
843
|
-
|
|
844
|
-
const result = {
|
|
845
|
-
id: generateComparisonId(signature),
|
|
846
|
-
name: sanitizedName,
|
|
847
|
-
status: 'new',
|
|
848
|
-
baseline: baselineImagePath,
|
|
849
|
-
current: currentImagePath,
|
|
850
|
-
diff: null,
|
|
851
|
-
properties: validatedProperties,
|
|
852
|
-
signature
|
|
853
|
-
};
|
|
854
|
-
this.comparisons.push(result);
|
|
855
|
-
return result;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Baseline exists - compare with it
|
|
859
|
-
try {
|
|
860
|
-
// Per-screenshot threshold/minClusterSize override support
|
|
861
|
-
// Priority: screenshot-level > config > defaults
|
|
862
|
-
// Validate overrides before using them
|
|
863
|
-
const effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
|
|
864
|
-
const effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
|
|
865
|
-
|
|
866
|
-
// Try to compare - honeydiff will throw if dimensions don't match
|
|
867
|
-
const result = await compare(baselineImagePath, currentImagePath, {
|
|
868
|
-
threshold: effectiveThreshold,
|
|
869
|
-
// CIEDE2000 Delta E (2.0 = recommended default)
|
|
870
|
-
antialiasing: true,
|
|
871
|
-
diffPath: diffImagePath,
|
|
872
|
-
overwrite: true,
|
|
873
|
-
includeClusters: true,
|
|
874
|
-
// Enable spatial clustering analysis
|
|
875
|
-
minClusterSize: effectiveMinClusterSize // Filter single-pixel noise (default: 2)
|
|
876
|
-
});
|
|
877
|
-
if (!result.isDifferent) {
|
|
878
|
-
// Images match
|
|
879
|
-
const comparison = {
|
|
880
|
-
id: generateComparisonId(signature),
|
|
881
|
-
name: sanitizedName,
|
|
882
|
-
status: 'passed',
|
|
883
|
-
baseline: baselineImagePath,
|
|
884
|
-
current: currentImagePath,
|
|
885
|
-
diff: null,
|
|
886
|
-
properties: validatedProperties,
|
|
887
|
-
signature,
|
|
888
|
-
threshold: effectiveThreshold,
|
|
889
|
-
minClusterSize: effectiveMinClusterSize,
|
|
890
|
-
// Include honeydiff metrics even for passing comparisons
|
|
891
|
-
totalPixels: result.totalPixels,
|
|
892
|
-
aaPixelsIgnored: result.aaPixelsIgnored,
|
|
893
|
-
aaPercentage: result.aaPercentage
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
// Result tracked by event handler
|
|
897
|
-
this.comparisons.push(comparison);
|
|
898
|
-
return comparison;
|
|
899
|
-
} else {
|
|
900
|
-
// Images differ - check if differences are in known hotspot regions
|
|
901
|
-
const hotspotAnalysis = this.getHotspotForScreenshot(name);
|
|
902
|
-
let hotspotCoverage = null;
|
|
903
|
-
let isHotspotFiltered = false;
|
|
904
|
-
if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
|
|
905
|
-
hotspotCoverage = this.calculateHotspotCoverage(result.diffClusters, hotspotAnalysis);
|
|
906
|
-
|
|
907
|
-
// Consider it filtered if:
|
|
908
|
-
// 1. High confidence hotspot data (score >= 70)
|
|
909
|
-
// 2. 80%+ of the diff is within hotspot regions
|
|
910
|
-
const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
|
|
911
|
-
if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
|
|
912
|
-
isHotspotFiltered = true;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
let diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffPixels} pixels)`;
|
|
916
|
-
|
|
917
|
-
// Add cluster info to log if available
|
|
918
|
-
if (result.diffClusters && result.diffClusters.length > 0) {
|
|
919
|
-
diffInfo += `, ${result.diffClusters.length} region${result.diffClusters.length > 1 ? 's' : ''}`;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// Add hotspot info if applicable
|
|
923
|
-
if (hotspotCoverage && hotspotCoverage.coverage > 0) {
|
|
924
|
-
diffInfo += `, ${Math.round(hotspotCoverage.coverage * 100)}% in hotspots`;
|
|
925
|
-
}
|
|
926
|
-
const comparison = {
|
|
927
|
-
id: generateComparisonId(signature),
|
|
928
|
-
name: sanitizedName,
|
|
929
|
-
status: isHotspotFiltered ? 'passed' : 'failed',
|
|
930
|
-
baseline: baselineImagePath,
|
|
931
|
-
current: currentImagePath,
|
|
932
|
-
diff: diffImagePath,
|
|
933
|
-
properties: validatedProperties,
|
|
934
|
-
signature,
|
|
935
|
-
threshold: effectiveThreshold,
|
|
936
|
-
minClusterSize: effectiveMinClusterSize,
|
|
937
|
-
diffPercentage: result.diffPercentage,
|
|
938
|
-
diffCount: result.diffPixels,
|
|
939
|
-
reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
|
|
940
|
-
// Honeydiff metrics
|
|
941
|
-
totalPixels: result.totalPixels,
|
|
942
|
-
aaPixelsIgnored: result.aaPixelsIgnored,
|
|
943
|
-
aaPercentage: result.aaPercentage,
|
|
944
|
-
boundingBox: result.boundingBox,
|
|
945
|
-
heightDiff: result.heightDiff,
|
|
946
|
-
intensityStats: result.intensityStats,
|
|
947
|
-
diffClusters: result.diffClusters,
|
|
948
|
-
// Hotspot analysis data
|
|
949
|
-
hotspotAnalysis: hotspotCoverage ? {
|
|
950
|
-
coverage: hotspotCoverage.coverage,
|
|
951
|
-
linesInHotspots: hotspotCoverage.linesInHotspots,
|
|
952
|
-
totalLines: hotspotCoverage.totalLines,
|
|
953
|
-
confidence: hotspotAnalysis?.confidence,
|
|
954
|
-
confidenceScore: hotspotAnalysis?.confidence_score,
|
|
955
|
-
regionCount: hotspotAnalysis?.regions?.length || 0,
|
|
956
|
-
isFiltered: isHotspotFiltered
|
|
957
|
-
} : null
|
|
958
|
-
};
|
|
959
|
-
if (isHotspotFiltered) {
|
|
960
|
-
output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
|
|
961
|
-
output.debug('tdd', `Hotspot filtered: ${Math.round(hotspotCoverage.coverage * 100)}% coverage, confidence: ${hotspotAnalysis.confidence}`);
|
|
962
|
-
} else {
|
|
963
|
-
output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
|
|
964
|
-
output.info(` Diff saved to: ${diffImagePath}`);
|
|
965
|
-
}
|
|
966
|
-
this.comparisons.push(comparison);
|
|
967
|
-
return comparison;
|
|
968
|
-
}
|
|
969
|
-
} catch (error) {
|
|
970
|
-
// Check if error is due to dimension mismatch
|
|
971
|
-
const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
|
|
972
|
-
if (isDimensionMismatch) {
|
|
973
|
-
// Different dimensions = different screenshot signature
|
|
974
|
-
// This shouldn't happen if signatures are working correctly, but handle gracefully
|
|
975
|
-
output.warn(`⚠️ Dimension mismatch for ${sanitizedName} - baseline file exists but has different dimensions`);
|
|
976
|
-
output.warn(` This indicates a signature collision. Creating new baseline with correct signature.`);
|
|
977
|
-
output.debug('tdd', 'dimension mismatch', {
|
|
978
|
-
error: error.message
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
// Create a new baseline for this screenshot (overwriting the incorrect one)
|
|
982
|
-
writeFileSync(baselineImagePath, imageBuffer);
|
|
983
|
-
|
|
984
|
-
// Update baseline metadata
|
|
985
|
-
if (!this.baselineData) {
|
|
986
|
-
this.baselineData = {
|
|
987
|
-
buildId: 'local-baseline',
|
|
988
|
-
buildName: 'Local TDD Baseline',
|
|
989
|
-
environment: 'test',
|
|
990
|
-
branch: 'local',
|
|
991
|
-
threshold: this.threshold,
|
|
992
|
-
screenshots: []
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
const screenshotEntry = {
|
|
996
|
-
name: sanitizedName,
|
|
997
|
-
properties: validatedProperties,
|
|
998
|
-
path: baselineImagePath,
|
|
999
|
-
signature: signature
|
|
1000
|
-
};
|
|
1001
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
1002
|
-
if (existingIndex >= 0) {
|
|
1003
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
1004
|
-
} else {
|
|
1005
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
1006
|
-
}
|
|
1007
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
1008
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
1009
|
-
output.info(`✅ Created new baseline for ${sanitizedName} (different dimensions)`);
|
|
1010
|
-
const comparison = {
|
|
1011
|
-
id: generateComparisonId(signature),
|
|
1012
|
-
name: sanitizedName,
|
|
1013
|
-
status: 'new',
|
|
1014
|
-
baseline: baselineImagePath,
|
|
1015
|
-
current: currentImagePath,
|
|
1016
|
-
diff: null,
|
|
1017
|
-
properties: validatedProperties,
|
|
1018
|
-
signature
|
|
1019
|
-
};
|
|
1020
|
-
this.comparisons.push(comparison);
|
|
1021
|
-
return comparison;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
// Handle other file errors or issues
|
|
1025
|
-
output.error(`❌ Error comparing ${sanitizedName}: ${error.message}`);
|
|
1026
|
-
const comparison = {
|
|
1027
|
-
id: generateComparisonId(signature),
|
|
1028
|
-
name: sanitizedName,
|
|
1029
|
-
status: 'error',
|
|
1030
|
-
baseline: baselineImagePath,
|
|
1031
|
-
current: currentImagePath,
|
|
1032
|
-
diff: null,
|
|
1033
|
-
properties: validatedProperties,
|
|
1034
|
-
signature,
|
|
1035
|
-
error: error.message
|
|
1036
|
-
};
|
|
1037
|
-
this.comparisons.push(comparison);
|
|
1038
|
-
return comparison;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
getResults() {
|
|
1042
|
-
const passed = this.comparisons.filter(c => c.status === 'passed').length;
|
|
1043
|
-
const failed = this.comparisons.filter(c => c.status === 'failed').length;
|
|
1044
|
-
const newScreenshots = this.comparisons.filter(c => c.status === 'new').length;
|
|
1045
|
-
const errors = this.comparisons.filter(c => c.status === 'error').length;
|
|
1046
|
-
return {
|
|
1047
|
-
total: this.comparisons.length,
|
|
1048
|
-
passed,
|
|
1049
|
-
failed,
|
|
1050
|
-
new: newScreenshots,
|
|
1051
|
-
errors,
|
|
1052
|
-
comparisons: this.comparisons,
|
|
1053
|
-
baseline: this.baselineData
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
async printResults() {
|
|
1057
|
-
const results = this.getResults();
|
|
1058
|
-
output.info('\n📊 TDD Results:');
|
|
1059
|
-
output.info(`Total: ${colors.cyan(results.total)}`);
|
|
1060
|
-
output.info(`Passed: ${colors.green(results.passed)}`);
|
|
1061
|
-
if (results.failed > 0) {
|
|
1062
|
-
output.info(`Failed: ${colors.red(results.failed)}`);
|
|
1063
|
-
}
|
|
1064
|
-
if (results.new > 0) {
|
|
1065
|
-
output.info(`New: ${colors.yellow(results.new)}`);
|
|
1066
|
-
}
|
|
1067
|
-
if (results.errors > 0) {
|
|
1068
|
-
output.info(`Errors: ${colors.red(results.errors)}`);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Show failed comparisons
|
|
1072
|
-
const failedComparisons = results.comparisons.filter(c => c.status === 'failed');
|
|
1073
|
-
if (failedComparisons.length > 0) {
|
|
1074
|
-
output.info('\n❌ Failed comparisons:');
|
|
1075
|
-
failedComparisons.forEach(comp => {
|
|
1076
|
-
output.info(` • ${comp.name}`);
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// Show new screenshots
|
|
1081
|
-
const newComparisons = results.comparisons.filter(c => c.status === 'new');
|
|
1082
|
-
if (newComparisons.length > 0) {
|
|
1083
|
-
output.info('\n📸 New screenshots:');
|
|
1084
|
-
newComparisons.forEach(comp => {
|
|
1085
|
-
output.info(` • ${comp.name}`);
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// Generate HTML report
|
|
1090
|
-
await this.generateHtmlReport(results);
|
|
1091
|
-
return results;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/**
|
|
1095
|
-
* Generate HTML report for TDD results
|
|
1096
|
-
* @param {Object} results - TDD comparison results
|
|
1097
|
-
*/
|
|
1098
|
-
async generateHtmlReport(results) {
|
|
1099
|
-
try {
|
|
1100
|
-
const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
|
|
1101
|
-
const reportPath = await reportGenerator.generateReport(results, {
|
|
1102
|
-
baseline: this.baselineData,
|
|
1103
|
-
threshold: this.threshold
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
// Show report path (always clickable)
|
|
1107
|
-
output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
|
|
1108
|
-
|
|
1109
|
-
// Auto-open if configured
|
|
1110
|
-
if (this.config.tdd?.openReport) {
|
|
1111
|
-
await this.openReport(reportPath);
|
|
1112
|
-
}
|
|
1113
|
-
return reportPath;
|
|
1114
|
-
} catch (error) {
|
|
1115
|
-
output.warn(`Failed to generate HTML report: ${error.message}`);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
/**
|
|
1120
|
-
* Open HTML report in default browser
|
|
1121
|
-
* @param {string} reportPath - Path to HTML report
|
|
1122
|
-
*/
|
|
1123
|
-
async openReport(reportPath) {
|
|
1124
|
-
try {
|
|
1125
|
-
const {
|
|
1126
|
-
exec
|
|
1127
|
-
} = await import('node:child_process');
|
|
1128
|
-
const {
|
|
1129
|
-
promisify
|
|
1130
|
-
} = await import('node:util');
|
|
1131
|
-
const execAsync = promisify(exec);
|
|
1132
|
-
let command;
|
|
1133
|
-
switch (process.platform) {
|
|
1134
|
-
case 'darwin':
|
|
1135
|
-
// macOS
|
|
1136
|
-
command = `open "${reportPath}"`;
|
|
1137
|
-
break;
|
|
1138
|
-
case 'win32':
|
|
1139
|
-
// Windows
|
|
1140
|
-
command = `start "" "${reportPath}"`;
|
|
1141
|
-
break;
|
|
1142
|
-
default:
|
|
1143
|
-
// Linux and others
|
|
1144
|
-
command = `xdg-open "${reportPath}"`;
|
|
1145
|
-
break;
|
|
1146
|
-
}
|
|
1147
|
-
await execAsync(command);
|
|
1148
|
-
output.info('📖 Report opened in browser');
|
|
1149
|
-
} catch {
|
|
1150
|
-
// Browser open may fail silently
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Update baselines with current screenshots (accept changes)
|
|
1156
|
-
* @returns {number} Number of baselines updated
|
|
1157
|
-
*/
|
|
1158
|
-
updateBaselines() {
|
|
1159
|
-
if (this.comparisons.length === 0) {
|
|
1160
|
-
output.warn('No comparisons found - nothing to update');
|
|
1161
|
-
return 0;
|
|
1162
|
-
}
|
|
1163
|
-
let updatedCount = 0;
|
|
1164
|
-
|
|
1165
|
-
// Initialize baseline data if it doesn't exist
|
|
1166
|
-
if (!this.baselineData) {
|
|
1167
|
-
this.baselineData = {
|
|
1168
|
-
buildId: 'local-baseline',
|
|
1169
|
-
buildName: 'Local TDD Baseline',
|
|
1170
|
-
environment: 'test',
|
|
1171
|
-
branch: 'local',
|
|
1172
|
-
threshold: this.threshold,
|
|
1173
|
-
screenshots: []
|
|
1174
|
-
};
|
|
1175
|
-
}
|
|
1176
|
-
for (const comparison of this.comparisons) {
|
|
1177
|
-
const {
|
|
1178
|
-
name,
|
|
1179
|
-
current
|
|
1180
|
-
} = comparison;
|
|
1181
|
-
if (!current || !existsSync(current)) {
|
|
1182
|
-
output.warn(`Current screenshot not found for ${name}, skipping`);
|
|
1183
|
-
continue;
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
// Sanitize screenshot name for security
|
|
1187
|
-
let sanitizedName;
|
|
1188
|
-
try {
|
|
1189
|
-
sanitizedName = sanitizeScreenshotName(name);
|
|
1190
|
-
} catch (error) {
|
|
1191
|
-
output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
1192
|
-
continue;
|
|
1193
|
-
}
|
|
1194
|
-
const validatedProperties = validateScreenshotProperties(comparison.properties || {});
|
|
1195
|
-
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
1196
|
-
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
1197
|
-
const baselineImagePath = safePath(this.baselinePath, filename);
|
|
1198
|
-
try {
|
|
1199
|
-
// Copy current screenshot to baseline
|
|
1200
|
-
const currentBuffer = readFileSync(current);
|
|
1201
|
-
writeFileSync(baselineImagePath, currentBuffer);
|
|
1202
|
-
|
|
1203
|
-
// Update baseline metadata
|
|
1204
|
-
const screenshotEntry = {
|
|
1205
|
-
name: sanitizedName,
|
|
1206
|
-
properties: validatedProperties,
|
|
1207
|
-
path: baselineImagePath,
|
|
1208
|
-
signature: signature
|
|
1209
|
-
};
|
|
1210
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
1211
|
-
if (existingIndex >= 0) {
|
|
1212
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
1213
|
-
} else {
|
|
1214
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
1215
|
-
}
|
|
1216
|
-
updatedCount++;
|
|
1217
|
-
output.info(`✅ Updated baseline for ${sanitizedName}`);
|
|
1218
|
-
} catch (error) {
|
|
1219
|
-
output.error(`❌ Failed to update baseline for ${sanitizedName}: ${error.message}`);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// Save updated metadata
|
|
1224
|
-
if (updatedCount > 0) {
|
|
1225
|
-
try {
|
|
1226
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
1227
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
1228
|
-
output.info(`✅ Updated ${updatedCount} baseline(s)`);
|
|
1229
|
-
} catch (error) {
|
|
1230
|
-
output.error(`❌ Failed to save baseline metadata: ${error.message}`);
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
return updatedCount;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* Create a new baseline (used during --set-baseline mode)
|
|
1238
|
-
* @private
|
|
1239
|
-
*/
|
|
1240
|
-
createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
1241
|
-
output.info(`🐻 Creating baseline for ${name}`);
|
|
1242
|
-
|
|
1243
|
-
// Copy current screenshot to baseline directory
|
|
1244
|
-
writeFileSync(baselineImagePath, imageBuffer);
|
|
1245
|
-
|
|
1246
|
-
// Update or create baseline metadata
|
|
1247
|
-
if (!this.baselineData) {
|
|
1248
|
-
this.baselineData = {
|
|
1249
|
-
buildId: 'local-baseline',
|
|
1250
|
-
buildName: 'Local TDD Baseline',
|
|
1251
|
-
environment: 'test',
|
|
1252
|
-
branch: 'local',
|
|
1253
|
-
threshold: this.threshold,
|
|
1254
|
-
screenshots: []
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// Generate signature for this screenshot
|
|
1259
|
-
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1260
|
-
|
|
1261
|
-
// Add screenshot to baseline metadata
|
|
1262
|
-
const screenshotEntry = {
|
|
1263
|
-
name,
|
|
1264
|
-
properties: properties || {},
|
|
1265
|
-
path: baselineImagePath,
|
|
1266
|
-
signature: signature
|
|
1267
|
-
};
|
|
1268
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
1269
|
-
if (existingIndex >= 0) {
|
|
1270
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
1271
|
-
} else {
|
|
1272
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// Save updated metadata
|
|
1276
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
1277
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
1278
|
-
const result = {
|
|
1279
|
-
id: generateComparisonId(signature),
|
|
1280
|
-
name,
|
|
1281
|
-
status: 'new',
|
|
1282
|
-
baseline: baselineImagePath,
|
|
1283
|
-
current: currentImagePath,
|
|
1284
|
-
diff: null,
|
|
1285
|
-
properties,
|
|
1286
|
-
signature
|
|
1287
|
-
};
|
|
1288
|
-
this.comparisons.push(result);
|
|
1289
|
-
output.info(`✅ Baseline created for ${name}`);
|
|
1290
|
-
return result;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* Update a single baseline with current screenshot
|
|
1295
|
-
* @private
|
|
1296
|
-
*/
|
|
1297
|
-
updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
1298
|
-
output.info(`🐻 Setting baseline for ${name}`);
|
|
1299
|
-
|
|
1300
|
-
// Copy current screenshot to baseline directory
|
|
1301
|
-
writeFileSync(baselineImagePath, imageBuffer);
|
|
1302
|
-
|
|
1303
|
-
// Update or create baseline metadata
|
|
1304
|
-
if (!this.baselineData) {
|
|
1305
|
-
this.baselineData = {
|
|
1306
|
-
buildId: 'local-baseline',
|
|
1307
|
-
buildName: 'Local TDD Baseline',
|
|
1308
|
-
environment: 'test',
|
|
1309
|
-
branch: 'local',
|
|
1310
|
-
threshold: this.threshold,
|
|
1311
|
-
screenshots: []
|
|
1312
|
-
};
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
// Generate signature for this screenshot
|
|
1316
|
-
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1317
|
-
|
|
1318
|
-
// Add screenshot to baseline metadata
|
|
1319
|
-
const screenshotEntry = {
|
|
1320
|
-
name,
|
|
1321
|
-
properties: properties || {},
|
|
1322
|
-
path: baselineImagePath,
|
|
1323
|
-
signature: signature
|
|
1324
|
-
};
|
|
1325
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
1326
|
-
if (existingIndex >= 0) {
|
|
1327
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
1328
|
-
} else {
|
|
1329
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
// Save updated metadata
|
|
1333
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
1334
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
1335
|
-
const result = {
|
|
1336
|
-
id: generateComparisonId(signature),
|
|
1337
|
-
name,
|
|
1338
|
-
status: 'baseline-updated',
|
|
1339
|
-
baseline: baselineImagePath,
|
|
1340
|
-
current: currentImagePath,
|
|
1341
|
-
diff: null,
|
|
1342
|
-
properties,
|
|
1343
|
-
signature
|
|
1344
|
-
};
|
|
1345
|
-
this.comparisons.push(result);
|
|
1346
|
-
output.info(`🐻 Baseline set for ${name}`);
|
|
1347
|
-
return result;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* Accept a current screenshot as the new baseline
|
|
1352
|
-
* @param {string|Object} idOrComparison - Comparison ID or comparison object
|
|
1353
|
-
* @returns {Object} Result object
|
|
1354
|
-
*/
|
|
1355
|
-
async acceptBaseline(idOrComparison) {
|
|
1356
|
-
let comparison;
|
|
1357
|
-
|
|
1358
|
-
// Support both ID lookup and direct comparison object
|
|
1359
|
-
if (typeof idOrComparison === 'string') {
|
|
1360
|
-
// Find the comparison by ID in memory
|
|
1361
|
-
comparison = this.comparisons.find(c => c.id === idOrComparison);
|
|
1362
|
-
if (!comparison) {
|
|
1363
|
-
throw new Error(`No comparison found with ID: ${idOrComparison}`);
|
|
1364
|
-
}
|
|
1365
|
-
} else {
|
|
1366
|
-
// Use the provided comparison object directly
|
|
1367
|
-
comparison = idOrComparison;
|
|
1368
|
-
}
|
|
1369
|
-
const sanitizedName = comparison.name;
|
|
1370
|
-
const properties = comparison.properties || {};
|
|
1371
|
-
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
1372
|
-
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
1373
|
-
|
|
1374
|
-
// Find the current screenshot file
|
|
1375
|
-
const currentImagePath = safePath(this.currentPath, filename);
|
|
1376
|
-
if (!existsSync(currentImagePath)) {
|
|
1377
|
-
output.error(`Current screenshot not found at: ${currentImagePath}`);
|
|
1378
|
-
throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
// Read the current image
|
|
1382
|
-
const imageBuffer = readFileSync(currentImagePath);
|
|
1383
|
-
|
|
1384
|
-
// Create baseline directory if it doesn't exist
|
|
1385
|
-
if (!existsSync(this.baselinePath)) {
|
|
1386
|
-
mkdirSync(this.baselinePath, {
|
|
1387
|
-
recursive: true
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
// Update the baseline
|
|
1392
|
-
const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
1393
|
-
|
|
1394
|
-
// Write the baseline image directly
|
|
1395
|
-
writeFileSync(baselineImagePath, imageBuffer);
|
|
1396
|
-
|
|
1397
|
-
// Verify the write
|
|
1398
|
-
if (!existsSync(baselineImagePath)) {
|
|
1399
|
-
output.error(`Baseline file does not exist after write!`);
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// Update baseline metadata
|
|
1403
|
-
if (!this.baselineData) {
|
|
1404
|
-
this.baselineData = {
|
|
1405
|
-
buildId: 'local-baseline',
|
|
1406
|
-
buildName: 'Local TDD Baseline',
|
|
1407
|
-
environment: 'test',
|
|
1408
|
-
branch: 'local',
|
|
1409
|
-
threshold: this.threshold,
|
|
1410
|
-
screenshots: []
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
// Add or update screenshot in baseline metadata
|
|
1415
|
-
const screenshotEntry = {
|
|
1416
|
-
name: sanitizedName,
|
|
1417
|
-
properties: properties,
|
|
1418
|
-
path: baselineImagePath,
|
|
1419
|
-
signature: signature
|
|
1420
|
-
};
|
|
1421
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
|
|
1422
|
-
if (existingIndex >= 0) {
|
|
1423
|
-
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
1424
|
-
} else {
|
|
1425
|
-
this.baselineData.screenshots.push(screenshotEntry);
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// Save updated metadata
|
|
1429
|
-
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
1430
|
-
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
1431
|
-
return {
|
|
1432
|
-
name: sanitizedName,
|
|
1433
|
-
status: 'accepted',
|
|
1434
|
-
message: 'Screenshot accepted as new baseline'
|
|
1435
|
-
};
|
|
1436
|
-
}
|
|
1437
|
-
}
|