@vizzly-testing/cli 0.5.0 ā 0.7.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/README.md +55 -9
- package/dist/cli.js +15 -2
- package/dist/commands/finalize.js +72 -0
- package/dist/commands/run.js +59 -19
- package/dist/commands/tdd.js +6 -13
- package/dist/commands/upload.js +1 -0
- package/dist/server/handlers/tdd-handler.js +82 -8
- package/dist/services/api-service.js +14 -0
- package/dist/services/html-report-generator.js +377 -0
- package/dist/services/report-generator/report.css +355 -0
- package/dist/services/report-generator/viewer.js +100 -0
- package/dist/services/server-manager.js +3 -2
- package/dist/services/tdd-service.js +436 -66
- package/dist/services/test-runner.js +56 -28
- package/dist/services/uploader.js +3 -2
- package/dist/types/commands/finalize.d.ts +13 -0
- package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
- package/dist/types/services/api-service.d.ts +6 -0
- package/dist/types/services/html-report-generator.d.ts +52 -0
- package/dist/types/services/report-generator/viewer.d.ts +0 -0
- package/dist/types/services/server-manager.d.ts +19 -1
- package/dist/types/services/tdd-service.d.ts +24 -3
- package/dist/types/services/uploader.d.ts +2 -1
- package/dist/types/utils/config-loader.d.ts +3 -0
- package/dist/types/utils/environment-config.d.ts +5 -0
- package/dist/types/utils/security.d.ts +29 -0
- package/dist/utils/config-loader.js +11 -1
- package/dist/utils/environment-config.js +9 -0
- package/dist/utils/security.js +154 -0
- package/docs/api-reference.md +27 -0
- package/docs/tdd-mode.md +58 -12
- package/docs/test-integration.md +69 -0
- package/package.json +3 -2
|
@@ -6,37 +6,59 @@ import { colors } from '../utils/colors.js';
|
|
|
6
6
|
import { getDefaultBranch } from '../utils/git.js';
|
|
7
7
|
import { fetchWithTimeout } from '../utils/fetch-utils.js';
|
|
8
8
|
import { NetworkError } from '../errors/vizzly-error.js';
|
|
9
|
+
import { HtmlReportGenerator } from './html-report-generator.js';
|
|
10
|
+
import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
|
|
9
11
|
const logger = createServiceLogger('TDD');
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Create a new TDD service instance
|
|
13
15
|
*/
|
|
14
16
|
export function createTDDService(config, options = {}) {
|
|
15
|
-
return new TddService(config, options.workingDir);
|
|
17
|
+
return new TddService(config, options.workingDir, options.setBaseline);
|
|
16
18
|
}
|
|
17
19
|
export class TddService {
|
|
18
|
-
constructor(config, workingDir = process.cwd()) {
|
|
20
|
+
constructor(config, workingDir = process.cwd(), setBaseline = false) {
|
|
19
21
|
this.config = config;
|
|
22
|
+
this.setBaseline = setBaseline;
|
|
20
23
|
this.api = new ApiService({
|
|
21
24
|
baseUrl: config.apiUrl,
|
|
22
25
|
token: config.apiKey,
|
|
23
26
|
command: 'tdd',
|
|
24
27
|
allowNoToken: true // TDD can run without a token to create new screenshots
|
|
25
28
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
|
|
30
|
+
// Validate and secure the working directory
|
|
31
|
+
try {
|
|
32
|
+
this.workingDir = validatePathSecurity(workingDir, workingDir);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
logger.error(`Invalid working directory: ${error.message}`);
|
|
35
|
+
throw new Error(`Working directory validation failed: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Use safe path construction for subdirectories
|
|
39
|
+
this.baselinePath = safePath(this.workingDir, '.vizzly', 'baselines');
|
|
40
|
+
this.currentPath = safePath(this.workingDir, '.vizzly', 'current');
|
|
41
|
+
this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
|
|
30
42
|
this.baselineData = null;
|
|
31
43
|
this.comparisons = [];
|
|
32
|
-
this.threshold = config.comparison?.threshold || 0.
|
|
44
|
+
this.threshold = config.comparison?.threshold || 0.1;
|
|
45
|
+
|
|
46
|
+
// Check if we're in baseline update mode
|
|
47
|
+
if (this.setBaseline) {
|
|
48
|
+
logger.info('š» Baseline update mode - will overwrite existing baselines with new ones');
|
|
49
|
+
}
|
|
33
50
|
|
|
34
51
|
// Ensure directories exist
|
|
35
52
|
[this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
|
|
36
53
|
if (!existsSync(dir)) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dir, {
|
|
56
|
+
recursive: true
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logger.error(`Failed to create directory ${dir}: ${error.message}`);
|
|
60
|
+
throw new Error(`Directory creation failed: ${error.message}`);
|
|
61
|
+
}
|
|
40
62
|
}
|
|
41
63
|
});
|
|
42
64
|
}
|
|
@@ -57,9 +79,34 @@ export class TddService {
|
|
|
57
79
|
try {
|
|
58
80
|
let baselineBuild;
|
|
59
81
|
if (buildId) {
|
|
60
|
-
// Use specific build ID
|
|
82
|
+
// Use specific build ID - get it with screenshots in one call
|
|
61
83
|
logger.info(`š Using specified build: ${buildId}`);
|
|
62
|
-
|
|
84
|
+
const apiResponse = await this.api.getBuild(buildId, 'screenshots');
|
|
85
|
+
|
|
86
|
+
// Debug the full API response (only in debug mode)
|
|
87
|
+
logger.debug(`š Raw API response:`, {
|
|
88
|
+
apiResponse
|
|
89
|
+
});
|
|
90
|
+
if (!apiResponse) {
|
|
91
|
+
throw new Error(`Build ${buildId} not found or API returned null`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle wrapped response format
|
|
95
|
+
baselineBuild = apiResponse.build || apiResponse;
|
|
96
|
+
if (!baselineBuild.id) {
|
|
97
|
+
logger.warn(`ā ļø Build response structure: ${JSON.stringify(Object.keys(apiResponse))}`);
|
|
98
|
+
logger.warn(`ā ļø Extracted build keys: ${JSON.stringify(Object.keys(baselineBuild))}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check build status and warn if it's not successful
|
|
102
|
+
if (baselineBuild.status === 'failed') {
|
|
103
|
+
logger.warn(`ā ļø Build ${buildId} is marked as FAILED - falling back to local baselines`);
|
|
104
|
+
logger.info(`š” To use remote baselines, specify a successful build ID instead`);
|
|
105
|
+
// Fall back to local baseline logic
|
|
106
|
+
return await this.handleLocalBaselines();
|
|
107
|
+
} else if (baselineBuild.status !== 'completed') {
|
|
108
|
+
logger.warn(`ā ļø Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
|
|
109
|
+
}
|
|
63
110
|
} else if (comparisonId) {
|
|
64
111
|
// Use specific comparison ID
|
|
65
112
|
logger.info(`š Using comparison: ${comparisonId}`);
|
|
@@ -80,53 +127,244 @@ export class TddService {
|
|
|
80
127
|
}
|
|
81
128
|
baselineBuild = builds.data[0];
|
|
82
129
|
}
|
|
83
|
-
logger.info(`š„ Found baseline build: ${colors.cyan(baselineBuild.name)} (${baselineBuild.id})`);
|
|
130
|
+
logger.info(`š„ Found baseline build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
|
|
84
131
|
|
|
85
|
-
//
|
|
86
|
-
|
|
132
|
+
// For specific buildId, we already have screenshots, otherwise get build details
|
|
133
|
+
let buildDetails = baselineBuild;
|
|
134
|
+
if (!buildId) {
|
|
135
|
+
// Get build details with screenshots for non-buildId cases
|
|
136
|
+
const actualBuildId = baselineBuild.id;
|
|
137
|
+
buildDetails = await this.api.getBuild(actualBuildId, 'screenshots');
|
|
138
|
+
}
|
|
87
139
|
if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
|
|
88
140
|
logger.warn('ā ļø No screenshots found in baseline build');
|
|
89
141
|
return null;
|
|
90
142
|
}
|
|
91
143
|
logger.info(`šø Downloading ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
|
|
92
144
|
|
|
93
|
-
//
|
|
145
|
+
// Debug screenshots structure (only in debug mode)
|
|
146
|
+
logger.debug(`š Screenshots array structure:`, {
|
|
147
|
+
screenshotSample: buildDetails.screenshots.slice(0, 2),
|
|
148
|
+
totalCount: buildDetails.screenshots.length
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Check existing baseline metadata for efficient SHA comparison
|
|
152
|
+
const existingBaseline = await this.loadBaseline();
|
|
153
|
+
const existingShaMap = new Map();
|
|
154
|
+
if (existingBaseline) {
|
|
155
|
+
existingBaseline.screenshots.forEach(s => {
|
|
156
|
+
if (s.sha256) {
|
|
157
|
+
existingShaMap.set(s.name, s.sha256);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Download screenshots in batches with progress indication
|
|
163
|
+
let downloadedCount = 0;
|
|
164
|
+
let skippedCount = 0;
|
|
165
|
+
let errorCount = 0;
|
|
166
|
+
const totalScreenshots = buildDetails.screenshots.length;
|
|
167
|
+
const batchSize = 5; // Download up to 5 screenshots concurrently
|
|
168
|
+
|
|
169
|
+
// Filter screenshots that need to be downloaded
|
|
170
|
+
const screenshotsToProcess = [];
|
|
94
171
|
for (const screenshot of buildDetails.screenshots) {
|
|
95
|
-
|
|
172
|
+
// Sanitize screenshot name for security
|
|
173
|
+
let sanitizedName;
|
|
174
|
+
try {
|
|
175
|
+
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
logger.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
|
|
178
|
+
errorCount++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const imagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
182
|
+
|
|
183
|
+
// Check if we already have this file with the same SHA (using metadata)
|
|
184
|
+
if (existsSync(imagePath) && screenshot.sha256) {
|
|
185
|
+
const storedSha = existingShaMap.get(sanitizedName);
|
|
186
|
+
if (storedSha === screenshot.sha256) {
|
|
187
|
+
logger.debug(`ā” Skipping ${sanitizedName} - SHA match from metadata`);
|
|
188
|
+
downloadedCount++; // Count as "downloaded" since we have it
|
|
189
|
+
skippedCount++;
|
|
190
|
+
continue;
|
|
191
|
+
} else if (storedSha) {
|
|
192
|
+
logger.debug(`š SHA mismatch for ${sanitizedName} - will re-download (stored: ${storedSha?.slice(0, 8)}..., remote: ${screenshot.sha256?.slice(0, 8)}...)`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Use original_url as the download URL
|
|
197
|
+
const downloadUrl = screenshot.original_url || screenshot.url;
|
|
198
|
+
if (!downloadUrl) {
|
|
199
|
+
logger.warn(`ā ļø Screenshot ${sanitizedName} has no download URL - skipping`);
|
|
200
|
+
errorCount++;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
screenshotsToProcess.push({
|
|
204
|
+
screenshot,
|
|
205
|
+
sanitizedName,
|
|
206
|
+
imagePath,
|
|
207
|
+
downloadUrl
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Process downloads in batches
|
|
212
|
+
const actualDownloadsNeeded = screenshotsToProcess.length;
|
|
213
|
+
if (actualDownloadsNeeded > 0) {
|
|
214
|
+
logger.info(`š„ Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...`);
|
|
215
|
+
for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
|
|
216
|
+
const batch = screenshotsToProcess.slice(i, i + batchSize);
|
|
217
|
+
const batchNum = Math.floor(i / batchSize) + 1;
|
|
218
|
+
const totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
|
|
219
|
+
logger.info(`š¦ Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)`);
|
|
220
|
+
|
|
221
|
+
// Download batch concurrently
|
|
222
|
+
const downloadPromises = batch.map(async ({
|
|
223
|
+
sanitizedName,
|
|
224
|
+
imagePath,
|
|
225
|
+
downloadUrl
|
|
226
|
+
}) => {
|
|
227
|
+
try {
|
|
228
|
+
logger.debug(`š„ Downloading: ${sanitizedName}`);
|
|
229
|
+
const response = await fetchWithTimeout(downloadUrl);
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
|
|
232
|
+
}
|
|
233
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
234
|
+
const imageBuffer = Buffer.from(arrayBuffer);
|
|
235
|
+
writeFileSync(imagePath, imageBuffer);
|
|
236
|
+
logger.debug(`ā Downloaded ${sanitizedName}.png`);
|
|
237
|
+
return {
|
|
238
|
+
success: true,
|
|
239
|
+
name: sanitizedName
|
|
240
|
+
};
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logger.warn(`ā ļø Failed to download ${sanitizedName}: ${error.message}`);
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
name: sanitizedName,
|
|
246
|
+
error: error.message
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const batchResults = await Promise.all(downloadPromises);
|
|
251
|
+
const batchSuccesses = batchResults.filter(r => r.success).length;
|
|
252
|
+
const batchFailures = batchResults.filter(r => !r.success).length;
|
|
253
|
+
downloadedCount += batchSuccesses;
|
|
254
|
+
errorCount += batchFailures;
|
|
255
|
+
|
|
256
|
+
// Show progress
|
|
257
|
+
const totalProcessed = downloadedCount + skippedCount + errorCount;
|
|
258
|
+
const progressPercent = Math.round(totalProcessed / totalScreenshots * 100);
|
|
259
|
+
logger.info(`š Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
96
262
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
263
|
+
// Check if we actually downloaded any screenshots
|
|
264
|
+
if (downloadedCount === 0 && skippedCount === 0) {
|
|
265
|
+
logger.error('ā No screenshots were successfully downloaded from the baseline build');
|
|
266
|
+
if (errorCount > 0) {
|
|
267
|
+
logger.info(`š” ${errorCount} screenshots had errors - check download URLs and network connection`);
|
|
101
268
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
269
|
+
logger.info('š” This usually means the build failed or screenshots have no download URLs');
|
|
270
|
+
logger.info('š” Try using a successful build ID, or run without --baseline-build to create local baselines');
|
|
271
|
+
return null;
|
|
105
272
|
}
|
|
106
273
|
|
|
107
|
-
// Store baseline metadata
|
|
274
|
+
// Store enhanced baseline metadata with SHA hashes and build info
|
|
108
275
|
this.baselineData = {
|
|
109
276
|
buildId: baselineBuild.id,
|
|
110
277
|
buildName: baselineBuild.name,
|
|
111
278
|
environment,
|
|
112
279
|
branch,
|
|
113
280
|
threshold: this.threshold,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
281
|
+
createdAt: new Date().toISOString(),
|
|
282
|
+
buildInfo: {
|
|
283
|
+
commitSha: baselineBuild.commit_sha,
|
|
284
|
+
commitMessage: baselineBuild.commit_message,
|
|
285
|
+
approvalStatus: baselineBuild.approval_status,
|
|
286
|
+
completedAt: baselineBuild.completed_at
|
|
287
|
+
},
|
|
288
|
+
screenshots: buildDetails.screenshots.map(s => {
|
|
289
|
+
let sanitizedName;
|
|
290
|
+
try {
|
|
291
|
+
sanitizedName = sanitizeScreenshotName(s.name);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
logger.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
|
|
294
|
+
return null; // Skip invalid screenshots
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
name: sanitizedName,
|
|
298
|
+
originalName: s.name,
|
|
299
|
+
sha256: s.sha256,
|
|
300
|
+
// Store remote SHA for quick comparison
|
|
301
|
+
id: s.id,
|
|
302
|
+
properties: validateScreenshotProperties(s.metadata || s.properties || {}),
|
|
303
|
+
path: safePath(this.baselinePath, `${sanitizedName}.png`),
|
|
304
|
+
originalUrl: s.original_url,
|
|
305
|
+
fileSize: s.file_size_bytes,
|
|
306
|
+
dimensions: {
|
|
307
|
+
width: s.width,
|
|
308
|
+
height: s.height
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}).filter(Boolean) // Remove null entries from invalid screenshots
|
|
119
312
|
};
|
|
120
313
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
121
314
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
122
|
-
|
|
315
|
+
|
|
316
|
+
// Final summary
|
|
317
|
+
const actualDownloads = downloadedCount - skippedCount;
|
|
318
|
+
const totalAttempted = downloadedCount + errorCount;
|
|
319
|
+
if (skippedCount > 0 || errorCount > 0) {
|
|
320
|
+
let summaryParts = [];
|
|
321
|
+
if (actualDownloads > 0) summaryParts.push(`${actualDownloads} downloaded`);
|
|
322
|
+
if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped (matching SHA)`);
|
|
323
|
+
if (errorCount > 0) summaryParts.push(`${errorCount} failed`);
|
|
324
|
+
logger.info(`ā
Baseline ready - ${summaryParts.join(', ')} - ${totalAttempted}/${buildDetails.screenshots.length} total`);
|
|
325
|
+
} else {
|
|
326
|
+
logger.info(`ā
Baseline downloaded successfully - ${downloadedCount}/${buildDetails.screenshots.length} screenshots`);
|
|
327
|
+
}
|
|
123
328
|
return this.baselineData;
|
|
124
329
|
} catch (error) {
|
|
125
330
|
logger.error(`ā Failed to download baseline: ${error.message}`);
|
|
126
331
|
throw error;
|
|
127
332
|
}
|
|
128
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Handle local baseline logic (either load existing or prepare for new baselines)
|
|
337
|
+
* @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
|
|
338
|
+
*/
|
|
339
|
+
async handleLocalBaselines() {
|
|
340
|
+
// Check if we're in baseline update mode - skip loading existing baselines
|
|
341
|
+
if (this.setBaseline) {
|
|
342
|
+
logger.info('š Ready for new baseline creation - all screenshots will be treated as new baselines');
|
|
343
|
+
|
|
344
|
+
// Reset baseline data since we're creating new ones
|
|
345
|
+
this.baselineData = null;
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const baseline = await this.loadBaseline();
|
|
349
|
+
if (!baseline) {
|
|
350
|
+
if (this.config.apiKey) {
|
|
351
|
+
logger.info('š„ No local baseline found, but API key available for future remote fetching');
|
|
352
|
+
logger.info('š Current run will create new local baselines');
|
|
353
|
+
} else {
|
|
354
|
+
logger.info('š No local baseline found and no API token - all screenshots will be marked as new');
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
} else {
|
|
358
|
+
logger.info(`ā
Using existing baseline: ${colors.cyan(baseline.buildName)}`);
|
|
359
|
+
return baseline;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
129
362
|
async loadBaseline() {
|
|
363
|
+
// In baseline update mode, never load existing baselines
|
|
364
|
+
if (this.setBaseline) {
|
|
365
|
+
logger.debug('š» Baseline update mode - skipping baseline loading');
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
130
368
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
131
369
|
if (!existsSync(metadataPath)) {
|
|
132
370
|
return null;
|
|
@@ -142,22 +380,36 @@ export class TddService {
|
|
|
142
380
|
}
|
|
143
381
|
}
|
|
144
382
|
async compareScreenshot(name, imageBuffer, properties = {}) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
383
|
+
// Sanitize screenshot name and validate properties
|
|
384
|
+
let sanitizedName;
|
|
385
|
+
try {
|
|
386
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
logger.error(`Invalid screenshot name '${name}': ${error.message}`);
|
|
389
|
+
throw new Error(`Screenshot name validation failed: ${error.message}`);
|
|
390
|
+
}
|
|
391
|
+
let validatedProperties;
|
|
392
|
+
try {
|
|
393
|
+
validatedProperties = validateScreenshotProperties(properties);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
logger.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
|
|
396
|
+
validatedProperties = {};
|
|
397
|
+
}
|
|
398
|
+
const currentImagePath = safePath(this.currentPath, `${sanitizedName}.png`);
|
|
399
|
+
const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
400
|
+
const diffImagePath = safePath(this.diffPath, `${sanitizedName}.png`);
|
|
148
401
|
|
|
149
402
|
// Save current screenshot
|
|
150
403
|
writeFileSync(currentImagePath, imageBuffer);
|
|
151
404
|
|
|
152
|
-
// Check if we're in baseline update mode -
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return this.updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath);
|
|
405
|
+
// Check if we're in baseline update mode - treat as first run, no comparisons
|
|
406
|
+
if (this.setBaseline) {
|
|
407
|
+
return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
|
|
156
408
|
}
|
|
157
409
|
|
|
158
410
|
// Check if baseline exists
|
|
159
411
|
if (!existsSync(baselineImagePath)) {
|
|
160
|
-
logger.warn(`ā ļø No baseline found for ${
|
|
412
|
+
logger.warn(`ā ļø No baseline found for ${sanitizedName} - creating baseline`);
|
|
161
413
|
|
|
162
414
|
// Copy current screenshot to baseline directory for future comparisons
|
|
163
415
|
writeFileSync(baselineImagePath, imageBuffer);
|
|
@@ -176,11 +428,11 @@ export class TddService {
|
|
|
176
428
|
|
|
177
429
|
// Add screenshot to baseline metadata
|
|
178
430
|
const screenshotEntry = {
|
|
179
|
-
name,
|
|
180
|
-
properties:
|
|
431
|
+
name: sanitizedName,
|
|
432
|
+
properties: validatedProperties,
|
|
181
433
|
path: baselineImagePath
|
|
182
434
|
};
|
|
183
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name ===
|
|
435
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
|
|
184
436
|
if (existingIndex >= 0) {
|
|
185
437
|
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
186
438
|
} else {
|
|
@@ -190,14 +442,14 @@ export class TddService {
|
|
|
190
442
|
// Save updated metadata
|
|
191
443
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
192
444
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
193
|
-
logger.info(`ā
Created baseline for ${
|
|
445
|
+
logger.info(`ā
Created baseline for ${sanitizedName}`);
|
|
194
446
|
const result = {
|
|
195
|
-
name,
|
|
447
|
+
name: sanitizedName,
|
|
196
448
|
status: 'new',
|
|
197
449
|
baseline: baselineImagePath,
|
|
198
450
|
current: currentImagePath,
|
|
199
451
|
diff: null,
|
|
200
|
-
properties
|
|
452
|
+
properties: validatedProperties
|
|
201
453
|
};
|
|
202
454
|
this.comparisons.push(result);
|
|
203
455
|
return result;
|
|
@@ -215,15 +467,15 @@ export class TddService {
|
|
|
215
467
|
if (result.match) {
|
|
216
468
|
// Images match
|
|
217
469
|
const comparison = {
|
|
218
|
-
name,
|
|
470
|
+
name: sanitizedName,
|
|
219
471
|
status: 'passed',
|
|
220
472
|
baseline: baselineImagePath,
|
|
221
473
|
current: currentImagePath,
|
|
222
474
|
diff: null,
|
|
223
|
-
properties,
|
|
475
|
+
properties: validatedProperties,
|
|
224
476
|
threshold: this.threshold
|
|
225
477
|
};
|
|
226
|
-
logger.info(`ā
${colors.green('PASSED')} ${
|
|
478
|
+
logger.info(`ā
${colors.green('PASSED')} ${sanitizedName}`);
|
|
227
479
|
this.comparisons.push(comparison);
|
|
228
480
|
return comparison;
|
|
229
481
|
} else {
|
|
@@ -235,32 +487,32 @@ export class TddService {
|
|
|
235
487
|
diffInfo = ' (layout difference)';
|
|
236
488
|
}
|
|
237
489
|
const comparison = {
|
|
238
|
-
name,
|
|
490
|
+
name: sanitizedName,
|
|
239
491
|
status: 'failed',
|
|
240
492
|
baseline: baselineImagePath,
|
|
241
493
|
current: currentImagePath,
|
|
242
494
|
diff: diffImagePath,
|
|
243
|
-
properties,
|
|
495
|
+
properties: validatedProperties,
|
|
244
496
|
threshold: this.threshold,
|
|
245
497
|
diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
|
|
246
498
|
diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
|
|
247
499
|
reason: result.reason
|
|
248
500
|
};
|
|
249
|
-
logger.warn(`ā ${colors.red('FAILED')} ${
|
|
501
|
+
logger.warn(`ā ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
|
|
250
502
|
logger.info(` Diff saved to: ${diffImagePath}`);
|
|
251
503
|
this.comparisons.push(comparison);
|
|
252
504
|
return comparison;
|
|
253
505
|
}
|
|
254
506
|
} catch (error) {
|
|
255
507
|
// Handle file errors or other issues
|
|
256
|
-
logger.error(`ā Error comparing ${
|
|
508
|
+
logger.error(`ā Error comparing ${sanitizedName}: ${error.message}`);
|
|
257
509
|
const comparison = {
|
|
258
|
-
name,
|
|
510
|
+
name: sanitizedName,
|
|
259
511
|
status: 'error',
|
|
260
512
|
baseline: baselineImagePath,
|
|
261
513
|
current: currentImagePath,
|
|
262
514
|
diff: null,
|
|
263
|
-
properties,
|
|
515
|
+
properties: validatedProperties,
|
|
264
516
|
error: error.message
|
|
265
517
|
};
|
|
266
518
|
this.comparisons.push(comparison);
|
|
@@ -282,7 +534,7 @@ export class TddService {
|
|
|
282
534
|
baseline: this.baselineData
|
|
283
535
|
};
|
|
284
536
|
}
|
|
285
|
-
printResults() {
|
|
537
|
+
async printResults() {
|
|
286
538
|
const results = this.getResults();
|
|
287
539
|
logger.info('\nš TDD Results:');
|
|
288
540
|
logger.info(`Total: ${colors.cyan(results.total)}`);
|
|
@@ -303,9 +555,6 @@ export class TddService {
|
|
|
303
555
|
logger.info('\nā Failed comparisons:');
|
|
304
556
|
failedComparisons.forEach(comp => {
|
|
305
557
|
logger.info(` ⢠${comp.name}`);
|
|
306
|
-
logger.info(` Baseline: ${comp.baseline}`);
|
|
307
|
-
logger.info(` Current: ${comp.current}`);
|
|
308
|
-
logger.info(` Diff: ${comp.diff}`);
|
|
309
558
|
});
|
|
310
559
|
}
|
|
311
560
|
|
|
@@ -315,13 +564,74 @@ export class TddService {
|
|
|
315
564
|
logger.info('\nšø New screenshots:');
|
|
316
565
|
newComparisons.forEach(comp => {
|
|
317
566
|
logger.info(` ⢠${comp.name}`);
|
|
318
|
-
logger.info(` Current: ${comp.current}`);
|
|
319
567
|
});
|
|
320
568
|
}
|
|
321
|
-
|
|
569
|
+
|
|
570
|
+
// Generate HTML report
|
|
571
|
+
await this.generateHtmlReport(results);
|
|
322
572
|
return results;
|
|
323
573
|
}
|
|
324
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Generate HTML report for TDD results
|
|
577
|
+
* @param {Object} results - TDD comparison results
|
|
578
|
+
*/
|
|
579
|
+
async generateHtmlReport(results) {
|
|
580
|
+
try {
|
|
581
|
+
const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
|
|
582
|
+
const reportPath = await reportGenerator.generateReport(results, {
|
|
583
|
+
baseline: this.baselineData,
|
|
584
|
+
threshold: this.threshold
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Show report path (always clickable)
|
|
588
|
+
logger.info(`\nš» View detailed report: ${colors.cyan('file://' + reportPath)}`);
|
|
589
|
+
|
|
590
|
+
// Auto-open if configured
|
|
591
|
+
if (this.config.tdd?.openReport) {
|
|
592
|
+
await this.openReport(reportPath);
|
|
593
|
+
}
|
|
594
|
+
return reportPath;
|
|
595
|
+
} catch (error) {
|
|
596
|
+
logger.warn(`Failed to generate HTML report: ${error.message}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Open HTML report in default browser
|
|
602
|
+
* @param {string} reportPath - Path to HTML report
|
|
603
|
+
*/
|
|
604
|
+
async openReport(reportPath) {
|
|
605
|
+
try {
|
|
606
|
+
const {
|
|
607
|
+
exec
|
|
608
|
+
} = await import('child_process');
|
|
609
|
+
const {
|
|
610
|
+
promisify
|
|
611
|
+
} = await import('util');
|
|
612
|
+
const execAsync = promisify(exec);
|
|
613
|
+
let command;
|
|
614
|
+
switch (process.platform) {
|
|
615
|
+
case 'darwin':
|
|
616
|
+
// macOS
|
|
617
|
+
command = `open "${reportPath}"`;
|
|
618
|
+
break;
|
|
619
|
+
case 'win32':
|
|
620
|
+
// Windows
|
|
621
|
+
command = `start "" "${reportPath}"`;
|
|
622
|
+
break;
|
|
623
|
+
default:
|
|
624
|
+
// Linux and others
|
|
625
|
+
command = `xdg-open "${reportPath}"`;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
await execAsync(command);
|
|
629
|
+
logger.info('š Report opened in browser');
|
|
630
|
+
} catch (error) {
|
|
631
|
+
logger.debug(`Failed to open report: ${error.message}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
325
635
|
/**
|
|
326
636
|
* Update baselines with current screenshots (accept changes)
|
|
327
637
|
* @returns {number} Number of baselines updated
|
|
@@ -353,7 +663,16 @@ export class TddService {
|
|
|
353
663
|
logger.warn(`Current screenshot not found for ${name}, skipping`);
|
|
354
664
|
continue;
|
|
355
665
|
}
|
|
356
|
-
|
|
666
|
+
|
|
667
|
+
// Sanitize screenshot name for security
|
|
668
|
+
let sanitizedName;
|
|
669
|
+
try {
|
|
670
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
logger.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
357
676
|
try {
|
|
358
677
|
// Copy current screenshot to baseline
|
|
359
678
|
const currentBuffer = readFileSync(current);
|
|
@@ -361,20 +680,20 @@ export class TddService {
|
|
|
361
680
|
|
|
362
681
|
// Update baseline metadata
|
|
363
682
|
const screenshotEntry = {
|
|
364
|
-
name,
|
|
365
|
-
properties: comparison.properties || {},
|
|
683
|
+
name: sanitizedName,
|
|
684
|
+
properties: validateScreenshotProperties(comparison.properties || {}),
|
|
366
685
|
path: baselineImagePath
|
|
367
686
|
};
|
|
368
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name ===
|
|
687
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
|
|
369
688
|
if (existingIndex >= 0) {
|
|
370
689
|
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
371
690
|
} else {
|
|
372
691
|
this.baselineData.screenshots.push(screenshotEntry);
|
|
373
692
|
}
|
|
374
693
|
updatedCount++;
|
|
375
|
-
logger.info(`ā
Updated baseline for ${
|
|
694
|
+
logger.info(`ā
Updated baseline for ${sanitizedName}`);
|
|
376
695
|
} catch (error) {
|
|
377
|
-
logger.error(`ā Failed to update baseline for ${
|
|
696
|
+
logger.error(`ā Failed to update baseline for ${sanitizedName}: ${error.message}`);
|
|
378
697
|
}
|
|
379
698
|
}
|
|
380
699
|
|
|
@@ -391,6 +710,57 @@ export class TddService {
|
|
|
391
710
|
return updatedCount;
|
|
392
711
|
}
|
|
393
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Create a new baseline (used during --set-baseline mode)
|
|
715
|
+
* @private
|
|
716
|
+
*/
|
|
717
|
+
createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
718
|
+
logger.info(`š» Creating baseline for ${name}`);
|
|
719
|
+
|
|
720
|
+
// Copy current screenshot to baseline directory
|
|
721
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
722
|
+
|
|
723
|
+
// Update or create baseline metadata
|
|
724
|
+
if (!this.baselineData) {
|
|
725
|
+
this.baselineData = {
|
|
726
|
+
buildId: 'local-baseline',
|
|
727
|
+
buildName: 'Local TDD Baseline',
|
|
728
|
+
environment: 'test',
|
|
729
|
+
branch: 'local',
|
|
730
|
+
threshold: this.threshold,
|
|
731
|
+
screenshots: []
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Add screenshot to baseline metadata
|
|
736
|
+
const screenshotEntry = {
|
|
737
|
+
name,
|
|
738
|
+
properties: properties || {},
|
|
739
|
+
path: baselineImagePath
|
|
740
|
+
};
|
|
741
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
|
|
742
|
+
if (existingIndex >= 0) {
|
|
743
|
+
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
744
|
+
} else {
|
|
745
|
+
this.baselineData.screenshots.push(screenshotEntry);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Save updated metadata
|
|
749
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
750
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
751
|
+
const result = {
|
|
752
|
+
name,
|
|
753
|
+
status: 'new',
|
|
754
|
+
baseline: baselineImagePath,
|
|
755
|
+
current: currentImagePath,
|
|
756
|
+
diff: null,
|
|
757
|
+
properties
|
|
758
|
+
};
|
|
759
|
+
this.comparisons.push(result);
|
|
760
|
+
logger.info(`ā
Baseline created for ${name}`);
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
|
|
394
764
|
/**
|
|
395
765
|
* Update a single baseline with current screenshot
|
|
396
766
|
* @private
|