@vizzly-testing/cli 0.19.2 → 0.20.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.js +134 -0
- package/dist/api/core.js +341 -0
- package/dist/api/endpoints.js +314 -0
- package/dist/api/index.js +19 -0
- package/dist/auth/client.js +91 -0
- package/dist/auth/core.js +176 -0
- package/dist/auth/index.js +30 -0
- package/dist/auth/operations.js +148 -0
- package/dist/cli.js +1 -1
- package/dist/client/index.js +0 -1
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/finalize.js +41 -15
- package/dist/commands/login.js +7 -6
- package/dist/commands/logout.js +4 -4
- package/dist/commands/project.js +5 -4
- package/dist/commands/run.js +158 -90
- package/dist/commands/status.js +22 -18
- package/dist/commands/tdd.js +105 -78
- package/dist/commands/upload.js +61 -26
- package/dist/commands/whoami.js +4 -4
- package/dist/config/core.js +438 -0
- package/dist/config/index.js +13 -0
- package/dist/config/operations.js +327 -0
- package/dist/index.js +1 -1
- package/dist/project/core.js +295 -0
- package/dist/project/index.js +13 -0
- package/dist/project/operations.js +393 -0
- package/dist/report-generator/core.js +315 -0
- package/dist/report-generator/index.js +8 -0
- package/dist/report-generator/operations.js +196 -0
- package/dist/reporter/reporter-bundle.iife.js +16 -16
- package/dist/screenshot-server/core.js +157 -0
- package/dist/screenshot-server/index.js +11 -0
- package/dist/screenshot-server/operations.js +183 -0
- package/dist/sdk/index.js +3 -2
- package/dist/server/handlers/api-handler.js +14 -5
- package/dist/server/handlers/tdd-handler.js +80 -48
- package/dist/server-manager/core.js +183 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +208 -0
- package/dist/services/build-manager.js +2 -69
- package/dist/services/index.js +21 -48
- package/dist/services/screenshot-server.js +40 -74
- package/dist/services/server-manager.js +45 -80
- package/dist/services/static-report-generator.js +21 -163
- package/dist/services/test-runner.js +90 -249
- package/dist/services/uploader.js +56 -358
- package/dist/tdd/core/hotspot-coverage.js +112 -0
- package/dist/tdd/core/signature.js +101 -0
- package/dist/tdd/index.js +19 -0
- package/dist/tdd/metadata/baseline-metadata.js +103 -0
- package/dist/tdd/metadata/hotspot-metadata.js +93 -0
- package/dist/tdd/services/baseline-downloader.js +151 -0
- package/dist/tdd/services/baseline-manager.js +166 -0
- package/dist/tdd/services/comparison-service.js +230 -0
- package/dist/tdd/services/hotspot-service.js +71 -0
- package/dist/tdd/services/result-service.js +123 -0
- package/dist/tdd/tdd-service.js +1081 -0
- package/dist/test-runner/core.js +255 -0
- package/dist/test-runner/index.js +13 -0
- package/dist/test-runner/operations.js +483 -0
- package/dist/types/client.d.ts +4 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/config-schema.js +8 -3
- package/package.json +7 -12
- package/dist/services/api-service.js +0 -412
- package/dist/services/auth-service.js +0 -226
- package/dist/services/config-service.js +0 -369
- package/dist/services/html-report-generator.js +0 -455
- package/dist/services/project-service.js +0 -326
- package/dist/services/report-generator/report.css +0 -411
- package/dist/services/report-generator/viewer.js +0 -102
- package/dist/services/tdd-service.js +0 -1429
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD Service - Local Visual Testing
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates visual testing by composing the extracted modules.
|
|
5
|
+
* This is a thin orchestration layer - most logic lives in the modules.
|
|
6
|
+
*
|
|
7
|
+
* CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
|
|
8
|
+
* See src/tdd/core/signature.js for details.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
|
|
12
|
+
import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
|
|
13
|
+
import { NetworkError } from '../errors/vizzly-error.js';
|
|
14
|
+
import { StaticReportGenerator as DefaultStaticReportGenerator } from '../services/static-report-generator.js';
|
|
15
|
+
import { colors as defaultColors } from '../utils/colors.js';
|
|
16
|
+
import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
|
|
17
|
+
import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
|
|
18
|
+
import * as defaultOutput from '../utils/output.js';
|
|
19
|
+
import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validatePathSecurity as defaultValidatePathSecurity, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../utils/security.js';
|
|
20
|
+
import { calculateHotspotCoverage as defaultCalculateHotspotCoverage } from './core/hotspot-coverage.js';
|
|
21
|
+
// Import from extracted modules
|
|
22
|
+
import { generateBaselineFilename as defaultGenerateBaselineFilename, generateComparisonId as defaultGenerateComparisonId, generateScreenshotSignature as defaultGenerateScreenshotSignature } from './core/signature.js';
|
|
23
|
+
import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
|
|
24
|
+
import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
|
|
25
|
+
import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
|
|
26
|
+
import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
|
|
27
|
+
import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a new TDD service instance
|
|
31
|
+
* @param {Object} config - Configuration object
|
|
32
|
+
* @param {Object} options - Options
|
|
33
|
+
* @param {string} options.workingDir - Working directory
|
|
34
|
+
* @param {boolean} options.setBaseline - Whether to set baselines
|
|
35
|
+
* @param {Object} options.authService - Authentication service
|
|
36
|
+
* @param {Object} deps - Injectable dependencies for testing
|
|
37
|
+
*/
|
|
38
|
+
export function createTDDService(config, options = {}, deps = {}) {
|
|
39
|
+
return new TddService(config, options.workingDir, options.setBaseline, options.authService, deps);
|
|
40
|
+
}
|
|
41
|
+
export class TddService {
|
|
42
|
+
constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null, deps = {}) {
|
|
43
|
+
// Grouped dependencies with defaults
|
|
44
|
+
let {
|
|
45
|
+
// Core utilities
|
|
46
|
+
output = defaultOutput,
|
|
47
|
+
colors = defaultColors,
|
|
48
|
+
validatePathSecurity = defaultValidatePathSecurity,
|
|
49
|
+
initializeDirectories = defaultInitializeDirectories,
|
|
50
|
+
// File system operations
|
|
51
|
+
fs = {},
|
|
52
|
+
// API operations
|
|
53
|
+
api = {},
|
|
54
|
+
// Baseline metadata operations
|
|
55
|
+
metadata = {},
|
|
56
|
+
// Baseline file management
|
|
57
|
+
baseline = {},
|
|
58
|
+
// Screenshot comparison
|
|
59
|
+
comparison = {},
|
|
60
|
+
// Signature generation and security
|
|
61
|
+
signature = {},
|
|
62
|
+
// Result building
|
|
63
|
+
results = {},
|
|
64
|
+
// Other
|
|
65
|
+
calculateHotspotCoverage = defaultCalculateHotspotCoverage,
|
|
66
|
+
StaticReportGenerator = DefaultStaticReportGenerator
|
|
67
|
+
} = deps;
|
|
68
|
+
|
|
69
|
+
// Merge grouped deps with defaults
|
|
70
|
+
let fsOps = {
|
|
71
|
+
existsSync: defaultExistsSync,
|
|
72
|
+
mkdirSync: defaultMkdirSync,
|
|
73
|
+
readFileSync: defaultReadFileSync,
|
|
74
|
+
writeFileSync: defaultWriteFileSync,
|
|
75
|
+
...fs
|
|
76
|
+
};
|
|
77
|
+
let apiOps = {
|
|
78
|
+
createApiClient: defaultCreateApiClient,
|
|
79
|
+
getTddBaselines: defaultGetTddBaselines,
|
|
80
|
+
getBuilds: defaultGetBuilds,
|
|
81
|
+
getComparison: defaultGetComparison,
|
|
82
|
+
getBatchHotspots: defaultGetBatchHotspots,
|
|
83
|
+
fetchWithTimeout: defaultFetchWithTimeout,
|
|
84
|
+
getDefaultBranch: defaultGetDefaultBranch,
|
|
85
|
+
...api
|
|
86
|
+
};
|
|
87
|
+
let metadataOps = {
|
|
88
|
+
loadBaselineMetadata: defaultLoadBaselineMetadata,
|
|
89
|
+
saveBaselineMetadata: defaultSaveBaselineMetadata,
|
|
90
|
+
createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
|
|
91
|
+
upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
|
|
92
|
+
loadHotspotMetadata: defaultLoadHotspotMetadata,
|
|
93
|
+
saveHotspotMetadata: defaultSaveHotspotMetadata,
|
|
94
|
+
...metadata
|
|
95
|
+
};
|
|
96
|
+
let baselineOps = {
|
|
97
|
+
baselineExists: defaultBaselineExists,
|
|
98
|
+
clearBaselineData: defaultClearBaselineData,
|
|
99
|
+
getBaselinePath: defaultGetBaselinePath,
|
|
100
|
+
getCurrentPath: defaultGetCurrentPath,
|
|
101
|
+
getDiffPath: defaultGetDiffPath,
|
|
102
|
+
saveBaseline: defaultSaveBaseline,
|
|
103
|
+
saveCurrent: defaultSaveCurrent,
|
|
104
|
+
...baseline
|
|
105
|
+
};
|
|
106
|
+
let comparisonOps = {
|
|
107
|
+
compareImages: defaultCompareImages,
|
|
108
|
+
buildPassedComparison: defaultBuildPassedComparison,
|
|
109
|
+
buildNewComparison: defaultBuildNewComparison,
|
|
110
|
+
buildFailedComparison: defaultBuildFailedComparison,
|
|
111
|
+
buildErrorComparison: defaultBuildErrorComparison,
|
|
112
|
+
isDimensionMismatchError: defaultIsDimensionMismatchError,
|
|
113
|
+
...comparison
|
|
114
|
+
};
|
|
115
|
+
let signatureOps = {
|
|
116
|
+
generateScreenshotSignature: defaultGenerateScreenshotSignature,
|
|
117
|
+
generateBaselineFilename: defaultGenerateBaselineFilename,
|
|
118
|
+
generateComparisonId: defaultGenerateComparisonId,
|
|
119
|
+
sanitizeScreenshotName: defaultSanitizeScreenshotName,
|
|
120
|
+
validateScreenshotProperties: defaultValidateScreenshotProperties,
|
|
121
|
+
safePath: defaultSafePath,
|
|
122
|
+
...signature
|
|
123
|
+
};
|
|
124
|
+
let resultsOps = {
|
|
125
|
+
buildResults: defaultBuildResults,
|
|
126
|
+
getFailedComparisons: defaultGetFailedComparisons,
|
|
127
|
+
getNewComparisons: defaultGetNewComparisons,
|
|
128
|
+
...results
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Store flattened dependencies for use in methods
|
|
132
|
+
this._deps = {
|
|
133
|
+
output,
|
|
134
|
+
colors,
|
|
135
|
+
validatePathSecurity,
|
|
136
|
+
initializeDirectories,
|
|
137
|
+
calculateHotspotCoverage,
|
|
138
|
+
StaticReportGenerator,
|
|
139
|
+
...fsOps,
|
|
140
|
+
...apiOps,
|
|
141
|
+
...metadataOps,
|
|
142
|
+
...baselineOps,
|
|
143
|
+
...comparisonOps,
|
|
144
|
+
...signatureOps,
|
|
145
|
+
...resultsOps
|
|
146
|
+
};
|
|
147
|
+
this.config = config;
|
|
148
|
+
this.setBaseline = setBaseline;
|
|
149
|
+
this.authService = authService;
|
|
150
|
+
this.client = apiOps.createApiClient({
|
|
151
|
+
baseUrl: config.apiUrl,
|
|
152
|
+
token: config.apiKey,
|
|
153
|
+
command: 'tdd',
|
|
154
|
+
allowNoToken: true
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Validate and secure the working directory
|
|
158
|
+
try {
|
|
159
|
+
this.workingDir = validatePathSecurity(workingDir, workingDir);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
output.error(`Invalid working directory: ${error.message}`);
|
|
162
|
+
throw new Error(`Working directory validation failed: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Initialize directories using extracted module
|
|
166
|
+
let paths = initializeDirectories(this.workingDir);
|
|
167
|
+
this.baselinePath = paths.baselinePath;
|
|
168
|
+
this.currentPath = paths.currentPath;
|
|
169
|
+
this.diffPath = paths.diffPath;
|
|
170
|
+
|
|
171
|
+
// State
|
|
172
|
+
this.baselineData = null;
|
|
173
|
+
this.comparisons = [];
|
|
174
|
+
this.threshold = config.comparison?.threshold || 2.0;
|
|
175
|
+
this.minClusterSize = config.comparison?.minClusterSize ?? 2;
|
|
176
|
+
this.signatureProperties = config.signatureProperties ?? [];
|
|
177
|
+
|
|
178
|
+
// Hotspot data (loaded lazily from disk or downloaded from cloud)
|
|
179
|
+
this.hotspotData = null;
|
|
180
|
+
if (this.setBaseline) {
|
|
181
|
+
output.info('🐻 Baseline update mode - will overwrite existing baselines with new ones');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Download baselines from cloud
|
|
187
|
+
*/
|
|
188
|
+
async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
|
|
189
|
+
// If no branch specified, detect default branch
|
|
190
|
+
if (!branch) {
|
|
191
|
+
branch = await getDefaultBranch();
|
|
192
|
+
if (!branch) {
|
|
193
|
+
branch = 'main';
|
|
194
|
+
output.warn(`⚠️ Could not detect default branch, using 'main' as fallback`);
|
|
195
|
+
} else {
|
|
196
|
+
output.debug('tdd', `detected default branch: ${branch}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
let baselineBuild;
|
|
201
|
+
if (buildId) {
|
|
202
|
+
let apiResponse = await getTddBaselines(this.client, buildId);
|
|
203
|
+
if (!apiResponse) {
|
|
204
|
+
throw new Error(`Build ${buildId} not found or API returned null`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Clear local state before downloading
|
|
208
|
+
output.info('Clearing local state before downloading baselines...');
|
|
209
|
+
clearBaselineData({
|
|
210
|
+
baselinePath: this.baselinePath,
|
|
211
|
+
currentPath: this.currentPath,
|
|
212
|
+
diffPath: this.diffPath
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Extract signature properties
|
|
216
|
+
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
217
|
+
this.signatureProperties = apiResponse.signatureProperties;
|
|
218
|
+
if (this.signatureProperties.length > 0) {
|
|
219
|
+
output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
baselineBuild = apiResponse.build;
|
|
223
|
+
if (baselineBuild.status === 'failed') {
|
|
224
|
+
output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
|
|
225
|
+
return await this.handleLocalBaselines();
|
|
226
|
+
} else if (baselineBuild.status !== 'completed') {
|
|
227
|
+
output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
|
|
228
|
+
}
|
|
229
|
+
baselineBuild.screenshots = apiResponse.screenshots;
|
|
230
|
+
} else if (comparisonId) {
|
|
231
|
+
// Handle specific comparison download
|
|
232
|
+
output.info(`Using comparison: ${comparisonId}`);
|
|
233
|
+
let comparison = await getComparison(this.client, comparisonId);
|
|
234
|
+
if (!comparison.baseline_screenshot) {
|
|
235
|
+
throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot.`);
|
|
236
|
+
}
|
|
237
|
+
let baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
|
|
238
|
+
if (!baselineUrl) {
|
|
239
|
+
throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
|
|
240
|
+
}
|
|
241
|
+
let screenshotProperties = {};
|
|
242
|
+
if (comparison.current_viewport_width || comparison.current_browser) {
|
|
243
|
+
if (comparison.current_viewport_width) {
|
|
244
|
+
screenshotProperties.viewport = {
|
|
245
|
+
width: comparison.current_viewport_width,
|
|
246
|
+
height: comparison.current_viewport_height
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (comparison.current_browser) {
|
|
250
|
+
screenshotProperties.browser = comparison.current_browser;
|
|
251
|
+
}
|
|
252
|
+
} else if (comparison.baseline_viewport_width || comparison.baseline_browser) {
|
|
253
|
+
if (comparison.baseline_viewport_width) {
|
|
254
|
+
screenshotProperties.viewport = {
|
|
255
|
+
width: comparison.baseline_viewport_width,
|
|
256
|
+
height: comparison.baseline_viewport_height
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (comparison.baseline_browser) {
|
|
260
|
+
screenshotProperties.browser = comparison.baseline_browser;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let screenshotName = comparison.baseline_name || comparison.current_name;
|
|
264
|
+
let signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
|
|
265
|
+
let filename = generateBaselineFilename(screenshotName, signature);
|
|
266
|
+
baselineBuild = {
|
|
267
|
+
id: comparison.baseline_screenshot.build_id || 'comparison-baseline',
|
|
268
|
+
name: `Comparison ${comparisonId.substring(0, 8)}`,
|
|
269
|
+
screenshots: [{
|
|
270
|
+
id: comparison.baseline_screenshot.id,
|
|
271
|
+
name: screenshotName,
|
|
272
|
+
original_url: baselineUrl,
|
|
273
|
+
metadata: screenshotProperties,
|
|
274
|
+
properties: screenshotProperties,
|
|
275
|
+
filename: filename
|
|
276
|
+
}]
|
|
277
|
+
};
|
|
278
|
+
} else {
|
|
279
|
+
// Get latest passed build
|
|
280
|
+
let builds = await getBuilds(this.client, {
|
|
281
|
+
environment,
|
|
282
|
+
branch,
|
|
283
|
+
status: 'passed',
|
|
284
|
+
limit: 1
|
|
285
|
+
});
|
|
286
|
+
if (!builds.data || builds.data.length === 0) {
|
|
287
|
+
output.warn(`⚠️ No baseline builds found for ${environment}/${branch}`);
|
|
288
|
+
output.info('💡 Run a build in normal mode first to create baselines');
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
let apiResponse = await getTddBaselines(this.client, builds.data[0].id);
|
|
292
|
+
if (!apiResponse) {
|
|
293
|
+
throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
|
|
294
|
+
}
|
|
295
|
+
if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
|
|
296
|
+
this.signatureProperties = apiResponse.signatureProperties;
|
|
297
|
+
if (this.signatureProperties.length > 0) {
|
|
298
|
+
output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
baselineBuild = apiResponse.build;
|
|
302
|
+
baselineBuild.screenshots = apiResponse.screenshots;
|
|
303
|
+
}
|
|
304
|
+
let buildDetails = baselineBuild;
|
|
305
|
+
if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
|
|
306
|
+
output.warn('⚠️ No screenshots found in baseline build');
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
output.info(`Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
|
|
310
|
+
output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
|
|
311
|
+
|
|
312
|
+
// Check existing baseline metadata for SHA comparison
|
|
313
|
+
let existingBaseline = await this.loadBaseline();
|
|
314
|
+
let existingShaMap = new Map();
|
|
315
|
+
if (existingBaseline) {
|
|
316
|
+
existingBaseline.screenshots.forEach(s => {
|
|
317
|
+
if (s.sha256 && s.filename) {
|
|
318
|
+
existingShaMap.set(s.filename, s.sha256);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Download screenshots
|
|
324
|
+
let downloadedCount = 0;
|
|
325
|
+
let skippedCount = 0;
|
|
326
|
+
let errorCount = 0;
|
|
327
|
+
let batchSize = 5;
|
|
328
|
+
let screenshotsToProcess = [];
|
|
329
|
+
for (let screenshot of buildDetails.screenshots) {
|
|
330
|
+
let sanitizedName;
|
|
331
|
+
try {
|
|
332
|
+
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
output.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
|
|
335
|
+
errorCount++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
let filename = screenshot.filename;
|
|
339
|
+
if (!filename) {
|
|
340
|
+
output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
|
|
341
|
+
errorCount++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
let imagePath = safePath(this.baselinePath, filename);
|
|
345
|
+
|
|
346
|
+
// Check SHA
|
|
347
|
+
if (existsSync(imagePath) && screenshot.sha256) {
|
|
348
|
+
let storedSha = existingShaMap.get(filename);
|
|
349
|
+
if (storedSha === screenshot.sha256) {
|
|
350
|
+
downloadedCount++;
|
|
351
|
+
skippedCount++;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
let downloadUrl = screenshot.original_url || screenshot.url;
|
|
356
|
+
if (!downloadUrl) {
|
|
357
|
+
output.warn(`⚠️ Screenshot ${sanitizedName} has no download URL - skipping`);
|
|
358
|
+
errorCount++;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
screenshotsToProcess.push({
|
|
362
|
+
screenshot,
|
|
363
|
+
sanitizedName,
|
|
364
|
+
imagePath,
|
|
365
|
+
downloadUrl,
|
|
366
|
+
filename
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Process downloads in batches
|
|
371
|
+
if (screenshotsToProcess.length > 0) {
|
|
372
|
+
output.info(`📥 Downloading ${screenshotsToProcess.length} new/updated screenshots...`);
|
|
373
|
+
for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
|
|
374
|
+
let batch = screenshotsToProcess.slice(i, i + batchSize);
|
|
375
|
+
let batchNum = Math.floor(i / batchSize) + 1;
|
|
376
|
+
let totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
|
|
377
|
+
output.info(`📦 Processing batch ${batchNum}/${totalBatches}`);
|
|
378
|
+
let downloadPromises = batch.map(async ({
|
|
379
|
+
sanitizedName,
|
|
380
|
+
imagePath,
|
|
381
|
+
downloadUrl
|
|
382
|
+
}) => {
|
|
383
|
+
try {
|
|
384
|
+
let response = await fetchWithTimeout(downloadUrl);
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
|
|
387
|
+
}
|
|
388
|
+
let arrayBuffer = await response.arrayBuffer();
|
|
389
|
+
let imageBuffer = Buffer.from(arrayBuffer);
|
|
390
|
+
writeFileSync(imagePath, imageBuffer);
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
name: sanitizedName
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
name: sanitizedName,
|
|
400
|
+
error: error.message
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
let batchResults = await Promise.all(downloadPromises);
|
|
405
|
+
let batchSuccesses = batchResults.filter(r => r.success).length;
|
|
406
|
+
let batchFailures = batchResults.filter(r => !r.success).length;
|
|
407
|
+
downloadedCount += batchSuccesses;
|
|
408
|
+
errorCount += batchFailures;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (downloadedCount === 0 && skippedCount === 0) {
|
|
412
|
+
output.error('❌ No screenshots were successfully downloaded');
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Store baseline metadata
|
|
417
|
+
this.baselineData = {
|
|
418
|
+
buildId: baselineBuild.id,
|
|
419
|
+
buildName: baselineBuild.name,
|
|
420
|
+
environment,
|
|
421
|
+
branch,
|
|
422
|
+
threshold: this.threshold,
|
|
423
|
+
signatureProperties: this.signatureProperties,
|
|
424
|
+
createdAt: new Date().toISOString(),
|
|
425
|
+
buildInfo: {
|
|
426
|
+
commitSha: baselineBuild.commit_sha,
|
|
427
|
+
commitMessage: baselineBuild.commit_message,
|
|
428
|
+
approvalStatus: baselineBuild.approval_status,
|
|
429
|
+
completedAt: baselineBuild.completed_at
|
|
430
|
+
},
|
|
431
|
+
screenshots: buildDetails.screenshots.filter(s => s.filename).map(s => ({
|
|
432
|
+
name: sanitizeScreenshotName(s.name),
|
|
433
|
+
originalName: s.name,
|
|
434
|
+
sha256: s.sha256,
|
|
435
|
+
id: s.id,
|
|
436
|
+
filename: s.filename,
|
|
437
|
+
path: safePath(this.baselinePath, s.filename),
|
|
438
|
+
browser: s.browser,
|
|
439
|
+
viewport_width: s.viewport_width,
|
|
440
|
+
originalUrl: s.original_url,
|
|
441
|
+
fileSize: s.file_size_bytes,
|
|
442
|
+
dimensions: {
|
|
443
|
+
width: s.width,
|
|
444
|
+
height: s.height
|
|
445
|
+
}
|
|
446
|
+
}))
|
|
447
|
+
};
|
|
448
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
449
|
+
|
|
450
|
+
// Download hotspots
|
|
451
|
+
await this.downloadHotspots(buildDetails.screenshots);
|
|
452
|
+
|
|
453
|
+
// Save baseline build metadata for MCP plugin
|
|
454
|
+
let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
455
|
+
writeFileSync(baselineMetadataPath, JSON.stringify({
|
|
456
|
+
buildId: baselineBuild.id,
|
|
457
|
+
buildName: baselineBuild.name,
|
|
458
|
+
branch,
|
|
459
|
+
environment,
|
|
460
|
+
commitSha: baselineBuild.commit_sha,
|
|
461
|
+
commitMessage: baselineBuild.commit_message,
|
|
462
|
+
approvalStatus: baselineBuild.approval_status,
|
|
463
|
+
completedAt: baselineBuild.completed_at,
|
|
464
|
+
downloadedAt: new Date().toISOString()
|
|
465
|
+
}, null, 2));
|
|
466
|
+
|
|
467
|
+
// Summary
|
|
468
|
+
let actualDownloads = downloadedCount - skippedCount;
|
|
469
|
+
if (skippedCount > 0) {
|
|
470
|
+
if (actualDownloads === 0) {
|
|
471
|
+
output.info(`✅ All ${skippedCount} baselines up-to-date`);
|
|
472
|
+
} else {
|
|
473
|
+
output.info(`✅ Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date`);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
output.info(`✅ Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully`);
|
|
477
|
+
}
|
|
478
|
+
if (errorCount > 0) {
|
|
479
|
+
output.warn(`⚠️ ${errorCount} screenshots failed to download`);
|
|
480
|
+
}
|
|
481
|
+
return this.baselineData;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
output.error(`❌ Failed to download baseline: ${error.message}`);
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Download hotspot data for screenshots
|
|
490
|
+
*/
|
|
491
|
+
async downloadHotspots(screenshots) {
|
|
492
|
+
if (!this.config.apiKey) {
|
|
493
|
+
output.debug('tdd', 'Skipping hotspot download - no API token configured');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
let screenshotNames = [...new Set(screenshots.map(s => s.name))];
|
|
498
|
+
if (screenshotNames.length === 0) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
|
|
502
|
+
let response = await getBatchHotspots(this.client, screenshotNames);
|
|
503
|
+
if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
|
|
504
|
+
output.debug('tdd', 'No hotspot data available from cloud');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Update memory cache
|
|
509
|
+
this.hotspotData = response.hotspots;
|
|
510
|
+
|
|
511
|
+
// Save to disk using extracted module
|
|
512
|
+
saveHotspotMetadata(this.workingDir, response.hotspots, response.summary);
|
|
513
|
+
let hotspotCount = Object.keys(response.hotspots).length;
|
|
514
|
+
let totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
|
|
515
|
+
output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
output.debug('tdd', `Hotspot download failed: ${error.message}`);
|
|
518
|
+
output.warn('⚠️ Could not fetch hotspot data - comparisons will run without noise filtering');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Load hotspot data from disk
|
|
524
|
+
*/
|
|
525
|
+
loadHotspots() {
|
|
526
|
+
let {
|
|
527
|
+
loadHotspotMetadata
|
|
528
|
+
} = this._deps;
|
|
529
|
+
return loadHotspotMetadata(this.workingDir);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Get hotspot for a specific screenshot
|
|
534
|
+
*
|
|
535
|
+
* Note: Once hotspotData is loaded (from disk or cloud), we don't reload.
|
|
536
|
+
* This is intentional - hotspots are downloaded once per session and cached.
|
|
537
|
+
* If a screenshot isn't in the cache, it means no hotspot data exists for it.
|
|
538
|
+
*/
|
|
539
|
+
getHotspotForScreenshot(screenshotName) {
|
|
540
|
+
// Check memory cache first
|
|
541
|
+
if (this.hotspotData?.[screenshotName]) {
|
|
542
|
+
return this.hotspotData[screenshotName];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Try loading from disk (only if we haven't loaded yet)
|
|
546
|
+
if (!this.hotspotData) {
|
|
547
|
+
this.hotspotData = this.loadHotspots();
|
|
548
|
+
}
|
|
549
|
+
return this.hotspotData?.[screenshotName] || null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Calculate hotspot coverage (delegating to pure function)
|
|
554
|
+
*/
|
|
555
|
+
calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
|
|
556
|
+
let {
|
|
557
|
+
calculateHotspotCoverage
|
|
558
|
+
} = this._deps;
|
|
559
|
+
return calculateHotspotCoverage(diffClusters, hotspotAnalysis);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Handle local baselines logic
|
|
564
|
+
*/
|
|
565
|
+
async handleLocalBaselines() {
|
|
566
|
+
let {
|
|
567
|
+
output,
|
|
568
|
+
colors
|
|
569
|
+
} = this._deps;
|
|
570
|
+
if (this.setBaseline) {
|
|
571
|
+
output.info('📁 Ready for new baseline creation');
|
|
572
|
+
this.baselineData = null;
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
let baseline = await this.loadBaseline();
|
|
576
|
+
if (!baseline) {
|
|
577
|
+
if (this.config.apiKey) {
|
|
578
|
+
output.info('📥 No local baseline found, but API key available');
|
|
579
|
+
output.info('🆕 Current run will create new local baselines');
|
|
580
|
+
} else {
|
|
581
|
+
output.info('📝 No local baseline found - all screenshots will be marked as new');
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
} else {
|
|
585
|
+
output.info(`✅ Using existing baseline: ${colors.cyan(baseline.buildName)}`);
|
|
586
|
+
return baseline;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Load baseline metadata
|
|
592
|
+
*/
|
|
593
|
+
async loadBaseline() {
|
|
594
|
+
let {
|
|
595
|
+
output,
|
|
596
|
+
loadBaselineMetadata
|
|
597
|
+
} = this._deps;
|
|
598
|
+
if (this.setBaseline) {
|
|
599
|
+
output.debug('tdd', 'baseline update mode - skipping loading');
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
let metadata = loadBaselineMetadata(this.baselinePath);
|
|
603
|
+
if (!metadata) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
this.baselineData = metadata;
|
|
607
|
+
this.threshold = metadata.threshold || this.threshold;
|
|
608
|
+
this.signatureProperties = metadata.signatureProperties || this.signatureProperties;
|
|
609
|
+
if (this.signatureProperties.length > 0) {
|
|
610
|
+
output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
|
|
611
|
+
}
|
|
612
|
+
return metadata;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Compare a screenshot against baseline
|
|
617
|
+
*/
|
|
618
|
+
async compareScreenshot(name, imageBuffer, properties = {}) {
|
|
619
|
+
// Destructure dependencies
|
|
620
|
+
let {
|
|
621
|
+
output,
|
|
622
|
+
sanitizeScreenshotName,
|
|
623
|
+
validateScreenshotProperties,
|
|
624
|
+
generateScreenshotSignature,
|
|
625
|
+
generateBaselineFilename,
|
|
626
|
+
getCurrentPath,
|
|
627
|
+
getBaselinePath,
|
|
628
|
+
getDiffPath,
|
|
629
|
+
saveCurrent,
|
|
630
|
+
baselineExists,
|
|
631
|
+
saveBaseline,
|
|
632
|
+
createEmptyBaselineMetadata,
|
|
633
|
+
upsertScreenshotInMetadata,
|
|
634
|
+
saveBaselineMetadata,
|
|
635
|
+
buildNewComparison,
|
|
636
|
+
compareImages,
|
|
637
|
+
buildPassedComparison,
|
|
638
|
+
buildFailedComparison,
|
|
639
|
+
buildErrorComparison,
|
|
640
|
+
isDimensionMismatchError,
|
|
641
|
+
colors
|
|
642
|
+
} = this._deps;
|
|
643
|
+
|
|
644
|
+
// Sanitize and validate
|
|
645
|
+
let sanitizedName;
|
|
646
|
+
try {
|
|
647
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
output.error(`Invalid screenshot name '${name}': ${error.message}`);
|
|
650
|
+
throw new Error(`Screenshot name validation failed: ${error.message}`);
|
|
651
|
+
}
|
|
652
|
+
let validatedProperties;
|
|
653
|
+
try {
|
|
654
|
+
validatedProperties = validateScreenshotProperties(properties);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
output.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
|
|
657
|
+
validatedProperties = {};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Preserve metadata
|
|
661
|
+
if (properties.metadata && typeof properties.metadata === 'object') {
|
|
662
|
+
validatedProperties.metadata = properties.metadata;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Normalize viewport_width
|
|
666
|
+
if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
|
|
667
|
+
validatedProperties.viewport_width = validatedProperties.viewport.width;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Generate signature and filename
|
|
671
|
+
let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
672
|
+
let filename = generateBaselineFilename(sanitizedName, signature);
|
|
673
|
+
let currentImagePath = getCurrentPath(this.currentPath, filename);
|
|
674
|
+
let baselineImagePath = getBaselinePath(this.baselinePath, filename);
|
|
675
|
+
let diffImagePath = getDiffPath(this.diffPath, filename);
|
|
676
|
+
|
|
677
|
+
// Save current screenshot
|
|
678
|
+
saveCurrent(this.currentPath, filename, imageBuffer);
|
|
679
|
+
|
|
680
|
+
// Handle baseline update mode
|
|
681
|
+
if (this.setBaseline) {
|
|
682
|
+
return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Check if baseline exists
|
|
686
|
+
if (!baselineExists(this.baselinePath, filename)) {
|
|
687
|
+
// Create new baseline
|
|
688
|
+
saveBaseline(this.baselinePath, filename, imageBuffer);
|
|
689
|
+
|
|
690
|
+
// Update metadata
|
|
691
|
+
if (!this.baselineData) {
|
|
692
|
+
this.baselineData = createEmptyBaselineMetadata({
|
|
693
|
+
threshold: this.threshold,
|
|
694
|
+
signatureProperties: this.signatureProperties
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
let screenshotEntry = {
|
|
698
|
+
name: sanitizedName,
|
|
699
|
+
properties: validatedProperties,
|
|
700
|
+
path: baselineImagePath,
|
|
701
|
+
signature
|
|
702
|
+
};
|
|
703
|
+
upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
|
|
704
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
705
|
+
let result = buildNewComparison({
|
|
706
|
+
name: sanitizedName,
|
|
707
|
+
signature,
|
|
708
|
+
baselinePath: baselineImagePath,
|
|
709
|
+
currentPath: currentImagePath,
|
|
710
|
+
properties: validatedProperties
|
|
711
|
+
});
|
|
712
|
+
this.comparisons.push(result);
|
|
713
|
+
return result;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Baseline exists - compare
|
|
717
|
+
try {
|
|
718
|
+
let effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
|
|
719
|
+
let effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
|
|
720
|
+
let honeydiffResult = await compareImages(baselineImagePath, currentImagePath, diffImagePath, {
|
|
721
|
+
threshold: effectiveThreshold,
|
|
722
|
+
minClusterSize: effectiveMinClusterSize
|
|
723
|
+
});
|
|
724
|
+
if (!honeydiffResult.isDifferent) {
|
|
725
|
+
let result = buildPassedComparison({
|
|
726
|
+
name: sanitizedName,
|
|
727
|
+
signature,
|
|
728
|
+
baselinePath: baselineImagePath,
|
|
729
|
+
currentPath: currentImagePath,
|
|
730
|
+
properties: validatedProperties,
|
|
731
|
+
threshold: effectiveThreshold,
|
|
732
|
+
minClusterSize: effectiveMinClusterSize,
|
|
733
|
+
honeydiffResult
|
|
734
|
+
});
|
|
735
|
+
this.comparisons.push(result);
|
|
736
|
+
return result;
|
|
737
|
+
} else {
|
|
738
|
+
let hotspotAnalysis = this.getHotspotForScreenshot(name);
|
|
739
|
+
let result = buildFailedComparison({
|
|
740
|
+
name: sanitizedName,
|
|
741
|
+
signature,
|
|
742
|
+
baselinePath: baselineImagePath,
|
|
743
|
+
currentPath: currentImagePath,
|
|
744
|
+
diffPath: diffImagePath,
|
|
745
|
+
properties: validatedProperties,
|
|
746
|
+
threshold: effectiveThreshold,
|
|
747
|
+
minClusterSize: effectiveMinClusterSize,
|
|
748
|
+
honeydiffResult,
|
|
749
|
+
hotspotAnalysis
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Log result
|
|
753
|
+
let diffInfo = ` (${honeydiffResult.diffPercentage.toFixed(2)}% different, ${honeydiffResult.diffPixels} pixels)`;
|
|
754
|
+
if (honeydiffResult.diffClusters?.length > 0) {
|
|
755
|
+
diffInfo += `, ${honeydiffResult.diffClusters.length} region${honeydiffResult.diffClusters.length > 1 ? 's' : ''}`;
|
|
756
|
+
}
|
|
757
|
+
if (result.hotspotAnalysis?.coverage > 0) {
|
|
758
|
+
diffInfo += `, ${Math.round(result.hotspotAnalysis.coverage * 100)}% in hotspots`;
|
|
759
|
+
}
|
|
760
|
+
if (result.status === 'passed') {
|
|
761
|
+
output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
|
|
762
|
+
} else {
|
|
763
|
+
output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
|
|
764
|
+
output.info(` Diff saved to: ${diffImagePath}`);
|
|
765
|
+
}
|
|
766
|
+
this.comparisons.push(result);
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (isDimensionMismatchError(error)) {
|
|
771
|
+
output.warn(`⚠️ Dimension mismatch for ${sanitizedName} - creating new baseline`);
|
|
772
|
+
saveBaseline(this.baselinePath, filename, imageBuffer);
|
|
773
|
+
if (!this.baselineData) {
|
|
774
|
+
this.baselineData = createEmptyBaselineMetadata({
|
|
775
|
+
threshold: this.threshold,
|
|
776
|
+
signatureProperties: this.signatureProperties
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
let screenshotEntry = {
|
|
780
|
+
name: sanitizedName,
|
|
781
|
+
properties: validatedProperties,
|
|
782
|
+
path: baselineImagePath,
|
|
783
|
+
signature
|
|
784
|
+
};
|
|
785
|
+
upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
|
|
786
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
787
|
+
output.info(`✅ Created new baseline for ${sanitizedName} (different dimensions)`);
|
|
788
|
+
let result = buildNewComparison({
|
|
789
|
+
name: sanitizedName,
|
|
790
|
+
signature,
|
|
791
|
+
baselinePath: baselineImagePath,
|
|
792
|
+
currentPath: currentImagePath,
|
|
793
|
+
properties: validatedProperties
|
|
794
|
+
});
|
|
795
|
+
this.comparisons.push(result);
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
output.error(`❌ Error comparing ${sanitizedName}: ${error.message}`);
|
|
799
|
+
let result = buildErrorComparison({
|
|
800
|
+
name: sanitizedName,
|
|
801
|
+
signature,
|
|
802
|
+
baselinePath: baselineImagePath,
|
|
803
|
+
currentPath: currentImagePath,
|
|
804
|
+
properties: validatedProperties,
|
|
805
|
+
errorMessage: error.message
|
|
806
|
+
});
|
|
807
|
+
this.comparisons.push(result);
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Get results summary
|
|
814
|
+
*/
|
|
815
|
+
getResults() {
|
|
816
|
+
let {
|
|
817
|
+
buildResults
|
|
818
|
+
} = this._deps;
|
|
819
|
+
return buildResults(this.comparisons, this.baselineData);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Print results to console
|
|
824
|
+
*/
|
|
825
|
+
async printResults() {
|
|
826
|
+
let results = this.getResults();
|
|
827
|
+
output.info('\n📊 TDD Results:');
|
|
828
|
+
output.info(`Total: ${colors.cyan(results.total)}`);
|
|
829
|
+
output.info(`Passed: ${colors.green(results.passed)}`);
|
|
830
|
+
if (results.failed > 0) {
|
|
831
|
+
output.info(`Failed: ${colors.red(results.failed)}`);
|
|
832
|
+
}
|
|
833
|
+
if (results.new > 0) {
|
|
834
|
+
output.info(`New: ${colors.yellow(results.new)}`);
|
|
835
|
+
}
|
|
836
|
+
if (results.errors > 0) {
|
|
837
|
+
output.info(`Errors: ${colors.red(results.errors)}`);
|
|
838
|
+
}
|
|
839
|
+
let failedComparisons = getFailedComparisons(this.comparisons);
|
|
840
|
+
if (failedComparisons.length > 0) {
|
|
841
|
+
output.info('\n❌ Failed comparisons:');
|
|
842
|
+
for (let comp of failedComparisons) {
|
|
843
|
+
output.info(` • ${comp.name}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
let newComparisons = getNewComparisons(this.comparisons);
|
|
847
|
+
if (newComparisons.length > 0) {
|
|
848
|
+
output.info('\n📸 New screenshots:');
|
|
849
|
+
for (let comp of newComparisons) {
|
|
850
|
+
output.info(` • ${comp.name}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
await this.generateHtmlReport(results);
|
|
854
|
+
return results;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Generate HTML report using React reporter
|
|
859
|
+
*/
|
|
860
|
+
async generateHtmlReport(results) {
|
|
861
|
+
try {
|
|
862
|
+
let reportGenerator = new StaticReportGenerator(this.workingDir, this.config);
|
|
863
|
+
|
|
864
|
+
// Transform results to React reporter format
|
|
865
|
+
let reportData = {
|
|
866
|
+
buildId: this.baselineData?.buildId || 'local-tdd',
|
|
867
|
+
summary: {
|
|
868
|
+
passed: results.passed,
|
|
869
|
+
failed: results.failed,
|
|
870
|
+
total: results.total,
|
|
871
|
+
new: results.new,
|
|
872
|
+
errors: results.errors
|
|
873
|
+
},
|
|
874
|
+
comparisons: results.comparisons,
|
|
875
|
+
baseline: this.baselineData,
|
|
876
|
+
threshold: this.threshold
|
|
877
|
+
};
|
|
878
|
+
let reportPath = await reportGenerator.generateReport(reportData);
|
|
879
|
+
output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
|
|
880
|
+
if (this.config.tdd?.openReport) {
|
|
881
|
+
await this.openReport(reportPath);
|
|
882
|
+
}
|
|
883
|
+
return reportPath;
|
|
884
|
+
} catch (error) {
|
|
885
|
+
output.warn(`Failed to generate HTML report: ${error.message}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Open report in browser
|
|
891
|
+
*/
|
|
892
|
+
async openReport(reportPath) {
|
|
893
|
+
try {
|
|
894
|
+
let {
|
|
895
|
+
exec
|
|
896
|
+
} = await import('node:child_process');
|
|
897
|
+
let {
|
|
898
|
+
promisify
|
|
899
|
+
} = await import('node:util');
|
|
900
|
+
let execAsync = promisify(exec);
|
|
901
|
+
let command;
|
|
902
|
+
switch (process.platform) {
|
|
903
|
+
case 'darwin':
|
|
904
|
+
command = `open "${reportPath}"`;
|
|
905
|
+
break;
|
|
906
|
+
case 'win32':
|
|
907
|
+
command = `start "" "${reportPath}"`;
|
|
908
|
+
break;
|
|
909
|
+
default:
|
|
910
|
+
command = `xdg-open "${reportPath}"`;
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
await execAsync(command);
|
|
914
|
+
output.info('📖 Report opened in browser');
|
|
915
|
+
} catch {
|
|
916
|
+
// Browser open may fail silently
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Update all baselines with current screenshots
|
|
922
|
+
*/
|
|
923
|
+
updateBaselines() {
|
|
924
|
+
if (this.comparisons.length === 0) {
|
|
925
|
+
output.warn('No comparisons found - nothing to update');
|
|
926
|
+
return 0;
|
|
927
|
+
}
|
|
928
|
+
let updatedCount = 0;
|
|
929
|
+
if (!this.baselineData) {
|
|
930
|
+
this.baselineData = createEmptyBaselineMetadata({
|
|
931
|
+
threshold: this.threshold,
|
|
932
|
+
signatureProperties: this.signatureProperties
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
for (let comparison of this.comparisons) {
|
|
936
|
+
let {
|
|
937
|
+
name,
|
|
938
|
+
current
|
|
939
|
+
} = comparison;
|
|
940
|
+
if (!current || !existsSync(current)) {
|
|
941
|
+
output.warn(`Current screenshot not found for ${name}, skipping`);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
let sanitizedName;
|
|
945
|
+
try {
|
|
946
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
let validatedProperties = validateScreenshotProperties(comparison.properties || {});
|
|
952
|
+
let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
953
|
+
let filename = generateBaselineFilename(sanitizedName, signature);
|
|
954
|
+
let baselineImagePath = getBaselinePath(this.baselinePath, filename);
|
|
955
|
+
try {
|
|
956
|
+
let currentBuffer = readFileSync(current);
|
|
957
|
+
writeFileSync(baselineImagePath, currentBuffer);
|
|
958
|
+
let screenshotEntry = {
|
|
959
|
+
name: sanitizedName,
|
|
960
|
+
properties: validatedProperties,
|
|
961
|
+
path: baselineImagePath,
|
|
962
|
+
signature
|
|
963
|
+
};
|
|
964
|
+
upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
|
|
965
|
+
updatedCount++;
|
|
966
|
+
output.info(`✅ Updated baseline for ${sanitizedName}`);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
output.error(`❌ Failed to update baseline for ${sanitizedName}: ${error.message}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (updatedCount > 0) {
|
|
972
|
+
try {
|
|
973
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
974
|
+
output.info(`✅ Updated ${updatedCount} baseline(s)`);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
output.error(`❌ Failed to save baseline metadata: ${error.message}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return updatedCount;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Accept a single baseline
|
|
984
|
+
*/
|
|
985
|
+
async acceptBaseline(idOrComparison) {
|
|
986
|
+
let comparison;
|
|
987
|
+
if (typeof idOrComparison === 'string') {
|
|
988
|
+
comparison = this.comparisons.find(c => c.id === idOrComparison);
|
|
989
|
+
if (!comparison) {
|
|
990
|
+
throw new Error(`No comparison found with ID: ${idOrComparison}`);
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
comparison = idOrComparison;
|
|
994
|
+
}
|
|
995
|
+
let sanitizedName = comparison.name;
|
|
996
|
+
let properties = comparison.properties || {};
|
|
997
|
+
|
|
998
|
+
// Generate signature from properties (don't rely on comparison.signature)
|
|
999
|
+
let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
1000
|
+
let filename = generateBaselineFilename(sanitizedName, signature);
|
|
1001
|
+
|
|
1002
|
+
// Find the current screenshot file
|
|
1003
|
+
let currentImagePath = safePath(this.currentPath, filename);
|
|
1004
|
+
if (!existsSync(currentImagePath)) {
|
|
1005
|
+
output.error(`Current screenshot not found at: ${currentImagePath}`);
|
|
1006
|
+
throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Read the current image
|
|
1010
|
+
let imageBuffer = readFileSync(currentImagePath);
|
|
1011
|
+
|
|
1012
|
+
// Create baseline directory if it doesn't exist
|
|
1013
|
+
if (!existsSync(this.baselinePath)) {
|
|
1014
|
+
mkdirSync(this.baselinePath, {
|
|
1015
|
+
recursive: true
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Update the baseline
|
|
1020
|
+
let baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
1021
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
1022
|
+
|
|
1023
|
+
// Update baseline metadata
|
|
1024
|
+
if (!this.baselineData) {
|
|
1025
|
+
this.baselineData = createEmptyBaselineMetadata({
|
|
1026
|
+
threshold: this.threshold,
|
|
1027
|
+
signatureProperties: this.signatureProperties
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
let screenshotEntry = {
|
|
1031
|
+
name: sanitizedName,
|
|
1032
|
+
properties,
|
|
1033
|
+
path: baselineImagePath,
|
|
1034
|
+
signature
|
|
1035
|
+
};
|
|
1036
|
+
upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
|
|
1037
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
1038
|
+
return {
|
|
1039
|
+
name: sanitizedName,
|
|
1040
|
+
status: 'accepted',
|
|
1041
|
+
message: 'Screenshot accepted as new baseline'
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Create new baseline (used during --set-baseline mode)
|
|
1047
|
+
* @private
|
|
1048
|
+
*/
|
|
1049
|
+
createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
1050
|
+
output.info(`🐻 Creating baseline for ${name}`);
|
|
1051
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
1052
|
+
if (!this.baselineData) {
|
|
1053
|
+
this.baselineData = createEmptyBaselineMetadata({
|
|
1054
|
+
threshold: this.threshold,
|
|
1055
|
+
signatureProperties: this.signatureProperties
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1059
|
+
let screenshotEntry = {
|
|
1060
|
+
name,
|
|
1061
|
+
properties: properties || {},
|
|
1062
|
+
path: baselineImagePath,
|
|
1063
|
+
signature
|
|
1064
|
+
};
|
|
1065
|
+
upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
|
|
1066
|
+
saveBaselineMetadata(this.baselinePath, this.baselineData);
|
|
1067
|
+
let result = {
|
|
1068
|
+
id: generateComparisonId(signature),
|
|
1069
|
+
name,
|
|
1070
|
+
status: 'new',
|
|
1071
|
+
baseline: baselineImagePath,
|
|
1072
|
+
current: currentImagePath,
|
|
1073
|
+
diff: null,
|
|
1074
|
+
properties,
|
|
1075
|
+
signature
|
|
1076
|
+
};
|
|
1077
|
+
this.comparisons.push(result);
|
|
1078
|
+
output.info(`✅ Baseline created for ${name}`);
|
|
1079
|
+
return result;
|
|
1080
|
+
}
|
|
1081
|
+
}
|