@vizzly-testing/cli 0.16.4 → 0.18.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 +4 -4
- package/claude-plugin/skills/debug-visual-regression/SKILL.md +2 -2
- package/dist/cli.js +84 -58
- package/dist/client/index.js +6 -6
- package/dist/commands/doctor.js +18 -17
- package/dist/commands/finalize.js +7 -7
- package/dist/commands/init.js +30 -30
- 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 +27 -27
- package/dist/commands/upload.js +33 -33
- package/dist/commands/whoami.js +12 -12
- package/dist/index.js +9 -14
- 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 +12 -12
- 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 +33 -33
- 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 +101 -95
- package/dist/services/test-runner.js +14 -4
- package/dist/services/uploader.js +10 -8
- package/dist/types/config.d.ts +2 -1
- package/dist/types/index.d.ts +11 -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 +19 -19
- package/dist/utils/config-schema.js +10 -9
- 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 +56 -27
- package/docs/doctor-command.md +1 -1
- package/docs/tdd-mode.md +3 -3
- 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,15 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { compare } from '@vizzly-testing/honeydiff';
|
|
4
|
-
import
|
|
5
|
+
import { NetworkError } from '../errors/vizzly-error.js';
|
|
5
6
|
import { ApiService } from '../services/api-service.js';
|
|
6
|
-
import * as output from '../utils/output.js';
|
|
7
7
|
import { colors } from '../utils/colors.js';
|
|
8
|
-
import { getDefaultBranch } from '../utils/git.js';
|
|
9
8
|
import { fetchWithTimeout } from '../utils/fetch-utils.js';
|
|
10
|
-
import {
|
|
9
|
+
import { getDefaultBranch } from '../utils/git.js';
|
|
10
|
+
import * as output from '../utils/output.js';
|
|
11
|
+
import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
|
|
11
12
|
import { HtmlReportGenerator } from './html-report-generator.js';
|
|
12
|
-
import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Generate a screenshot signature for baseline matching
|
|
@@ -27,49 +27,52 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
|
|
|
27
27
|
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
|
|
28
28
|
*/
|
|
29
29
|
function generateScreenshotSignature(name, properties = {}, customProperties = []) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
// Match cloud screenshot-identity.js behavior exactly:
|
|
31
|
+
// Always include all default properties (name, viewport_width, browser)
|
|
32
|
+
// even if null/undefined, using empty string as placeholder
|
|
33
|
+
const defaultProperties = ['name', 'viewport_width', 'browser'];
|
|
34
|
+
const allProperties = [...defaultProperties, ...customProperties];
|
|
35
|
+
const parts = allProperties.map(propName => {
|
|
36
|
+
let value;
|
|
37
|
+
if (propName === 'name') {
|
|
38
|
+
value = name;
|
|
39
|
+
} else if (propName === 'viewport_width') {
|
|
40
|
+
// Check for viewport_width as top-level property first (backend format)
|
|
41
|
+
value = properties.viewport_width;
|
|
42
|
+
// Fallback to nested viewport.width (SDK format)
|
|
43
|
+
if (value === null || value === undefined) {
|
|
44
|
+
value = properties.viewport?.width;
|
|
45
|
+
}
|
|
46
|
+
} else if (propName === 'browser') {
|
|
47
|
+
value = properties.browser;
|
|
48
|
+
} else {
|
|
49
|
+
// Custom property - check multiple locations
|
|
50
|
+
value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
|
|
51
|
+
}
|
|
49
52
|
|
|
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] ?? '';
|
|
53
|
+
// Handle null/undefined values consistently (match cloud behavior)
|
|
54
|
+
if (value === null || value === undefined) {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
}
|
|
58
|
+
// Convert to string and normalize
|
|
59
|
+
return String(value).trim();
|
|
60
|
+
});
|
|
61
61
|
return parts.join('|');
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* Create a safe filename from signature
|
|
66
66
|
* Handles custom property values that may contain spaces or special characters
|
|
67
|
+
*
|
|
68
|
+
* IMPORTANT: Does NOT collapse multiple underscores because empty signature
|
|
69
|
+
* positions (e.g., null browser) result in `||` which becomes `__` and must
|
|
70
|
+
* be preserved for cloud compatibility.
|
|
67
71
|
*/
|
|
68
72
|
function signatureToFilename(signature) {
|
|
69
73
|
return signature.replace(/\|/g, '_') // pipes to underscores
|
|
70
|
-
.replace(/\s+/g, '
|
|
71
|
-
.replace(/[/\\:*?"<>]/g, '') // remove unsafe filesystem chars
|
|
72
|
-
.replace(/_+/g, '_'); // collapse multiple underscores
|
|
74
|
+
.replace(/\s+/g, '-') // spaces to hyphens (not underscores, to distinguish from position separators)
|
|
75
|
+
.replace(/[/\\:*?"<>]/g, ''); // remove unsafe filesystem chars
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -112,7 +115,8 @@ export class TddService {
|
|
|
112
115
|
this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
|
|
113
116
|
this.baselineData = null;
|
|
114
117
|
this.comparisons = [];
|
|
115
|
-
this.threshold = config.comparison?.threshold || 0
|
|
118
|
+
this.threshold = config.comparison?.threshold || 2.0;
|
|
119
|
+
this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
|
|
116
120
|
this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
|
|
117
121
|
|
|
118
122
|
// Check if we're in baseline update mode
|
|
@@ -196,7 +200,7 @@ export class TddService {
|
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
// The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url
|
|
199
|
-
|
|
203
|
+
const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
|
|
200
204
|
if (!baselineUrl) {
|
|
201
205
|
throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
|
|
202
206
|
}
|
|
@@ -205,7 +209,7 @@ export class TddService {
|
|
|
205
209
|
// The baseline should use the same properties (viewport/browser) as the current screenshot
|
|
206
210
|
// so that generateScreenshotSignature produces the correct filename
|
|
207
211
|
// Use current screenshot properties since we're downloading baseline to compare against current
|
|
208
|
-
|
|
212
|
+
const screenshotProperties = {};
|
|
209
213
|
|
|
210
214
|
// Build properties from comparison API fields (added in backend update)
|
|
211
215
|
// Use current_* fields since we're matching against the current screenshot being tested
|
|
@@ -312,13 +316,13 @@ export class TddService {
|
|
|
312
316
|
// Generate signature for baseline matching (same as compareScreenshot)
|
|
313
317
|
// Build properties object with top-level viewport_width and browser
|
|
314
318
|
// These are returned as top-level fields from the API, not inside metadata
|
|
315
|
-
|
|
319
|
+
const properties = validateScreenshotProperties({
|
|
316
320
|
viewport_width: screenshot.viewport_width,
|
|
317
321
|
browser: screenshot.browser,
|
|
318
322
|
...(screenshot.metadata || screenshot.properties || {})
|
|
319
323
|
});
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
325
|
+
const filename = signatureToFilename(signature);
|
|
322
326
|
const imagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
323
327
|
|
|
324
328
|
// Check if we already have this file with the same SHA (using metadata)
|
|
@@ -437,13 +441,13 @@ export class TddService {
|
|
|
437
441
|
|
|
438
442
|
// Build properties object with top-level viewport_width and browser
|
|
439
443
|
// These are returned as top-level fields from the API, not inside metadata
|
|
440
|
-
|
|
444
|
+
const properties = validateScreenshotProperties({
|
|
441
445
|
viewport_width: s.viewport_width,
|
|
442
446
|
browser: s.browser,
|
|
443
447
|
...(s.metadata || s.properties || {})
|
|
444
448
|
});
|
|
445
|
-
|
|
446
|
-
|
|
449
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
450
|
+
const filename = signatureToFilename(signature);
|
|
447
451
|
return {
|
|
448
452
|
name: sanitizedName,
|
|
449
453
|
originalName: s.name,
|
|
@@ -521,14 +525,14 @@ export class TddService {
|
|
|
521
525
|
}
|
|
522
526
|
try {
|
|
523
527
|
// Get unique screenshot names
|
|
524
|
-
|
|
528
|
+
const screenshotNames = [...new Set(screenshots.map(s => s.name))];
|
|
525
529
|
if (screenshotNames.length === 0) {
|
|
526
530
|
return;
|
|
527
531
|
}
|
|
528
532
|
output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
|
|
529
533
|
|
|
530
534
|
// Use batch endpoint for efficiency
|
|
531
|
-
|
|
535
|
+
const response = await this.api.getBatchHotspots(screenshotNames);
|
|
532
536
|
if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
|
|
533
537
|
output.debug('tdd', 'No hotspot data available from cloud');
|
|
534
538
|
return;
|
|
@@ -536,14 +540,14 @@ export class TddService {
|
|
|
536
540
|
|
|
537
541
|
// Store hotspots in a separate file for easy access during comparisons
|
|
538
542
|
this.hotspotData = response.hotspots;
|
|
539
|
-
|
|
543
|
+
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
540
544
|
writeFileSync(hotspotsPath, JSON.stringify({
|
|
541
545
|
downloadedAt: new Date().toISOString(),
|
|
542
546
|
summary: response.summary,
|
|
543
547
|
hotspots: response.hotspots
|
|
544
548
|
}, null, 2));
|
|
545
|
-
|
|
546
|
-
|
|
549
|
+
const hotspotCount = Object.keys(response.hotspots).length;
|
|
550
|
+
const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
|
|
547
551
|
output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
|
|
548
552
|
} catch (error) {
|
|
549
553
|
// Don't fail baseline download if hotspot fetch fails
|
|
@@ -558,11 +562,11 @@ export class TddService {
|
|
|
558
562
|
*/
|
|
559
563
|
loadHotspots() {
|
|
560
564
|
try {
|
|
561
|
-
|
|
565
|
+
const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
|
|
562
566
|
if (!existsSync(hotspotsPath)) {
|
|
563
567
|
return null;
|
|
564
568
|
}
|
|
565
|
-
|
|
569
|
+
const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
|
|
566
570
|
return data.hotspots || null;
|
|
567
571
|
} catch (error) {
|
|
568
572
|
output.debug('tdd', `Failed to load hotspots: ${error.message}`);
|
|
@@ -577,7 +581,7 @@ export class TddService {
|
|
|
577
581
|
*/
|
|
578
582
|
getHotspotForScreenshot(screenshotName) {
|
|
579
583
|
// Check memory cache first
|
|
580
|
-
if (this.hotspotData
|
|
584
|
+
if (this.hotspotData?.[screenshotName]) {
|
|
581
585
|
return this.hotspotData[screenshotName];
|
|
582
586
|
}
|
|
583
587
|
|
|
@@ -614,9 +618,9 @@ export class TddService {
|
|
|
614
618
|
// Extract Y-coordinates (diff lines) from clusters
|
|
615
619
|
// Each cluster has a boundingBox with y and height
|
|
616
620
|
let diffLines = [];
|
|
617
|
-
for (
|
|
621
|
+
for (const cluster of diffClusters) {
|
|
618
622
|
if (cluster.boundingBox) {
|
|
619
|
-
|
|
623
|
+
const {
|
|
620
624
|
y,
|
|
621
625
|
height
|
|
622
626
|
} = cluster.boundingBox;
|
|
@@ -639,13 +643,13 @@ export class TddService {
|
|
|
639
643
|
|
|
640
644
|
// Check how many diff lines fall within hotspot regions
|
|
641
645
|
let linesInHotspots = 0;
|
|
642
|
-
for (
|
|
643
|
-
|
|
646
|
+
for (const line of diffLines) {
|
|
647
|
+
const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
|
|
644
648
|
if (inHotspot) {
|
|
645
649
|
linesInHotspots++;
|
|
646
650
|
}
|
|
647
651
|
}
|
|
648
|
-
|
|
652
|
+
const coverage = linesInHotspots / diffLines.length;
|
|
649
653
|
return {
|
|
650
654
|
coverage,
|
|
651
655
|
linesInHotspots,
|
|
@@ -666,14 +670,14 @@ export class TddService {
|
|
|
666
670
|
output.info(`Downloading baselines using OAuth from build ${buildId}...`);
|
|
667
671
|
try {
|
|
668
672
|
// Fetch build with screenshots via OAuth endpoint
|
|
669
|
-
|
|
670
|
-
|
|
673
|
+
const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
|
|
674
|
+
const response = await authService.authenticatedRequest(endpoint, {
|
|
671
675
|
method: 'GET',
|
|
672
676
|
headers: {
|
|
673
677
|
'X-Organization': organizationSlug
|
|
674
678
|
}
|
|
675
679
|
});
|
|
676
|
-
|
|
680
|
+
const {
|
|
677
681
|
build,
|
|
678
682
|
screenshots,
|
|
679
683
|
signatureProperties
|
|
@@ -698,8 +702,8 @@ export class TddService {
|
|
|
698
702
|
output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
|
|
699
703
|
|
|
700
704
|
// Load existing baseline metadata for SHA comparison
|
|
701
|
-
|
|
702
|
-
|
|
705
|
+
const existingBaseline = await this.loadBaseline();
|
|
706
|
+
const existingShaMap = new Map();
|
|
703
707
|
if (existingBaseline) {
|
|
704
708
|
existingBaseline.screenshots.forEach(s => {
|
|
705
709
|
if (s.sha256 && s.signature) {
|
|
@@ -712,8 +716,8 @@ export class TddService {
|
|
|
712
716
|
let downloadedCount = 0;
|
|
713
717
|
let skippedCount = 0;
|
|
714
718
|
let errorCount = 0;
|
|
715
|
-
|
|
716
|
-
for (
|
|
719
|
+
const downloadedScreenshots = [];
|
|
720
|
+
for (const screenshot of screenshots) {
|
|
717
721
|
let sanitizedName;
|
|
718
722
|
try {
|
|
719
723
|
sanitizedName = sanitizeScreenshotName(screenshot.name);
|
|
@@ -725,14 +729,14 @@ export class TddService {
|
|
|
725
729
|
|
|
726
730
|
// Build properties object with top-level viewport_width and browser
|
|
727
731
|
// These are returned as top-level fields from the API, not inside metadata
|
|
728
|
-
|
|
732
|
+
const properties = validateScreenshotProperties({
|
|
729
733
|
viewport_width: screenshot.viewport_width,
|
|
730
734
|
browser: screenshot.browser,
|
|
731
735
|
...screenshot.metadata
|
|
732
736
|
});
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
737
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
738
|
+
const filename = signatureToFilename(signature);
|
|
739
|
+
const filePath = safePath(this.baselinePath, `${filename}.png`);
|
|
736
740
|
|
|
737
741
|
// Check if we can skip via SHA comparison
|
|
738
742
|
if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
|
|
@@ -748,21 +752,21 @@ export class TddService {
|
|
|
748
752
|
}
|
|
749
753
|
|
|
750
754
|
// Download the screenshot
|
|
751
|
-
|
|
755
|
+
const downloadUrl = screenshot.original_url;
|
|
752
756
|
if (!downloadUrl) {
|
|
753
757
|
output.warn(`⚠️ No download URL for screenshot: ${sanitizedName}`);
|
|
754
758
|
errorCount++;
|
|
755
759
|
continue;
|
|
756
760
|
}
|
|
757
761
|
try {
|
|
758
|
-
|
|
762
|
+
const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
|
|
759
763
|
if (!imageResponse.ok) {
|
|
760
764
|
throw new Error(`HTTP ${imageResponse.status}`);
|
|
761
765
|
}
|
|
762
|
-
|
|
766
|
+
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
763
767
|
|
|
764
768
|
// Calculate SHA256 of downloaded content
|
|
765
|
-
|
|
769
|
+
const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
|
|
766
770
|
writeFileSync(filePath, imageBuffer);
|
|
767
771
|
downloadedCount++;
|
|
768
772
|
downloadedScreenshots.push({
|
|
@@ -789,11 +793,11 @@ export class TddService {
|
|
|
789
793
|
// Store for TDD comparison
|
|
790
794
|
screenshots: downloadedScreenshots
|
|
791
795
|
};
|
|
792
|
-
|
|
796
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
793
797
|
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
794
798
|
|
|
795
799
|
// Save baseline build metadata
|
|
796
|
-
|
|
800
|
+
const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
|
|
797
801
|
writeFileSync(baselineMetadataPath, JSON.stringify({
|
|
798
802
|
buildId: build.id,
|
|
799
803
|
buildName: build.name,
|
|
@@ -973,12 +977,14 @@ export class TddService {
|
|
|
973
977
|
try {
|
|
974
978
|
// Try to compare - honeydiff will throw if dimensions don't match
|
|
975
979
|
const result = await compare(baselineImagePath, currentImagePath, {
|
|
976
|
-
|
|
977
|
-
//
|
|
980
|
+
threshold: this.threshold,
|
|
981
|
+
// CIEDE2000 Delta E (2.0 = recommended default)
|
|
978
982
|
antialiasing: true,
|
|
979
983
|
diffPath: diffImagePath,
|
|
980
984
|
overwrite: true,
|
|
981
|
-
includeClusters: true
|
|
985
|
+
includeClusters: true,
|
|
986
|
+
// Enable spatial clustering analysis
|
|
987
|
+
minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
|
|
982
988
|
});
|
|
983
989
|
if (!result.isDifferent) {
|
|
984
990
|
// Images match
|
|
@@ -1003,7 +1009,7 @@ export class TddService {
|
|
|
1003
1009
|
return comparison;
|
|
1004
1010
|
} else {
|
|
1005
1011
|
// Images differ - check if differences are in known hotspot regions
|
|
1006
|
-
|
|
1012
|
+
const hotspotAnalysis = this.getHotspotForScreenshot(name);
|
|
1007
1013
|
let hotspotCoverage = null;
|
|
1008
1014
|
let isHotspotFiltered = false;
|
|
1009
1015
|
if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
|
|
@@ -1012,7 +1018,7 @@ export class TddService {
|
|
|
1012
1018
|
// Consider it filtered if:
|
|
1013
1019
|
// 1. High confidence hotspot data (score >= 70)
|
|
1014
1020
|
// 2. 80%+ of the diff is within hotspot regions
|
|
1015
|
-
|
|
1021
|
+
const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
|
|
1016
1022
|
if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
|
|
1017
1023
|
isHotspotFiltered = true;
|
|
1018
1024
|
}
|
|
@@ -1072,7 +1078,7 @@ export class TddService {
|
|
|
1072
1078
|
}
|
|
1073
1079
|
} catch (error) {
|
|
1074
1080
|
// Check if error is due to dimension mismatch
|
|
1075
|
-
const isDimensionMismatch = error.message
|
|
1081
|
+
const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
|
|
1076
1082
|
if (isDimensionMismatch) {
|
|
1077
1083
|
// Different dimensions = different screenshot signature
|
|
1078
1084
|
// This shouldn't happen if signatures are working correctly, but handle gracefully
|
|
@@ -1208,7 +1214,7 @@ export class TddService {
|
|
|
1208
1214
|
});
|
|
1209
1215
|
|
|
1210
1216
|
// Show report path (always clickable)
|
|
1211
|
-
output.info(`\n🐻 View detailed report: ${colors.cyan(
|
|
1217
|
+
output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
|
|
1212
1218
|
|
|
1213
1219
|
// Auto-open if configured
|
|
1214
1220
|
if (this.config.tdd?.openReport) {
|
|
@@ -1228,10 +1234,10 @@ export class TddService {
|
|
|
1228
1234
|
try {
|
|
1229
1235
|
const {
|
|
1230
1236
|
exec
|
|
1231
|
-
} = await import('child_process');
|
|
1237
|
+
} = await import('node:child_process');
|
|
1232
1238
|
const {
|
|
1233
1239
|
promisify
|
|
1234
|
-
} = await import('util');
|
|
1240
|
+
} = await import('node:util');
|
|
1235
1241
|
const execAsync = promisify(exec);
|
|
1236
1242
|
let command;
|
|
1237
1243
|
switch (process.platform) {
|
|
@@ -1295,9 +1301,9 @@ export class TddService {
|
|
|
1295
1301
|
output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
|
|
1296
1302
|
continue;
|
|
1297
1303
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1304
|
+
const validatedProperties = validateScreenshotProperties(comparison.properties || {});
|
|
1305
|
+
const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
|
|
1306
|
+
const filename = signatureToFilename(signature);
|
|
1301
1307
|
const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
|
|
1302
1308
|
try {
|
|
1303
1309
|
// Copy current screenshot to baseline
|
|
@@ -1360,7 +1366,7 @@ export class TddService {
|
|
|
1360
1366
|
}
|
|
1361
1367
|
|
|
1362
1368
|
// Generate signature for this screenshot
|
|
1363
|
-
|
|
1369
|
+
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1364
1370
|
|
|
1365
1371
|
// Add screenshot to baseline metadata
|
|
1366
1372
|
const screenshotEntry = {
|
|
@@ -1417,7 +1423,7 @@ export class TddService {
|
|
|
1417
1423
|
}
|
|
1418
1424
|
|
|
1419
1425
|
// Generate signature for this screenshot
|
|
1420
|
-
|
|
1426
|
+
const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
|
|
1421
1427
|
|
|
1422
1428
|
// Add screenshot to baseline metadata
|
|
1423
1429
|
const screenshotEntry = {
|
|
@@ -1471,9 +1477,9 @@ export class TddService {
|
|
|
1471
1477
|
comparison = idOrComparison;
|
|
1472
1478
|
}
|
|
1473
1479
|
const sanitizedName = comparison.name;
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1480
|
+
const properties = comparison.properties || {};
|
|
1481
|
+
const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
|
|
1482
|
+
const filename = signatureToFilename(signature);
|
|
1477
1483
|
|
|
1478
1484
|
// Find the current screenshot file
|
|
1479
1485
|
const currentImagePath = safePath(this.currentPath, `${filename}.png`);
|
|
@@ -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
|
-
const
|
|
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',
|
|
@@ -186,7 +186,17 @@ export class TestRunner extends EventEmitter {
|
|
|
186
186
|
commit_message: options.message,
|
|
187
187
|
github_pull_request_number: options.pullRequestNumber,
|
|
188
188
|
parallel_id: options.parallelId
|
|
189
|
-
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Only include metadata if we have meaningful config to send
|
|
192
|
+
if (this.config.comparison?.threshold != null) {
|
|
193
|
+
buildPayload.metadata = {
|
|
194
|
+
comparison: {
|
|
195
|
+
threshold: this.config.comparison.threshold
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const buildResult = await apiService.createBuild(buildPayload);
|
|
190
200
|
output.debug('build', `created ${buildResult.id}`);
|
|
191
201
|
|
|
192
202
|
// Emit build created event
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Handles screenshot uploads to the Vizzly platform
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
8
|
+
import { basename } from 'node:path';
|
|
6
9
|
import { glob } from 'glob';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import crypto from 'crypto';
|
|
10
|
+
import { TimeoutError, UploadError, ValidationError } from '../errors/vizzly-error.js';
|
|
11
|
+
import { getDefaultBranch } from '../utils/git.js';
|
|
10
12
|
import * as output from '../utils/output.js';
|
|
11
13
|
import { ApiService } from './api-service.js';
|
|
12
|
-
import { getDefaultBranch } from '../utils/git.js';
|
|
13
|
-
import { UploadError, TimeoutError, ValidationError } from '../errors/vizzly-error.js';
|
|
14
14
|
const DEFAULT_BATCH_SIZE = 50;
|
|
15
15
|
const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
|
|
16
16
|
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
|
@@ -25,7 +25,7 @@ export function createUploader({
|
|
|
25
25
|
command,
|
|
26
26
|
upload: uploadConfig = {}
|
|
27
27
|
} = {}, options = {}) {
|
|
28
|
-
|
|
28
|
+
const signal = options.signal || new AbortController().signal;
|
|
29
29
|
const api = new ApiService({
|
|
30
30
|
baseUrl: apiUrl,
|
|
31
31
|
token: apiKey,
|
|
@@ -160,7 +160,7 @@ export function createUploader({
|
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
// Re-throw if already a VizzlyError
|
|
163
|
-
if (error.name
|
|
163
|
+
if (error.name?.includes('Error') && error.code) {
|
|
164
164
|
throw error;
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -304,7 +304,9 @@ async function checkExistingFiles(fileMetadata, api, signal, buildId) {
|
|
|
304
304
|
existing = [],
|
|
305
305
|
screenshots = []
|
|
306
306
|
} = res || {};
|
|
307
|
-
|
|
307
|
+
for (let sha of existing) {
|
|
308
|
+
existingShas.add(sha);
|
|
309
|
+
}
|
|
308
310
|
allScreenshots.push(...screenshots);
|
|
309
311
|
} catch (error) {
|
|
310
312
|
// Continue without deduplication on error
|
package/dist/types/config.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export { VizzlyConfig } from './index';
|
|
|
20
20
|
* port: 47392
|
|
21
21
|
* },
|
|
22
22
|
* comparison: {
|
|
23
|
-
* threshold: 0
|
|
23
|
+
* threshold: 2.0, // CIEDE2000 Delta E (0=exact, 1=JND, 2=recommended)
|
|
24
|
+
* minClusterSize: 2 // Filter single-pixel noise (1=exact, 2=default, 3+=permissive)
|
|
24
25
|
* }
|
|
25
26
|
* });
|
|
26
27
|
*/
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @module @vizzly-testing/cli
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { EventEmitter } from 'events';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
7
|
|
|
8
8
|
// ============================================================================
|
|
9
9
|
// Configuration Types
|
|
@@ -29,7 +29,17 @@ export interface UploadConfig {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface ComparisonConfig {
|
|
32
|
+
/** CIEDE2000 Delta E threshold (0=exact, 1=JND, 2=recommended default) */
|
|
32
33
|
threshold?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Minimum cluster size to count as a real difference.
|
|
36
|
+
* Filters out scattered single-pixel noise from rendering variance.
|
|
37
|
+
* - 1 = Exact matching (any different pixel counts)
|
|
38
|
+
* - 2 = Default (filters single isolated pixels as noise)
|
|
39
|
+
* - 3+ = More permissive (only larger clusters detected)
|
|
40
|
+
* @default 2
|
|
41
|
+
*/
|
|
42
|
+
minClusterSize?: number;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export interface TddConfig {
|