@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.
Files changed (33) hide show
  1. package/README.md +55 -9
  2. package/dist/cli.js +15 -2
  3. package/dist/commands/finalize.js +72 -0
  4. package/dist/commands/run.js +59 -19
  5. package/dist/commands/tdd.js +6 -13
  6. package/dist/commands/upload.js +1 -0
  7. package/dist/server/handlers/tdd-handler.js +82 -8
  8. package/dist/services/api-service.js +14 -0
  9. package/dist/services/html-report-generator.js +377 -0
  10. package/dist/services/report-generator/report.css +355 -0
  11. package/dist/services/report-generator/viewer.js +100 -0
  12. package/dist/services/server-manager.js +3 -2
  13. package/dist/services/tdd-service.js +436 -66
  14. package/dist/services/test-runner.js +56 -28
  15. package/dist/services/uploader.js +3 -2
  16. package/dist/types/commands/finalize.d.ts +13 -0
  17. package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
  18. package/dist/types/services/api-service.d.ts +6 -0
  19. package/dist/types/services/html-report-generator.d.ts +52 -0
  20. package/dist/types/services/report-generator/viewer.d.ts +0 -0
  21. package/dist/types/services/server-manager.d.ts +19 -1
  22. package/dist/types/services/tdd-service.d.ts +24 -3
  23. package/dist/types/services/uploader.d.ts +2 -1
  24. package/dist/types/utils/config-loader.d.ts +3 -0
  25. package/dist/types/utils/environment-config.d.ts +5 -0
  26. package/dist/types/utils/security.d.ts +29 -0
  27. package/dist/utils/config-loader.js +11 -1
  28. package/dist/utils/environment-config.js +9 -0
  29. package/dist/utils/security.js +154 -0
  30. package/docs/api-reference.md +27 -0
  31. package/docs/tdd-mode.md +58 -12
  32. package/docs/test-integration.md +69 -0
  33. package/package.json +3 -2
@@ -0,0 +1,377 @@
1
+ /**
2
+ * HTML Report Generator for TDD visual comparison results
3
+ * Creates an interactive report with overlay, toggle, and onion skin modes
4
+ */
5
+
6
+ import { writeFile, mkdir } from 'fs/promises';
7
+ import { existsSync } from 'fs';
8
+ import { join, relative, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { createServiceLogger } from '../utils/logger-factory.js';
11
+ const logger = createServiceLogger('HTML-REPORT');
12
+ export class HtmlReportGenerator {
13
+ constructor(workingDir, config) {
14
+ this.workingDir = workingDir;
15
+ this.config = config;
16
+ this.reportDir = join(workingDir, '.vizzly', 'report');
17
+ this.reportPath = join(this.reportDir, 'index.html');
18
+
19
+ // Get path to the CSS file that ships with the package
20
+ let __filename = fileURLToPath(import.meta.url);
21
+ let __dirname = dirname(__filename);
22
+ this.cssPath = join(__dirname, 'report-generator', 'report.css');
23
+ }
24
+
25
+ /**
26
+ * Sanitize HTML content to prevent XSS attacks
27
+ * @param {string} text - Text to sanitize
28
+ * @returns {string} Sanitized text
29
+ */
30
+ sanitizeHtml(text) {
31
+ if (typeof text !== 'string') return '';
32
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
33
+ }
34
+
35
+ /**
36
+ * Sanitize build info object
37
+ * @param {Object} buildInfo - Build information to sanitize
38
+ * @returns {Object} Sanitized build info
39
+ */
40
+ sanitizeBuildInfo(buildInfo = {}) {
41
+ let sanitized = {};
42
+ if (buildInfo.baseline && typeof buildInfo.baseline === 'object') {
43
+ sanitized.baseline = {
44
+ buildId: this.sanitizeHtml(buildInfo.baseline.buildId || ''),
45
+ buildName: this.sanitizeHtml(buildInfo.baseline.buildName || ''),
46
+ environment: this.sanitizeHtml(buildInfo.baseline.environment || ''),
47
+ branch: this.sanitizeHtml(buildInfo.baseline.branch || '')
48
+ };
49
+ }
50
+ if (typeof buildInfo.threshold === 'number') {
51
+ sanitized.threshold = Math.max(0, Math.min(1, buildInfo.threshold));
52
+ }
53
+ return sanitized;
54
+ }
55
+
56
+ /**
57
+ * Generate HTML report from TDD results
58
+ * @param {Object} results - TDD comparison results
59
+ * @param {Object} buildInfo - Build information
60
+ * @returns {string} Path to generated report
61
+ */
62
+ async generateReport(results, buildInfo = {}) {
63
+ // Validate inputs
64
+ if (!results || typeof results !== 'object') {
65
+ throw new Error('Invalid results object provided');
66
+ }
67
+ const {
68
+ comparisons = [],
69
+ passed = 0,
70
+ failed = 0,
71
+ total = 0
72
+ } = results;
73
+
74
+ // Filter only failed comparisons for the report
75
+ const failedComparisons = comparisons.filter(comp => comp && comp.status === 'failed');
76
+ const reportData = {
77
+ buildInfo: {
78
+ timestamp: new Date().toISOString(),
79
+ ...this.sanitizeBuildInfo(buildInfo)
80
+ },
81
+ summary: {
82
+ total,
83
+ passed,
84
+ failed,
85
+ passRate: total > 0 ? (passed / total * 100).toFixed(1) : '0.0'
86
+ },
87
+ comparisons: failedComparisons.map(comp => this.processComparison(comp)).filter(Boolean)
88
+ };
89
+ const htmlContent = this.generateHtmlTemplate(reportData);
90
+ try {
91
+ // Ensure report directory exists
92
+ await mkdir(this.reportDir, {
93
+ recursive: true
94
+ });
95
+ await writeFile(this.reportPath, htmlContent, 'utf8');
96
+ logger.debug(`HTML report generated: ${this.reportPath}`);
97
+ return this.reportPath;
98
+ } catch (error) {
99
+ logger.error(`Failed to generate HTML report: ${error.message}`);
100
+ throw new Error(`Report generation failed: ${error.message}`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Process comparison data for HTML report
106
+ * @param {Object} comparison - Comparison object
107
+ * @returns {Object} Processed comparison data
108
+ */
109
+ processComparison(comparison) {
110
+ if (!comparison || typeof comparison !== 'object') {
111
+ logger.warn('Invalid comparison object provided');
112
+ return null;
113
+ }
114
+ return {
115
+ name: comparison.name || 'unnamed',
116
+ status: comparison.status,
117
+ baseline: this.getRelativePath(comparison.baseline, this.reportDir),
118
+ current: this.getRelativePath(comparison.current, this.reportDir),
119
+ diff: this.getRelativePath(comparison.diff, this.reportDir),
120
+ threshold: comparison.threshold || 0,
121
+ diffPercentage: comparison.diffPercentage || 0
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Get relative path from report directory to image file
127
+ * @param {string} imagePath - Absolute path to image
128
+ * @param {string} reportDir - Report directory path
129
+ * @returns {string|null} Relative path or null if invalid
130
+ */
131
+ getRelativePath(imagePath, reportDir) {
132
+ if (!imagePath || !existsSync(imagePath)) {
133
+ return null;
134
+ }
135
+ return relative(reportDir, imagePath);
136
+ }
137
+
138
+ /**
139
+ * Generate the complete HTML template
140
+ * @param {Object} data - Report data
141
+ * @returns {string} HTML content
142
+ */
143
+ generateHtmlTemplate(data) {
144
+ return `<!DOCTYPE html>
145
+ <html lang="en">
146
+ <head>
147
+ <meta charset="UTF-8">
148
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
149
+ <title>Vizzly TDD Report</title>
150
+ <link rel="stylesheet" href="file://${this.cssPath}">
151
+ </head>
152
+ <body>
153
+ <div class="container">
154
+ <header class="header">
155
+ <h1>🐻 Vizzly Visual Testing Report</h1>
156
+ <div class="summary">
157
+ <div class="stat">
158
+ <span class="stat-number">${data.summary.total}</span>
159
+ <span class="stat-label">Total</span>
160
+ </div>
161
+ <div class="stat passed">
162
+ <span class="stat-number">${data.summary.passed}</span>
163
+ <span class="stat-label">Passed</span>
164
+ </div>
165
+ <div class="stat failed">
166
+ <span class="stat-number">${data.summary.failed}</span>
167
+ <span class="stat-label">Failed</span>
168
+ </div>
169
+ <div class="stat">
170
+ <span class="stat-number">${data.summary.passRate}%</span>
171
+ <span class="stat-label">Pass Rate</span>
172
+ </div>
173
+ </div>
174
+ <div class="build-info">
175
+ <span>Generated: ${new Date(data.buildInfo.timestamp).toLocaleString()}</span>
176
+ </div>
177
+ </header>
178
+
179
+ ${data.comparisons.length === 0 ? '<div class="no-failures">🎉 All tests passed! No visual differences detected.</div>' : `<main class="comparisons">
180
+ ${data.comparisons.map(comp => this.generateComparisonHtml(comp)).join('')}
181
+ </main>`}
182
+ </div>
183
+
184
+ <script>
185
+ document.addEventListener('DOMContentLoaded', function () {
186
+ // Handle view mode switching
187
+ document.querySelectorAll('.view-mode-btn').forEach(btn => {
188
+ btn.addEventListener('click', function () {
189
+ let comparison = this.closest('.comparison');
190
+ let mode = this.dataset.mode;
191
+
192
+ // Update active button
193
+ comparison
194
+ .querySelectorAll('.view-mode-btn')
195
+ .forEach(b => b.classList.remove('active'));
196
+ this.classList.add('active');
197
+
198
+ // Update viewer mode
199
+ let viewer = comparison.querySelector('.comparison-viewer');
200
+ viewer.dataset.mode = mode;
201
+
202
+ // Hide all mode containers
203
+ viewer.querySelectorAll('.mode-container').forEach(container => {
204
+ container.style.display = 'none';
205
+ });
206
+
207
+ // Show appropriate mode container
208
+ let activeContainer = viewer.querySelector('.' + mode + '-mode');
209
+ if (activeContainer) {
210
+ activeContainer.style.display = 'block';
211
+ }
212
+ });
213
+ });
214
+
215
+ // Handle onion skin drag-to-reveal
216
+ document.querySelectorAll('.onion-container').forEach(container => {
217
+ let isDragging = false;
218
+
219
+ function updateOnionSkin(x) {
220
+ let rect = container.getBoundingClientRect();
221
+ let percentage = Math.max(
222
+ 0,
223
+ Math.min(100, ((x - rect.left) / rect.width) * 100)
224
+ );
225
+
226
+ let currentImg = container.querySelector('.onion-current');
227
+ let divider = container.querySelector('.onion-divider');
228
+
229
+ if (currentImg && divider) {
230
+ currentImg.style.clipPath = 'inset(0 ' + (100 - percentage) + '% 0 0)';
231
+ divider.style.left = percentage + '%';
232
+ }
233
+ }
234
+
235
+ container.addEventListener('mousedown', function (e) {
236
+ isDragging = true;
237
+ updateOnionSkin(e.clientX);
238
+ e.preventDefault();
239
+ });
240
+
241
+ container.addEventListener('mousemove', function (e) {
242
+ if (isDragging) {
243
+ updateOnionSkin(e.clientX);
244
+ }
245
+ });
246
+
247
+ document.addEventListener('mouseup', function () {
248
+ isDragging = false;
249
+ });
250
+
251
+ // Touch events for mobile
252
+ container.addEventListener('touchstart', function (e) {
253
+ isDragging = true;
254
+ updateOnionSkin(e.touches[0].clientX);
255
+ e.preventDefault();
256
+ });
257
+
258
+ container.addEventListener('touchmove', function (e) {
259
+ if (isDragging) {
260
+ updateOnionSkin(e.touches[0].clientX);
261
+ e.preventDefault();
262
+ }
263
+ });
264
+
265
+ document.addEventListener('touchend', function () {
266
+ isDragging = false;
267
+ });
268
+ });
269
+
270
+ // Handle overlay mode clicking
271
+ document.querySelectorAll('.overlay-container').forEach(container => {
272
+ container.addEventListener('click', function () {
273
+ let diffImage = this.querySelector('.diff-image');
274
+ if (diffImage) {
275
+ // Toggle diff visibility
276
+ let isVisible = diffImage.style.opacity === '1';
277
+ diffImage.style.opacity = isVisible ? '0' : '1';
278
+ }
279
+ });
280
+ });
281
+
282
+ // Handle toggle mode clicking
283
+ document.querySelectorAll('.toggle-container img').forEach(img => {
284
+ let isBaseline = true;
285
+ let comparison = img.closest('.comparison');
286
+ let baselineSrc = comparison.querySelector('.baseline-image').src;
287
+ let currentSrc = comparison.querySelector('.current-image').src;
288
+
289
+ img.addEventListener('click', function () {
290
+ isBaseline = !isBaseline;
291
+ this.src = isBaseline ? baselineSrc : currentSrc;
292
+
293
+ // Update cursor style to indicate interactivity
294
+ this.style.cursor = 'pointer';
295
+ });
296
+ });
297
+
298
+ console.log('Vizzly TDD Report loaded successfully');
299
+ });
300
+ </script>
301
+ </body>
302
+ </html>`;
303
+ }
304
+
305
+ /**
306
+ * Generate HTML for a single comparison
307
+ * @param {Object} comparison - Comparison data
308
+ * @returns {string} HTML content
309
+ */
310
+ generateComparisonHtml(comparison) {
311
+ if (!comparison || !comparison.baseline || !comparison.current || !comparison.diff) {
312
+ return `<div class="comparison error">
313
+ <h3>${this.sanitizeHtml(comparison?.name || 'Unknown')}</h3>
314
+ <p>Missing comparison images</p>
315
+ </div>`;
316
+ }
317
+ let safeName = this.sanitizeHtml(comparison.name);
318
+ return `
319
+ <div class="comparison" data-comparison="${safeName}">
320
+ <div class="comparison-header">
321
+ <h3>${safeName}</h3>
322
+ <div class="comparison-meta">
323
+ <span class="diff-status">Visual differences detected</span>
324
+ </div>
325
+ </div>
326
+
327
+ <div class="comparison-controls">
328
+ <button class="view-mode-btn active" data-mode="overlay">Overlay</button>
329
+ <button class="view-mode-btn" data-mode="toggle">Toggle</button>
330
+ <button class="view-mode-btn" data-mode="onion">Onion Skin</button>
331
+ <button class="view-mode-btn" data-mode="side-by-side">Side by Side</button>
332
+ </div>
333
+
334
+ <div class="comparison-viewer">
335
+ <!-- Overlay Mode -->
336
+ <div class="mode-container overlay-mode" data-mode="overlay">
337
+ <div class="overlay-container">
338
+ <img class="current-image" src="${comparison.current}" alt="Current" />
339
+ <img class="baseline-image" src="${comparison.baseline}" alt="Baseline" />
340
+ <img class="diff-image" src="${comparison.diff}" alt="Diff" />
341
+ </div>
342
+ </div>
343
+
344
+ <!-- Toggle Mode -->
345
+ <div class="mode-container toggle-mode" data-mode="toggle" style="display: none;">
346
+ <div class="toggle-container">
347
+ <img class="toggle-image" src="${comparison.baseline}" alt="Baseline" />
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Onion Skin Mode -->
352
+ <div class="mode-container onion-mode" data-mode="onion" style="display: none;">
353
+ <div class="onion-container">
354
+ <img class="onion-baseline" src="${comparison.baseline}" alt="Baseline" />
355
+ <img class="onion-current" src="${comparison.current}" alt="Current" />
356
+ <div class="onion-divider"></div>
357
+ </div>
358
+ </div>
359
+
360
+ <!-- Side by Side Mode -->
361
+ <div class="mode-container side-by-side-mode" data-mode="side-by-side" style="display: none;">
362
+ <div class="side-by-side-container">
363
+ <div class="side-by-side-image">
364
+ <img src="${comparison.baseline}" alt="Baseline" />
365
+ <label>Baseline</label>
366
+ </div>
367
+ <div class="side-by-side-image">
368
+ <img src="${comparison.current}" alt="Current" />
369
+ <label>Current</label>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ </div>`;
376
+ }
377
+ }