@vizzly-testing/cli 0.17.0 ā 0.19.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/cli.js +87 -59
- package/dist/client/index.js +6 -6
- package/dist/commands/doctor.js +15 -15
- package/dist/commands/finalize.js +7 -7
- package/dist/commands/init.js +28 -28
- package/dist/commands/login.js +23 -23
- package/dist/commands/logout.js +4 -4
- package/dist/commands/project.js +36 -36
- package/dist/commands/run.js +33 -33
- package/dist/commands/status.js +14 -14
- package/dist/commands/tdd-daemon.js +43 -43
- package/dist/commands/tdd.js +26 -26
- package/dist/commands/upload.js +32 -32
- package/dist/commands/whoami.js +12 -12
- package/dist/index.js +9 -14
- package/dist/plugin-api.js +43 -0
- package/dist/plugin-loader.js +28 -28
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +19 -19
- package/dist/sdk/index.js +33 -35
- package/dist/server/handlers/api-handler.js +4 -4
- package/dist/server/handlers/tdd-handler.js +22 -21
- package/dist/server/http-server.js +21 -22
- package/dist/server/middleware/json-parser.js +1 -1
- package/dist/server/routers/assets.js +14 -14
- package/dist/server/routers/auth.js +14 -14
- package/dist/server/routers/baseline.js +8 -8
- package/dist/server/routers/cloud-proxy.js +15 -15
- package/dist/server/routers/config.js +11 -11
- package/dist/server/routers/dashboard.js +11 -11
- package/dist/server/routers/health.js +4 -4
- package/dist/server/routers/projects.js +19 -19
- package/dist/server/routers/screenshot.js +9 -9
- package/dist/services/api-service.js +16 -16
- package/dist/services/auth-service.js +17 -17
- package/dist/services/build-manager.js +3 -3
- package/dist/services/config-service.js +32 -32
- package/dist/services/html-report-generator.js +8 -8
- package/dist/services/index.js +11 -11
- package/dist/services/project-service.js +19 -19
- package/dist/services/report-generator/report.css +3 -3
- package/dist/services/report-generator/viewer.js +25 -23
- package/dist/services/screenshot-server.js +1 -1
- package/dist/services/server-manager.js +5 -5
- package/dist/services/static-report-generator.js +14 -14
- package/dist/services/tdd-service.js +152 -110
- package/dist/services/test-runner.js +3 -3
- package/dist/services/uploader.js +10 -8
- package/dist/types/config.d.ts +2 -1
- package/dist/types/index.d.ts +95 -1
- package/dist/types/sdk.d.ts +1 -1
- package/dist/utils/browser.js +3 -3
- package/dist/utils/build-history.js +12 -12
- package/dist/utils/config-loader.js +17 -17
- package/dist/utils/config-schema.js +6 -6
- package/dist/utils/environment-config.js +11 -0
- package/dist/utils/fetch-utils.js +2 -2
- package/dist/utils/file-helpers.js +2 -2
- package/dist/utils/git.js +3 -6
- package/dist/utils/global-config.js +28 -25
- package/dist/utils/output.js +136 -28
- package/dist/utils/package-info.js +3 -3
- package/dist/utils/security.js +12 -12
- package/docs/api-reference.md +52 -23
- package/docs/plugins.md +60 -25
- package/package.json +9 -13
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Generates a self-contained HTML file with the React dashboard and embedded data
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { fileURLToPath } from 'url';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import * as output from '../utils/output.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
const PROJECT_ROOT = join(__dirname, '..', '..');
|
|
14
14
|
export class StaticReportGenerator {
|
|
15
15
|
constructor(workingDir, config) {
|
|
16
16
|
this.workingDir = workingDir;
|
|
@@ -35,8 +35,8 @@ export class StaticReportGenerator {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
// Copy React bundles to report directory
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const bundlePath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.iife.js');
|
|
39
|
+
const cssPath = join(PROJECT_ROOT, 'dist', 'reporter', 'reporter-bundle.css');
|
|
40
40
|
if (!existsSync(bundlePath) || !existsSync(cssPath)) {
|
|
41
41
|
throw new Error('Reporter bundles not found. Run "npm run build:reporter" first.');
|
|
42
42
|
}
|
|
@@ -46,7 +46,7 @@ export class StaticReportGenerator {
|
|
|
46
46
|
await copyFile(cssPath, join(this.reportDir, 'reporter-bundle.css'));
|
|
47
47
|
|
|
48
48
|
// Generate HTML with embedded data
|
|
49
|
-
|
|
49
|
+
const htmlContent = this.generateHtmlTemplate(reportData);
|
|
50
50
|
await writeFile(this.reportPath, htmlContent, 'utf8');
|
|
51
51
|
output.debug('report', 'generated static report');
|
|
52
52
|
return this.reportPath;
|
|
@@ -63,7 +63,7 @@ export class StaticReportGenerator {
|
|
|
63
63
|
*/
|
|
64
64
|
generateHtmlTemplate(reportData) {
|
|
65
65
|
// Serialize report data safely
|
|
66
|
-
|
|
66
|
+
const serializedData = JSON.stringify(reportData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
67
67
|
return `<!DOCTYPE html>
|
|
68
68
|
<html lang="en">
|
|
69
69
|
<head>
|
|
@@ -127,9 +127,9 @@ export class StaticReportGenerator {
|
|
|
127
127
|
* @returns {string} Minimal HTML content
|
|
128
128
|
*/
|
|
129
129
|
generateFallbackHtml(reportData) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
const summary = reportData.summary || {};
|
|
131
|
+
const comparisons = reportData.comparisons || [];
|
|
132
|
+
const failed = comparisons.filter(c => c.status === 'failed');
|
|
133
133
|
return `<!DOCTYPE html>
|
|
134
134
|
<html lang="en">
|
|
135
135
|
<head>
|
|
@@ -1,25 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* TDD Service - Local Visual Testing
|
|
3
|
+
*
|
|
4
|
+
* ā ļø CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
|
|
5
|
+
*
|
|
6
|
+
* Cloud counterpart: vizzly/src/utils/screenshot-identity.js
|
|
7
|
+
* - generateScreenshotSignature()
|
|
8
|
+
* - generateBaselineFilename()
|
|
9
|
+
*
|
|
10
|
+
* Contract tests: Both repos have golden tests that must produce identical values:
|
|
11
|
+
* - Cloud: tests/contracts/signature-parity.test.js
|
|
12
|
+
* - CLI: tests/contracts/signature-parity.spec.js
|
|
13
|
+
*
|
|
14
|
+
* If you modify signature or filename generation here, you MUST:
|
|
15
|
+
* 1. Make the same change in the cloud repo
|
|
16
|
+
* 2. Update golden test values in BOTH repos
|
|
17
|
+
* 3. Run contract tests in both repos to verify parity
|
|
18
|
+
*
|
|
19
|
+
* The signature format is: name|viewport_width|browser|custom1|custom2|...
|
|
20
|
+
* The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
3
26
|
import { compare } from '@vizzly-testing/honeydiff';
|
|
4
|
-
import
|
|
27
|
+
import { NetworkError } from '../errors/vizzly-error.js';
|
|
5
28
|
import { ApiService } from '../services/api-service.js';
|
|
6
|
-
import * as output from '../utils/output.js';
|
|
7
29
|
import { colors } from '../utils/colors.js';
|
|
8
|
-
import { getDefaultBranch } from '../utils/git.js';
|
|
9
30
|
import { fetchWithTimeout } from '../utils/fetch-utils.js';
|
|
10
|
-
import {
|
|
31
|
+
import { getDefaultBranch } from '../utils/git.js';
|
|
32
|
+
import * as output from '../utils/output.js';
|
|
33
|
+
import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
|
|
11
34
|
import { HtmlReportGenerator } from './html-report-generator.js';
|
|
12
|
-
import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
|
|
13
35
|
|
|
14
36
|
/**
|
|
15
37
|
* Generate a screenshot signature for baseline matching
|
|
16
|
-
* Uses same logic as screenshot-identity.js: name + viewport_width + browser + custom properties
|
|
17
38
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - screenshot.browser (top-level property)
|
|
22
|
-
* - custom properties from project's baseline_signature_properties setting
|
|
39
|
+
* ā ļø SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
|
|
40
|
+
*
|
|
41
|
+
* Uses same logic as cloud: name + viewport_width + browser + custom properties
|
|
23
42
|
*
|
|
24
43
|
* @param {string} name - Screenshot name
|
|
25
44
|
* @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
|
|
@@ -27,49 +46,58 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
|
|
|
27
46
|
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
|
|
28
47
|
*/
|
|
29
48
|
function generateScreenshotSignature(name, properties = {}, customProperties = []) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
// Match cloud screenshot-identity.js behavior exactly:
|
|
50
|
+
// Always include all default properties (name, viewport_width, browser)
|
|
51
|
+
// even if null/undefined, using empty string as placeholder
|
|
52
|
+
const defaultProperties = ['name', 'viewport_width', 'browser'];
|
|
53
|
+
const allProperties = [...defaultProperties, ...customProperties];
|
|
54
|
+
const parts = allProperties.map(propName => {
|
|
55
|
+
let value;
|
|
56
|
+
if (propName === 'name') {
|
|
57
|
+
value = name;
|
|
58
|
+
} else if (propName === 'viewport_width') {
|
|
59
|
+
// Check for viewport_width as top-level property first (backend format)
|
|
60
|
+
value = properties.viewport_width;
|
|
61
|
+
// Fallback to nested viewport.width (SDK format)
|
|
62
|
+
if (value === null || value === undefined) {
|
|
63
|
+
value = properties.viewport?.width;
|
|
64
|
+
}
|
|
65
|
+
} else if (propName === 'browser') {
|
|
66
|
+
value = properties.browser;
|
|
67
|
+
} else {
|
|
68
|
+
// Custom property - check multiple locations
|
|
69
|
+
value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
|
|
70
|
+
}
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// 2. In metadata object (e.g., properties.metadata.device)
|
|
55
|
-
// 3. In nested metadata.properties (e.g., properties.metadata.properties.device)
|
|
56
|
-
let value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName] ?? '';
|
|
72
|
+
// Handle null/undefined values consistently (match cloud behavior)
|
|
73
|
+
if (value === null || value === undefined) {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
57
76
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
}
|
|
77
|
+
// Convert to string and normalize
|
|
78
|
+
return String(value).trim();
|
|
79
|
+
});
|
|
61
80
|
return parts.join('|');
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
84
|
+
* Generate a stable, filesystem-safe filename for a screenshot baseline
|
|
85
|
+
* Uses a hash of the signature to avoid character encoding issues
|
|
86
|
+
* Matches the cloud's generateBaselineFilename implementation exactly
|
|
87
|
+
*
|
|
88
|
+
* @param {string} name - Screenshot name
|
|
89
|
+
* @param {string} signature - Full signature string
|
|
90
|
+
* @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
|
|
67
91
|
*/
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.replace(/
|
|
92
|
+
function generateBaselineFilename(name, signature) {
|
|
93
|
+
const hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
|
|
94
|
+
|
|
95
|
+
// Sanitize the name for filesystem safety
|
|
96
|
+
const safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
|
|
97
|
+
.replace(/\s+/g, '-') // Spaces to hyphens
|
|
98
|
+
.slice(0, 50); // Limit length
|
|
99
|
+
|
|
100
|
+
return `${safeName}_${hash}.png`;
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
/**
|
|
@@ -113,6 +141,7 @@ export class TddService {
|
|
|
113
141
|
this.baselineData = null;
|
|
114
142
|
this.comparisons = [];
|
|
115
143
|
this.threshold = config.comparison?.threshold || 2.0;
|
|
144
|
+
this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
|
|
116
145
|
this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
|
|
117
146
|
|
|
118
147
|
// Check if we're in baseline update mode
|
|
@@ -196,7 +225,7 @@ export class TddService {
|
|
|
196
225
|
}
|
|
197
226
|
|
|
198
227
|
// The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url
|
|
199
|
-
|
|
228
|
+
const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
|
|
200
229
|
if (!baselineUrl) {
|
|
201
230
|
throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
|
|
202
231
|
}
|
|
@@ -205,7 +234,7 @@ export class TddService {
|
|
|
205
234
|
// The baseline should use the same properties (viewport/browser) as the current screenshot
|
|
206
235
|
// so that generateScreenshotSignature produces the correct filename
|
|
207
236
|
// Use current screenshot properties since we're downloading baseline to compare against current
|
|
208
|
-
|
|
237
|
+
const screenshotProperties = {};
|
|
209
238
|
|
|
210
239
|
// Build properties from comparison API fields (added in backend update)
|
|
211
240
|
// Use current_* fields since we're matching against the current screenshot being tested
|
|
@@ -312,14 +341,17 @@ export class TddService {
|
|
|
312
341
|
// Generate signature for baseline matching (same as compareScreenshot)
|
|
313
342
|
// Build properties object with top-level viewport_width and browser
|
|
314
343
|
// These are returned as top-level fields from the API, not inside metadata
|
|
315
|
-
|
|
344
|
+
const properties = validateScreenshotProperties({
|
|
316
345
|
viewport_width: screenshot.viewport_width,
|
|
317
346
|
browser: screenshot.browser,
|
|
318
347
|
...(screenshot.metadata || screenshot.properties || {})
|
|
319
348
|
});
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
349
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
350
|
+
|
|
351
|
+
// Use API-provided filename if available, otherwise generate hash-based filename
|
|
352
|
+
// Both return the full filename with .png extension
|
|
353
|
+
const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
|
|
354
|
+
const imagePath = safePath(this.baselinePath, filename);
|
|
323
355
|
|
|
324
356
|
// Check if we already have this file with the same SHA (using metadata)
|
|
325
357
|
if (existsSync(imagePath) && screenshot.sha256) {
|
|
@@ -437,13 +469,13 @@ export class TddService {
|
|
|
437
469
|
|
|
438
470
|
// Build properties object with top-level viewport_width and browser
|
|
439
471
|
// These are returned as top-level fields from the API, not inside metadata
|
|
440
|
-
|
|
472
|
+
const properties = validateScreenshotProperties({
|
|
441
473
|
viewport_width: s.viewport_width,
|
|
442
474
|
browser: s.browser,
|
|
443
475
|
...(s.metadata || s.properties || {})
|
|
444
476
|
});
|
|
445
|
-
|
|
446
|
-
|
|
477
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
478
|
+
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
447
479
|
return {
|
|
448
480
|
name: sanitizedName,
|
|
449
481
|
originalName: s.name,
|
|
@@ -451,7 +483,7 @@ export class TddService {
|
|
|
451
483
|
// Store remote SHA for quick comparison
|
|
452
484
|
id: s.id,
|
|
453
485
|
properties: properties,
|
|
454
|
-
path: safePath(this.baselinePath,
|
|
486
|
+
path: safePath(this.baselinePath, filename),
|
|
455
487
|
signature: signature,
|
|
456
488
|
originalUrl: s.original_url,
|
|
457
489
|
fileSize: s.file_size_bytes,
|
|
@@ -521,14 +553,14 @@ export class TddService {
|
|
|
521
553
|
}
|
|
522
554
|
try {
|
|
523
555
|
// Get unique screenshot names
|
|
524
|
-
|
|
556
|
+
const screenshotNames = [...new Set(screenshots.map(s => s.name))];
|
|
525
557
|
if (screenshotNames.length === 0) {
|
|
526
558
|
return;
|
|
527
559
|
}
|
|
528
560
|
output.info(`š„ Fetching hotspot data for ${screenshotNames.length} screenshots...`);
|
|
529
561
|
|
|
530
562
|
// Use batch endpoint for efficiency
|
|
531
|
-
|
|
563
|
+
const response = await this.api.getBatchHotspots(screenshotNames);
|
|
532
564
|
if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
|
|
533
565
|
output.debug('tdd', 'No hotspot data available from cloud');
|
|
534
566
|
return;
|
|
@@ -536,14 +568,14 @@ export class TddService {
|
|
|
536
568
|
|
|
537
569
|
// Store hotspots in a separate file for easy access during comparisons
|
|
538
570
|
this.hotspotData = response.hotspots;
|
|
539
|
-
|
|
571
|
+
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
540
572
|
writeFileSync(hotspotsPath, JSON.stringify({
|
|
541
573
|
downloadedAt: new Date().toISOString(),
|
|
542
574
|
summary: response.summary,
|
|
543
575
|
hotspots: response.hotspots
|
|
544
576
|
}, null, 2));
|
|
545
|
-
|
|
546
|
-
|
|
577
|
+
const hotspotCount = Object.keys(response.hotspots).length;
|
|
578
|
+
const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
|
|
547
579
|
output.info(`ā
Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
|
|
548
580
|
} catch (error) {
|
|
549
581
|
// Don't fail baseline download if hotspot fetch fails
|
|
@@ -558,11 +590,11 @@ export class TddService {
|
|
|
558
590
|
*/
|
|
559
591
|
loadHotspots() {
|
|
560
592
|
try {
|
|
561
|
-
|
|
593
|
+
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
562
594
|
if (!existsSync(hotspotsPath)) {
|
|
563
595
|
return null;
|
|
564
596
|
}
|
|
565
|
-
|
|
597
|
+
const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
|
|
566
598
|
return data.hotspots || null;
|
|
567
599
|
} catch (error) {
|
|
568
600
|
output.debug('tdd', `Failed to load hotspots: ${error.message}`);
|
|
@@ -577,7 +609,7 @@ export class TddService {
|
|
|
577
609
|
*/
|
|
578
610
|
getHotspotForScreenshot(screenshotName) {
|
|
579
611
|
// Check memory cache first
|
|
580
|
-
if (this.hotspotData
|
|
612
|
+
if (this.hotspotData?.[screenshotName]) {
|
|
581
613
|
return this.hotspotData[screenshotName];
|
|
582
614
|
}
|
|
583
615
|
|
|
@@ -614,9 +646,9 @@ export class TddService {
|
|
|
614
646
|
// Extract Y-coordinates (diff lines) from clusters
|
|
615
647
|
// Each cluster has a boundingBox with y and height
|
|
616
648
|
let diffLines = [];
|
|
617
|
-
for (
|
|
649
|
+
for (const cluster of diffClusters) {
|
|
618
650
|
if (cluster.boundingBox) {
|
|
619
|
-
|
|
651
|
+
const {
|
|
620
652
|
y,
|
|
621
653
|
height
|
|
622
654
|
} = cluster.boundingBox;
|
|
@@ -639,13 +671,13 @@ export class TddService {
|
|
|
639
671
|
|
|
640
672
|
// Check how many diff lines fall within hotspot regions
|
|
641
673
|
let linesInHotspots = 0;
|
|
642
|
-
for (
|
|
643
|
-
|
|
674
|
+
for (const line of diffLines) {
|
|
675
|
+
const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
|
|
644
676
|
if (inHotspot) {
|
|
645
677
|
linesInHotspots++;
|
|
646
678
|
}
|
|
647
679
|
}
|
|
648
|
-
|
|
680
|
+
const coverage = linesInHotspots / diffLines.length;
|
|
649
681
|
return {
|
|
650
682
|
coverage,
|
|
651
683
|
linesInHotspots,
|
|
@@ -666,14 +698,14 @@ export class TddService {
|
|
|
666
698
|
output.info(`Downloading baselines using OAuth from build ${buildId}...`);
|
|
667
699
|
try {
|
|
668
700
|
// Fetch build with screenshots via OAuth endpoint
|
|
669
|
-
|
|
670
|
-
|
|
701
|
+
const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
|
|
702
|
+
const response = await authService.authenticatedRequest(endpoint, {
|
|
671
703
|
method: 'GET',
|
|
672
704
|
headers: {
|
|
673
705
|
'X-Organization': organizationSlug
|
|
674
706
|
}
|
|
675
707
|
});
|
|
676
|
-
|
|
708
|
+
const {
|
|
677
709
|
build,
|
|
678
710
|
screenshots,
|
|
679
711
|
signatureProperties
|
|
@@ -698,8 +730,8 @@ export class TddService {
|
|
|
698
730
|
output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
|
|
699
731
|
|
|
700
732
|
// Load existing baseline metadata for SHA comparison
|
|
701
|
-
|
|
702
|
-
|
|
733
|
+
const existingBaseline = await this.loadBaseline();
|
|
734
|
+
const existingShaMap = new Map();
|
|
703
735
|
if (existingBaseline) {
|
|
704
736
|
existingBaseline.screenshots.forEach(s => {
|
|
705
737
|
if (s.sha256 && s.signature) {
|
|
@@ -712,8 +744,8 @@ export class TddService {
|
|
|
712
744
|
let downloadedCount = 0;
|
|
713
745
|
let skippedCount = 0;
|
|
714
746
|
let errorCount = 0;
|
|
715
|
-
|
|
716
|
-
for (
|
|
747
|
+
const downloadedScreenshots = [];
|
|
748
|
+
for (const screenshot of screenshots) {
|
|
717
749
|
let sanitizedName;
|
|
718
750
|
try {
|
|
719
751
|
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
@@ -725,14 +757,15 @@ export class TddService {
|
|
|
725
757
|
|
|
726
758
|
// Build properties object with top-level viewport_width and browser
|
|
727
759
|
// These are returned as top-level fields from the API, not inside metadata
|
|
728
|
-
|
|
760
|
+
const properties = validateScreenshotProperties({
|
|
729
761
|
viewport_width: screenshot.viewport_width,
|
|
730
762
|
browser: screenshot.browser,
|
|
731
763
|
...screenshot.metadata
|
|
732
764
|
});
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
765
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
766
|
+
// Use API-provided filename if available, otherwise generate hash-based filename
|
|
767
|
+
const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
|
|
768
|
+
const filePath = safePath(this.baselinePath, filename);
|
|
736
769
|
|
|
737
770
|
// Check if we can skip via SHA comparison
|
|
738
771
|
if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
|
|
@@ -748,21 +781,21 @@ export class TddService {
|
|
|
748
781
|
}
|
|
749
782
|
|
|
750
783
|
// Download the screenshot
|
|
751
|
-
|
|
784
|
+
const downloadUrl = screenshot.original_url;
|
|
752
785
|
if (!downloadUrl) {
|
|
753
786
|
output.warn(`ā ļø No download URL for screenshot: ${sanitizedName}`);
|
|
754
787
|
errorCount++;
|
|
755
788
|
continue;
|
|
756
789
|
}
|
|
757
790
|
try {
|
|
758
|
-
|
|
791
|
+
const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
|
|
759
792
|
if (!imageResponse.ok) {
|
|
760
793
|
throw new Error(`HTTP ${imageResponse.status}`);
|
|
761
794
|
}
|
|
762
|
-
|
|
795
|
+
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
763
796
|
|
|
764
797
|
// Calculate SHA256 of downloaded content
|
|
765
|
-
|
|
798
|
+
const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
|
|
766
799
|
writeFileSync(filePath, imageBuffer);
|
|
767
800
|
downloadedCount++;
|
|
768
801
|
downloadedScreenshots.push({
|
|
@@ -789,11 +822,11 @@ export class TddService {
|
|
|
789
822
|
// Store for TDD comparison
|
|
790
823
|
screenshots: downloadedScreenshots
|
|
791
824
|
};
|
|
792
|
-
|
|
825
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
793
826
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
794
827
|
|
|
795
828
|
// Save baseline build metadata
|
|
796
|
-
|
|
829
|
+
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
797
830
|
writeFileSync(baselineMetadataPath, JSON.stringify({
|
|
798
831
|
buildId: build.id,
|
|
799
832
|
buildName: build.name,
|
|
@@ -896,6 +929,12 @@ export class TddService {
|
|
|
896
929
|
validatedProperties = {};
|
|
897
930
|
}
|
|
898
931
|
|
|
932
|
+
// Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
|
|
933
|
+
// This is needed because signature generation checks properties.metadata.* for custom properties
|
|
934
|
+
if (properties.metadata && typeof properties.metadata === 'object') {
|
|
935
|
+
validatedProperties.metadata = properties.metadata;
|
|
936
|
+
}
|
|
937
|
+
|
|
899
938
|
// Normalize properties to match backend format (viewport_width at top level)
|
|
900
939
|
// This ensures signature generation matches backend's screenshot-identity.js
|
|
901
940
|
if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
|
|
@@ -904,10 +943,11 @@ export class TddService {
|
|
|
904
943
|
|
|
905
944
|
// Generate signature for baseline matching (name + viewport_width + browser + custom props)
|
|
906
945
|
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
907
|
-
|
|
908
|
-
const
|
|
909
|
-
const
|
|
910
|
-
const
|
|
946
|
+
// Use hash-based filename for reliable matching (matches cloud format)
|
|
947
|
+
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
948
|
+
const currentImagePath = safePath(this.currentPath, filename);
|
|
949
|
+
const baselineImagePath = safePath(this.baselinePath, filename);
|
|
950
|
+
const diffImagePath = safePath(this.diffPath, filename);
|
|
911
951
|
|
|
912
952
|
// Save current screenshot
|
|
913
953
|
writeFileSync(currentImagePath, imageBuffer);
|
|
@@ -978,7 +1018,9 @@ export class TddService {
|
|
|
978
1018
|
antialiasing: true,
|
|
979
1019
|
diffPath: diffImagePath,
|
|
980
1020
|
overwrite: true,
|
|
981
|
-
includeClusters: true
|
|
1021
|
+
includeClusters: true,
|
|
1022
|
+
// Enable spatial clustering analysis
|
|
1023
|
+
minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
|
|
982
1024
|
});
|
|
983
1025
|
if (!result.isDifferent) {
|
|
984
1026
|
// Images match
|
|
@@ -1003,7 +1045,7 @@ export class TddService {
|
|
|
1003
1045
|
return comparison;
|
|
1004
1046
|
} else {
|
|
1005
1047
|
// Images differ - check if differences are in known hotspot regions
|
|
1006
|
-
|
|
1048
|
+
const hotspotAnalysis = this.getHotspotForScreenshot(name);
|
|
1007
1049
|
let hotspotCoverage = null;
|
|
1008
1050
|
let isHotspotFiltered = false;
|
|
1009
1051
|
if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
|
|
@@ -1012,7 +1054,7 @@ export class TddService {
|
|
|
1012
1054
|
// Consider it filtered if:
|
|
1013
1055
|
// 1. High confidence hotspot data (score >= 70)
|
|
1014
1056
|
// 2. 80%+ of the diff is within hotspot regions
|
|
1015
|
-
|
|
1057
|
+
const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
|
|
1016
1058
|
if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
|
|
1017
1059
|
isHotspotFiltered = true;
|
|
1018
1060
|
}
|
|
@@ -1072,7 +1114,7 @@ export class TddService {
|
|
|
1072
1114
|
}
|
|
1073
1115
|
} catch (error) {
|
|
1074
1116
|
// Check if error is due to dimension mismatch
|
|
1075
|
-
const isDimensionMismatch = error.message
|
|
1117
|
+
const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
|
|
1076
1118
|
if (isDimensionMismatch) {
|
|
1077
1119
|
// Different dimensions = different screenshot signature
|
|
1078
1120
|
// This shouldn't happen if signatures are working correctly, but handle gracefully
|
|
@@ -1208,7 +1250,7 @@ export class TddService {
|
|
|
1208
1250
|
});
|
|
1209
1251
|
|
|
1210
1252
|
// Show report path (always clickable)
|
|
1211
|
-
output.info(`\nš» View detailed report: ${colors.cyan(
|
|
1253
|
+
output.info(`\nš» View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
|
|
1212
1254
|
|
|
1213
1255
|
// Auto-open if configured
|
|
1214
1256
|
if (this.config.tdd?.openReport) {
|
|
@@ -1228,10 +1270,10 @@ export class TddService {
|
|
|
1228
1270
|
try {
|
|
1229
1271
|
const {
|
|
1230
1272
|
exec
|
|
1231
|
-
} = await import('child_process');
|
|
1273
|
+
} = await import('node:child_process');
|
|
1232
1274
|
const {
|
|
1233
1275
|
promisify
|
|
1234
|
-
} = await import('util');
|
|
1276
|
+
} = await import('node:util');
|
|
1235
1277
|
const execAsync = promisify(exec);
|
|
1236
1278
|
let command;
|
|
1237
1279
|
switch (process.platform) {
|
|
@@ -1295,10 +1337,10 @@ export class TddService {
|
|
|
1295
1337
|
output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
1296
1338
|
continue;
|
|
1297
1339
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const baselineImagePath = safePath(this.baselinePath,
|
|
1340
|
+
const validatedProperties = validateScreenshotProperties(comparison.properties || {});
|
|
1341
|
+
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
1342
|
+
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
1343
|
+
const baselineImagePath = safePath(this.baselinePath, filename);
|
|
1302
1344
|
try {
|
|
1303
1345
|
// Copy current screenshot to baseline
|
|
1304
1346
|
const currentBuffer = readFileSync(current);
|
|
@@ -1360,7 +1402,7 @@ export class TddService {
|
|
|
1360
1402
|
}
|
|
1361
1403
|
|
|
1362
1404
|
// Generate signature for this screenshot
|
|
1363
|
-
|
|
1405
|
+
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1364
1406
|
|
|
1365
1407
|
// Add screenshot to baseline metadata
|
|
1366
1408
|
const screenshotEntry = {
|
|
@@ -1417,7 +1459,7 @@ export class TddService {
|
|
|
1417
1459
|
}
|
|
1418
1460
|
|
|
1419
1461
|
// Generate signature for this screenshot
|
|
1420
|
-
|
|
1462
|
+
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1421
1463
|
|
|
1422
1464
|
// Add screenshot to baseline metadata
|
|
1423
1465
|
const screenshotEntry = {
|
|
@@ -1471,12 +1513,12 @@ export class TddService {
|
|
|
1471
1513
|
comparison = idOrComparison;
|
|
1472
1514
|
}
|
|
1473
1515
|
const sanitizedName = comparison.name;
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1516
|
+
const properties = comparison.properties || {};
|
|
1517
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
1518
|
+
const filename = generateBaselineFilename(sanitizedName, signature);
|
|
1477
1519
|
|
|
1478
1520
|
// Find the current screenshot file
|
|
1479
|
-
const currentImagePath = safePath(this.currentPath,
|
|
1521
|
+
const currentImagePath = safePath(this.currentPath, filename);
|
|
1480
1522
|
if (!existsSync(currentImagePath)) {
|
|
1481
1523
|
output.error(`Current screenshot not found at: ${currentImagePath}`);
|
|
1482
1524
|
throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Orchestrates the test execution flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
7
8
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
8
|
-
import { spawn } from 'child_process';
|
|
9
9
|
import * as output from '../utils/output.js';
|
|
10
10
|
export class TestRunner extends EventEmitter {
|
|
11
11
|
constructor(config, buildManager, serverManager, tddService) {
|
|
@@ -178,7 +178,7 @@ export class TestRunner extends EventEmitter {
|
|
|
178
178
|
// API mode: create build via API
|
|
179
179
|
const apiService = await this.createApiService();
|
|
180
180
|
if (apiService) {
|
|
181
|
-
|
|
181
|
+
const buildPayload = {
|
|
182
182
|
name: options.buildName || `Test Run ${new Date().toISOString()}`,
|
|
183
183
|
branch: options.branch || 'main',
|
|
184
184
|
environment: options.environment || 'test',
|