@vizzly-testing/cli 0.5.0 ā 0.6.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 +15 -9
- package/dist/commands/run.js +57 -18
- package/dist/commands/tdd.js +6 -13
- package/dist/server/handlers/tdd-handler.js +82 -8
- 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 +375 -66
- package/dist/services/test-runner.js +54 -27
- package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
- 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/utils/config-loader.d.ts +3 -0
- package/dist/types/utils/security.d.ts +29 -0
- package/dist/utils/config-loader.js +7 -0
- package/dist/utils/security.js +154 -0
- package/docs/tdd-mode.md +58 -12
- 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,183 @@ 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 each screenshot (with efficient SHA checking)
|
|
163
|
+
let downloadedCount = 0;
|
|
164
|
+
let skippedCount = 0;
|
|
94
165
|
for (const screenshot of buildDetails.screenshots) {
|
|
95
|
-
|
|
166
|
+
// Sanitize screenshot name for security
|
|
167
|
+
let sanitizedName;
|
|
168
|
+
try {
|
|
169
|
+
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
logger.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const imagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
175
|
+
|
|
176
|
+
// Check if we already have this file with the same SHA (using metadata)
|
|
177
|
+
if (existsSync(imagePath) && screenshot.sha256) {
|
|
178
|
+
const storedSha = existingShaMap.get(sanitizedName);
|
|
179
|
+
if (storedSha === screenshot.sha256) {
|
|
180
|
+
logger.debug(`ā” Skipping ${sanitizedName} - SHA match from metadata`);
|
|
181
|
+
downloadedCount++; // Count as "downloaded" since we have it
|
|
182
|
+
skippedCount++;
|
|
183
|
+
continue;
|
|
184
|
+
} else if (storedSha) {
|
|
185
|
+
logger.debug(`š SHA mismatch for ${sanitizedName} - will re-download (stored: ${storedSha?.slice(0, 8)}..., remote: ${screenshot.sha256?.slice(0, 8)}...)`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
96
188
|
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
100
|
-
|
|
189
|
+
// Use original_url as the download URL
|
|
190
|
+
const downloadUrl = screenshot.original_url || screenshot.url;
|
|
191
|
+
if (!downloadUrl) {
|
|
192
|
+
logger.warn(`ā ļø Screenshot ${sanitizedName} has no download URL - skipping`);
|
|
193
|
+
continue; // Skip screenshots without URLs
|
|
194
|
+
}
|
|
195
|
+
logger.debug(`š„ Downloading screenshot: ${sanitizedName} from ${downloadUrl}`);
|
|
196
|
+
try {
|
|
197
|
+
// Download the image
|
|
198
|
+
const response = await fetchWithTimeout(downloadUrl);
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
|
|
201
|
+
}
|
|
202
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
203
|
+
const imageBuffer = Buffer.from(arrayBuffer);
|
|
204
|
+
writeFileSync(imagePath, imageBuffer);
|
|
205
|
+
downloadedCount++;
|
|
206
|
+
logger.debug(`ā Downloaded ${sanitizedName}.png`);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
logger.warn(`ā ļø Failed to download ${sanitizedName}: ${error.message}`);
|
|
101
209
|
}
|
|
102
|
-
const imageBuffer = await response.buffer();
|
|
103
|
-
writeFileSync(imagePath, imageBuffer);
|
|
104
|
-
logger.debug(`ā Downloaded ${screenshot.name}.png`);
|
|
105
210
|
}
|
|
106
211
|
|
|
107
|
-
//
|
|
212
|
+
// Check if we actually downloaded any screenshots
|
|
213
|
+
if (downloadedCount === 0) {
|
|
214
|
+
logger.error('ā No screenshots were successfully downloaded from the baseline build');
|
|
215
|
+
logger.info('š” This usually means the build failed or screenshots have no download URLs');
|
|
216
|
+
logger.info('š” Try using a successful build ID, or run without --baseline-build to create local baselines');
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Store enhanced baseline metadata with SHA hashes and build info
|
|
108
221
|
this.baselineData = {
|
|
109
222
|
buildId: baselineBuild.id,
|
|
110
223
|
buildName: baselineBuild.name,
|
|
111
224
|
environment,
|
|
112
225
|
branch,
|
|
113
226
|
threshold: this.threshold,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
227
|
+
createdAt: new Date().toISOString(),
|
|
228
|
+
buildInfo: {
|
|
229
|
+
commitSha: baselineBuild.commit_sha,
|
|
230
|
+
commitMessage: baselineBuild.commit_message,
|
|
231
|
+
approvalStatus: baselineBuild.approval_status,
|
|
232
|
+
completedAt: baselineBuild.completed_at
|
|
233
|
+
},
|
|
234
|
+
screenshots: buildDetails.screenshots.map(s => {
|
|
235
|
+
let sanitizedName;
|
|
236
|
+
try {
|
|
237
|
+
sanitizedName = sanitizeScreenshotName(s.name);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logger.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
|
|
240
|
+
return null; // Skip invalid screenshots
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
name: sanitizedName,
|
|
244
|
+
originalName: s.name,
|
|
245
|
+
sha256: s.sha256,
|
|
246
|
+
// Store remote SHA for quick comparison
|
|
247
|
+
id: s.id,
|
|
248
|
+
properties: validateScreenshotProperties(s.metadata || s.properties || {}),
|
|
249
|
+
path: safePath(this.baselinePath, `${sanitizedName}.png`),
|
|
250
|
+
originalUrl: s.original_url,
|
|
251
|
+
fileSize: s.file_size_bytes,
|
|
252
|
+
dimensions: {
|
|
253
|
+
width: s.width,
|
|
254
|
+
height: s.height
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}).filter(Boolean) // Remove null entries from invalid screenshots
|
|
119
258
|
};
|
|
120
259
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
121
260
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
122
|
-
|
|
261
|
+
if (skippedCount > 0) {
|
|
262
|
+
const actualDownloads = downloadedCount - skippedCount;
|
|
263
|
+
logger.info(`ā
Baseline ready - ${actualDownloads} downloaded, ${skippedCount} skipped (matching SHA) - ${downloadedCount}/${buildDetails.screenshots.length} total`);
|
|
264
|
+
} else {
|
|
265
|
+
logger.info(`ā
Baseline downloaded successfully - ${downloadedCount}/${buildDetails.screenshots.length} screenshots`);
|
|
266
|
+
}
|
|
123
267
|
return this.baselineData;
|
|
124
268
|
} catch (error) {
|
|
125
269
|
logger.error(`ā Failed to download baseline: ${error.message}`);
|
|
126
270
|
throw error;
|
|
127
271
|
}
|
|
128
272
|
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Handle local baseline logic (either load existing or prepare for new baselines)
|
|
276
|
+
* @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
|
|
277
|
+
*/
|
|
278
|
+
async handleLocalBaselines() {
|
|
279
|
+
// Check if we're in baseline update mode - skip loading existing baselines
|
|
280
|
+
if (this.setBaseline) {
|
|
281
|
+
logger.info('š Ready for new baseline creation - all screenshots will be treated as new baselines');
|
|
282
|
+
|
|
283
|
+
// Reset baseline data since we're creating new ones
|
|
284
|
+
this.baselineData = null;
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const baseline = await this.loadBaseline();
|
|
288
|
+
if (!baseline) {
|
|
289
|
+
if (this.config.apiKey) {
|
|
290
|
+
logger.info('š„ No local baseline found, but API key available for future remote fetching');
|
|
291
|
+
logger.info('š Current run will create new local baselines');
|
|
292
|
+
} else {
|
|
293
|
+
logger.info('š No local baseline found and no API token - all screenshots will be marked as new');
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
} else {
|
|
297
|
+
logger.info(`ā
Using existing baseline: ${colors.cyan(baseline.buildName)}`);
|
|
298
|
+
return baseline;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
129
301
|
async loadBaseline() {
|
|
302
|
+
// In baseline update mode, never load existing baselines
|
|
303
|
+
if (this.setBaseline) {
|
|
304
|
+
logger.debug('š» Baseline update mode - skipping baseline loading');
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
130
307
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
131
308
|
if (!existsSync(metadataPath)) {
|
|
132
309
|
return null;
|
|
@@ -142,22 +319,36 @@ export class TddService {
|
|
|
142
319
|
}
|
|
143
320
|
}
|
|
144
321
|
async compareScreenshot(name, imageBuffer, properties = {}) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
322
|
+
// Sanitize screenshot name and validate properties
|
|
323
|
+
let sanitizedName;
|
|
324
|
+
try {
|
|
325
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
logger.error(`Invalid screenshot name '${name}': ${error.message}`);
|
|
328
|
+
throw new Error(`Screenshot name validation failed: ${error.message}`);
|
|
329
|
+
}
|
|
330
|
+
let validatedProperties;
|
|
331
|
+
try {
|
|
332
|
+
validatedProperties = validateScreenshotProperties(properties);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
logger.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
|
|
335
|
+
validatedProperties = {};
|
|
336
|
+
}
|
|
337
|
+
const currentImagePath = safePath(this.currentPath, `${sanitizedName}.png`);
|
|
338
|
+
const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
339
|
+
const diffImagePath = safePath(this.diffPath, `${sanitizedName}.png`);
|
|
148
340
|
|
|
149
341
|
// Save current screenshot
|
|
150
342
|
writeFileSync(currentImagePath, imageBuffer);
|
|
151
343
|
|
|
152
|
-
// Check if we're in baseline update mode -
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return this.updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath);
|
|
344
|
+
// Check if we're in baseline update mode - treat as first run, no comparisons
|
|
345
|
+
if (this.setBaseline) {
|
|
346
|
+
return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
|
|
156
347
|
}
|
|
157
348
|
|
|
158
349
|
// Check if baseline exists
|
|
159
350
|
if (!existsSync(baselineImagePath)) {
|
|
160
|
-
logger.warn(`ā ļø No baseline found for ${
|
|
351
|
+
logger.warn(`ā ļø No baseline found for ${sanitizedName} - creating baseline`);
|
|
161
352
|
|
|
162
353
|
// Copy current screenshot to baseline directory for future comparisons
|
|
163
354
|
writeFileSync(baselineImagePath, imageBuffer);
|
|
@@ -176,11 +367,11 @@ export class TddService {
|
|
|
176
367
|
|
|
177
368
|
// Add screenshot to baseline metadata
|
|
178
369
|
const screenshotEntry = {
|
|
179
|
-
name,
|
|
180
|
-
properties:
|
|
370
|
+
name: sanitizedName,
|
|
371
|
+
properties: validatedProperties,
|
|
181
372
|
path: baselineImagePath
|
|
182
373
|
};
|
|
183
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name ===
|
|
374
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
|
|
184
375
|
if (existingIndex >= 0) {
|
|
185
376
|
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
186
377
|
} else {
|
|
@@ -190,14 +381,14 @@ export class TddService {
|
|
|
190
381
|
// Save updated metadata
|
|
191
382
|
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
192
383
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
193
|
-
logger.info(`ā
Created baseline for ${
|
|
384
|
+
logger.info(`ā
Created baseline for ${sanitizedName}`);
|
|
194
385
|
const result = {
|
|
195
|
-
name,
|
|
386
|
+
name: sanitizedName,
|
|
196
387
|
status: 'new',
|
|
197
388
|
baseline: baselineImagePath,
|
|
198
389
|
current: currentImagePath,
|
|
199
390
|
diff: null,
|
|
200
|
-
properties
|
|
391
|
+
properties: validatedProperties
|
|
201
392
|
};
|
|
202
393
|
this.comparisons.push(result);
|
|
203
394
|
return result;
|
|
@@ -215,15 +406,15 @@ export class TddService {
|
|
|
215
406
|
if (result.match) {
|
|
216
407
|
// Images match
|
|
217
408
|
const comparison = {
|
|
218
|
-
name,
|
|
409
|
+
name: sanitizedName,
|
|
219
410
|
status: 'passed',
|
|
220
411
|
baseline: baselineImagePath,
|
|
221
412
|
current: currentImagePath,
|
|
222
413
|
diff: null,
|
|
223
|
-
properties,
|
|
414
|
+
properties: validatedProperties,
|
|
224
415
|
threshold: this.threshold
|
|
225
416
|
};
|
|
226
|
-
logger.info(`ā
${colors.green('PASSED')} ${
|
|
417
|
+
logger.info(`ā
${colors.green('PASSED')} ${sanitizedName}`);
|
|
227
418
|
this.comparisons.push(comparison);
|
|
228
419
|
return comparison;
|
|
229
420
|
} else {
|
|
@@ -235,32 +426,32 @@ export class TddService {
|
|
|
235
426
|
diffInfo = ' (layout difference)';
|
|
236
427
|
}
|
|
237
428
|
const comparison = {
|
|
238
|
-
name,
|
|
429
|
+
name: sanitizedName,
|
|
239
430
|
status: 'failed',
|
|
240
431
|
baseline: baselineImagePath,
|
|
241
432
|
current: currentImagePath,
|
|
242
433
|
diff: diffImagePath,
|
|
243
|
-
properties,
|
|
434
|
+
properties: validatedProperties,
|
|
244
435
|
threshold: this.threshold,
|
|
245
436
|
diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
|
|
246
437
|
diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
|
|
247
438
|
reason: result.reason
|
|
248
439
|
};
|
|
249
|
-
logger.warn(`ā ${colors.red('FAILED')} ${
|
|
440
|
+
logger.warn(`ā ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
|
|
250
441
|
logger.info(` Diff saved to: ${diffImagePath}`);
|
|
251
442
|
this.comparisons.push(comparison);
|
|
252
443
|
return comparison;
|
|
253
444
|
}
|
|
254
445
|
} catch (error) {
|
|
255
446
|
// Handle file errors or other issues
|
|
256
|
-
logger.error(`ā Error comparing ${
|
|
447
|
+
logger.error(`ā Error comparing ${sanitizedName}: ${error.message}`);
|
|
257
448
|
const comparison = {
|
|
258
|
-
name,
|
|
449
|
+
name: sanitizedName,
|
|
259
450
|
status: 'error',
|
|
260
451
|
baseline: baselineImagePath,
|
|
261
452
|
current: currentImagePath,
|
|
262
453
|
diff: null,
|
|
263
|
-
properties,
|
|
454
|
+
properties: validatedProperties,
|
|
264
455
|
error: error.message
|
|
265
456
|
};
|
|
266
457
|
this.comparisons.push(comparison);
|
|
@@ -282,7 +473,7 @@ export class TddService {
|
|
|
282
473
|
baseline: this.baselineData
|
|
283
474
|
};
|
|
284
475
|
}
|
|
285
|
-
printResults() {
|
|
476
|
+
async printResults() {
|
|
286
477
|
const results = this.getResults();
|
|
287
478
|
logger.info('\nš TDD Results:');
|
|
288
479
|
logger.info(`Total: ${colors.cyan(results.total)}`);
|
|
@@ -303,9 +494,6 @@ export class TddService {
|
|
|
303
494
|
logger.info('\nā Failed comparisons:');
|
|
304
495
|
failedComparisons.forEach(comp => {
|
|
305
496
|
logger.info(` ⢠${comp.name}`);
|
|
306
|
-
logger.info(` Baseline: ${comp.baseline}`);
|
|
307
|
-
logger.info(` Current: ${comp.current}`);
|
|
308
|
-
logger.info(` Diff: ${comp.diff}`);
|
|
309
497
|
});
|
|
310
498
|
}
|
|
311
499
|
|
|
@@ -315,13 +503,74 @@ export class TddService {
|
|
|
315
503
|
logger.info('\nšø New screenshots:');
|
|
316
504
|
newComparisons.forEach(comp => {
|
|
317
505
|
logger.info(` ⢠${comp.name}`);
|
|
318
|
-
logger.info(` Current: ${comp.current}`);
|
|
319
506
|
});
|
|
320
507
|
}
|
|
321
|
-
|
|
508
|
+
|
|
509
|
+
// Generate HTML report
|
|
510
|
+
await this.generateHtmlReport(results);
|
|
322
511
|
return results;
|
|
323
512
|
}
|
|
324
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Generate HTML report for TDD results
|
|
516
|
+
* @param {Object} results - TDD comparison results
|
|
517
|
+
*/
|
|
518
|
+
async generateHtmlReport(results) {
|
|
519
|
+
try {
|
|
520
|
+
const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
|
|
521
|
+
const reportPath = await reportGenerator.generateReport(results, {
|
|
522
|
+
baseline: this.baselineData,
|
|
523
|
+
threshold: this.threshold
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// Show report path (always clickable)
|
|
527
|
+
logger.info(`\nš» View detailed report: ${colors.cyan('file://' + reportPath)}`);
|
|
528
|
+
|
|
529
|
+
// Auto-open if configured
|
|
530
|
+
if (this.config.tdd?.openReport) {
|
|
531
|
+
await this.openReport(reportPath);
|
|
532
|
+
}
|
|
533
|
+
return reportPath;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
logger.warn(`Failed to generate HTML report: ${error.message}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Open HTML report in default browser
|
|
541
|
+
* @param {string} reportPath - Path to HTML report
|
|
542
|
+
*/
|
|
543
|
+
async openReport(reportPath) {
|
|
544
|
+
try {
|
|
545
|
+
const {
|
|
546
|
+
exec
|
|
547
|
+
} = await import('child_process');
|
|
548
|
+
const {
|
|
549
|
+
promisify
|
|
550
|
+
} = await import('util');
|
|
551
|
+
const execAsync = promisify(exec);
|
|
552
|
+
let command;
|
|
553
|
+
switch (process.platform) {
|
|
554
|
+
case 'darwin':
|
|
555
|
+
// macOS
|
|
556
|
+
command = `open "${reportPath}"`;
|
|
557
|
+
break;
|
|
558
|
+
case 'win32':
|
|
559
|
+
// Windows
|
|
560
|
+
command = `start "" "${reportPath}"`;
|
|
561
|
+
break;
|
|
562
|
+
default:
|
|
563
|
+
// Linux and others
|
|
564
|
+
command = `xdg-open "${reportPath}"`;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
await execAsync(command);
|
|
568
|
+
logger.info('š Report opened in browser');
|
|
569
|
+
} catch (error) {
|
|
570
|
+
logger.debug(`Failed to open report: ${error.message}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
325
574
|
/**
|
|
326
575
|
* Update baselines with current screenshots (accept changes)
|
|
327
576
|
* @returns {number} Number of baselines updated
|
|
@@ -353,7 +602,16 @@ export class TddService {
|
|
|
353
602
|
logger.warn(`Current screenshot not found for ${name}, skipping`);
|
|
354
603
|
continue;
|
|
355
604
|
}
|
|
356
|
-
|
|
605
|
+
|
|
606
|
+
// Sanitize screenshot name for security
|
|
607
|
+
let sanitizedName;
|
|
608
|
+
try {
|
|
609
|
+
sanitizedName = sanitizeScreenshotName(name);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
logger.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
|
|
357
615
|
try {
|
|
358
616
|
// Copy current screenshot to baseline
|
|
359
617
|
const currentBuffer = readFileSync(current);
|
|
@@ -361,20 +619,20 @@ export class TddService {
|
|
|
361
619
|
|
|
362
620
|
// Update baseline metadata
|
|
363
621
|
const screenshotEntry = {
|
|
364
|
-
name,
|
|
365
|
-
properties: comparison.properties || {},
|
|
622
|
+
name: sanitizedName,
|
|
623
|
+
properties: validateScreenshotProperties(comparison.properties || {}),
|
|
366
624
|
path: baselineImagePath
|
|
367
625
|
};
|
|
368
|
-
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name ===
|
|
626
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
|
|
369
627
|
if (existingIndex >= 0) {
|
|
370
628
|
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
371
629
|
} else {
|
|
372
630
|
this.baselineData.screenshots.push(screenshotEntry);
|
|
373
631
|
}
|
|
374
632
|
updatedCount++;
|
|
375
|
-
logger.info(`ā
Updated baseline for ${
|
|
633
|
+
logger.info(`ā
Updated baseline for ${sanitizedName}`);
|
|
376
634
|
} catch (error) {
|
|
377
|
-
logger.error(`ā Failed to update baseline for ${
|
|
635
|
+
logger.error(`ā Failed to update baseline for ${sanitizedName}: ${error.message}`);
|
|
378
636
|
}
|
|
379
637
|
}
|
|
380
638
|
|
|
@@ -391,6 +649,57 @@ export class TddService {
|
|
|
391
649
|
return updatedCount;
|
|
392
650
|
}
|
|
393
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Create a new baseline (used during --set-baseline mode)
|
|
654
|
+
* @private
|
|
655
|
+
*/
|
|
656
|
+
createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
657
|
+
logger.info(`š» Creating baseline for ${name}`);
|
|
658
|
+
|
|
659
|
+
// Copy current screenshot to baseline directory
|
|
660
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
661
|
+
|
|
662
|
+
// Update or create baseline metadata
|
|
663
|
+
if (!this.baselineData) {
|
|
664
|
+
this.baselineData = {
|
|
665
|
+
buildId: 'local-baseline',
|
|
666
|
+
buildName: 'Local TDD Baseline',
|
|
667
|
+
environment: 'test',
|
|
668
|
+
branch: 'local',
|
|
669
|
+
threshold: this.threshold,
|
|
670
|
+
screenshots: []
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Add screenshot to baseline metadata
|
|
675
|
+
const screenshotEntry = {
|
|
676
|
+
name,
|
|
677
|
+
properties: properties || {},
|
|
678
|
+
path: baselineImagePath
|
|
679
|
+
};
|
|
680
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
|
|
681
|
+
if (existingIndex >= 0) {
|
|
682
|
+
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
683
|
+
} else {
|
|
684
|
+
this.baselineData.screenshots.push(screenshotEntry);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Save updated metadata
|
|
688
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
689
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
690
|
+
const result = {
|
|
691
|
+
name,
|
|
692
|
+
status: 'new',
|
|
693
|
+
baseline: baselineImagePath,
|
|
694
|
+
current: currentImagePath,
|
|
695
|
+
diff: null,
|
|
696
|
+
properties
|
|
697
|
+
};
|
|
698
|
+
this.comparisons.push(result);
|
|
699
|
+
logger.info(`ā
Baseline created for ${name}`);
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
|
|
394
703
|
/**
|
|
395
704
|
* Update a single baseline with current screenshot
|
|
396
705
|
* @private
|