@vizzly-testing/cli 0.21.1 → 0.22.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 +43 -13
- package/dist/commands/run.js +1 -0
- package/dist/commands/tdd-daemon.js +3 -2
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +13 -13
- package/dist/reporter-ssr/ssr-entry.js +558 -0
- package/dist/server/handlers/tdd-handler.js +22 -26
- package/dist/services/config-service.js +16 -1
- package/dist/services/static-report-generator.js +205 -0
- package/dist/tdd/tdd-service.js +20 -6
- package/dist/utils/browser.js +24 -4
- package/package.json +3 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static Report Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates a self-contained HTML report from TDD test results using SSR.
|
|
5
|
+
* The report is pre-rendered HTML with inlined CSS - no JavaScript required.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
let __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the path to the dist/reporter directory (for CSS)
|
|
15
|
+
*/
|
|
16
|
+
function getReporterDistPath() {
|
|
17
|
+
// Try production path first (when running from dist/)
|
|
18
|
+
let distPath = join(__dirname, '..', 'reporter');
|
|
19
|
+
if (existsSync(join(distPath, 'reporter-bundle.css'))) {
|
|
20
|
+
return distPath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Fall back to development path (when running from src/)
|
|
24
|
+
distPath = join(__dirname, '..', '..', 'dist', 'reporter');
|
|
25
|
+
if (existsSync(join(distPath, 'reporter-bundle.css'))) {
|
|
26
|
+
return distPath;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Reporter bundle not found. Run "npm run build:reporter" first.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the path to the SSR module
|
|
33
|
+
*/
|
|
34
|
+
function getSSRModulePath() {
|
|
35
|
+
// Try production path first (when running from dist/)
|
|
36
|
+
let ssrPath = join(__dirname, '..', 'reporter-ssr', 'ssr-entry.js');
|
|
37
|
+
if (existsSync(ssrPath)) {
|
|
38
|
+
return ssrPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fall back to development path (when running from src/)
|
|
42
|
+
ssrPath = join(__dirname, '..', '..', 'dist', 'reporter-ssr', 'ssr-entry.js');
|
|
43
|
+
if (existsSync(ssrPath)) {
|
|
44
|
+
return ssrPath;
|
|
45
|
+
}
|
|
46
|
+
throw new Error('SSR module not found. Run "npm run build:reporter-ssr" first.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recursively copy a directory
|
|
51
|
+
*/
|
|
52
|
+
function copyDirectory(src, dest) {
|
|
53
|
+
if (!existsSync(src)) return;
|
|
54
|
+
mkdirSync(dest, {
|
|
55
|
+
recursive: true
|
|
56
|
+
});
|
|
57
|
+
let entries = readdirSync(src);
|
|
58
|
+
for (let entry of entries) {
|
|
59
|
+
let srcPath = join(src, entry);
|
|
60
|
+
let destPath = join(dest, entry);
|
|
61
|
+
let stat = statSync(srcPath);
|
|
62
|
+
if (stat.isDirectory()) {
|
|
63
|
+
copyDirectory(srcPath, destPath);
|
|
64
|
+
} else {
|
|
65
|
+
copyFileSync(srcPath, destPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Transform image URLs from server paths to relative file paths
|
|
72
|
+
* /images/baselines/foo.png -> ./images/baselines/foo.png
|
|
73
|
+
*/
|
|
74
|
+
function transformImageUrls(reportData) {
|
|
75
|
+
let transformed = JSON.parse(JSON.stringify(reportData));
|
|
76
|
+
function transformUrl(url) {
|
|
77
|
+
if (!url || typeof url !== 'string') return url;
|
|
78
|
+
if (url.startsWith('/images/')) {
|
|
79
|
+
return `.${url}`;
|
|
80
|
+
}
|
|
81
|
+
return url;
|
|
82
|
+
}
|
|
83
|
+
function transformComparison(comparison) {
|
|
84
|
+
return {
|
|
85
|
+
...comparison,
|
|
86
|
+
baseline: transformUrl(comparison.baseline),
|
|
87
|
+
current: transformUrl(comparison.current),
|
|
88
|
+
diff: transformUrl(comparison.diff)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (transformed.comparisons) {
|
|
92
|
+
transformed.comparisons = transformed.comparisons.map(transformComparison);
|
|
93
|
+
}
|
|
94
|
+
if (transformed.groups) {
|
|
95
|
+
transformed.groups = transformed.groups.map(group => ({
|
|
96
|
+
...group,
|
|
97
|
+
comparisons: group.comparisons?.map(transformComparison) || []
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
return transformed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Generate the static HTML document with SSR-rendered content
|
|
105
|
+
*/
|
|
106
|
+
function generateHtml(renderedContent, css) {
|
|
107
|
+
return `<!DOCTYPE html>
|
|
108
|
+
<html lang="en">
|
|
109
|
+
<head>
|
|
110
|
+
<meta charset="UTF-8">
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
112
|
+
<title>Vizzly Visual Test Report</title>
|
|
113
|
+
<style>
|
|
114
|
+
${css}
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
${renderedContent}
|
|
119
|
+
</body>
|
|
120
|
+
</html>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate a static report from the current TDD results
|
|
125
|
+
*
|
|
126
|
+
* @param {string} workingDir - The project working directory
|
|
127
|
+
* @param {Object} options - Generation options
|
|
128
|
+
* @param {string} [options.outputDir] - Output directory (default: .vizzly/report)
|
|
129
|
+
* @returns {Promise<{success: boolean, reportPath: string, error?: string}>}
|
|
130
|
+
*/
|
|
131
|
+
export async function generateStaticReport(workingDir, options = {}) {
|
|
132
|
+
let outputDir = options.outputDir || join(workingDir, '.vizzly', 'report');
|
|
133
|
+
let vizzlyDir = join(workingDir, '.vizzly');
|
|
134
|
+
try {
|
|
135
|
+
// Read report data
|
|
136
|
+
let reportDataPath = join(vizzlyDir, 'report-data.json');
|
|
137
|
+
if (!existsSync(reportDataPath)) {
|
|
138
|
+
return {
|
|
139
|
+
success: false,
|
|
140
|
+
reportPath: null,
|
|
141
|
+
error: 'No report data found. Run tests first.'
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
let reportData = JSON.parse(readFileSync(reportDataPath, 'utf8'));
|
|
145
|
+
|
|
146
|
+
// Read baseline metadata if available
|
|
147
|
+
let metadataPath = join(vizzlyDir, 'baselines', 'metadata.json');
|
|
148
|
+
if (existsSync(metadataPath)) {
|
|
149
|
+
try {
|
|
150
|
+
reportData.baseline = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore metadata read errors
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Transform image URLs to relative paths
|
|
157
|
+
let transformedData = transformImageUrls(reportData);
|
|
158
|
+
|
|
159
|
+
// Load and use the SSR module
|
|
160
|
+
let ssrModulePath = getSSRModulePath();
|
|
161
|
+
let {
|
|
162
|
+
renderStaticReport
|
|
163
|
+
} = await import(ssrModulePath);
|
|
164
|
+
let renderedContent = renderStaticReport(transformedData);
|
|
165
|
+
|
|
166
|
+
// Get CSS
|
|
167
|
+
let reporterDistPath = getReporterDistPath();
|
|
168
|
+
let css = readFileSync(join(reporterDistPath, 'reporter-bundle.css'), 'utf8');
|
|
169
|
+
|
|
170
|
+
// Create output directory
|
|
171
|
+
mkdirSync(outputDir, {
|
|
172
|
+
recursive: true
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Copy image directories
|
|
176
|
+
let imageDirs = ['baselines', 'current', 'diffs'];
|
|
177
|
+
for (let dir of imageDirs) {
|
|
178
|
+
let srcDir = join(vizzlyDir, dir);
|
|
179
|
+
let destDir = join(outputDir, 'images', dir);
|
|
180
|
+
copyDirectory(srcDir, destDir);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Generate and write HTML
|
|
184
|
+
let html = generateHtml(renderedContent, css);
|
|
185
|
+
let htmlPath = join(outputDir, 'index.html');
|
|
186
|
+
writeFileSync(htmlPath, html, 'utf8');
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
reportPath: htmlPath
|
|
190
|
+
};
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
reportPath: null,
|
|
195
|
+
error: error.message
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get the file:// URL for a report path
|
|
202
|
+
*/
|
|
203
|
+
export function getReportFileUrl(reportPath) {
|
|
204
|
+
return `file://${reportPath}`;
|
|
205
|
+
}
|
package/dist/tdd/tdd-service.js
CHANGED
|
@@ -863,6 +863,20 @@ export class TddService {
|
|
|
863
863
|
return metadata;
|
|
864
864
|
}
|
|
865
865
|
|
|
866
|
+
/**
|
|
867
|
+
* Upsert a comparison result - replaces existing if same ID, otherwise appends.
|
|
868
|
+
* This prevents stale results from accumulating in daemon mode.
|
|
869
|
+
* @private
|
|
870
|
+
*/
|
|
871
|
+
_upsertComparison(result) {
|
|
872
|
+
let existingIndex = this.comparisons.findIndex(c => c.id === result.id);
|
|
873
|
+
if (existingIndex >= 0) {
|
|
874
|
+
this.comparisons[existingIndex] = result;
|
|
875
|
+
} else {
|
|
876
|
+
this.comparisons.push(result);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
866
880
|
/**
|
|
867
881
|
* Compare a screenshot against baseline
|
|
868
882
|
*/
|
|
@@ -959,7 +973,7 @@ export class TddService {
|
|
|
959
973
|
currentPath: currentImagePath,
|
|
960
974
|
properties: validatedProperties
|
|
961
975
|
});
|
|
962
|
-
this.
|
|
976
|
+
this._upsertComparison(result);
|
|
963
977
|
return result;
|
|
964
978
|
}
|
|
965
979
|
|
|
@@ -982,7 +996,7 @@ export class TddService {
|
|
|
982
996
|
minClusterSize: effectiveMinClusterSize,
|
|
983
997
|
honeydiffResult
|
|
984
998
|
});
|
|
985
|
-
this.
|
|
999
|
+
this._upsertComparison(result);
|
|
986
1000
|
return result;
|
|
987
1001
|
} else {
|
|
988
1002
|
let hotspotAnalysis = this.getHotspotForScreenshot(name);
|
|
@@ -1007,7 +1021,7 @@ export class TddService {
|
|
|
1007
1021
|
output.debug('comparison', `${sanitizedName}: ${result.status}`, {
|
|
1008
1022
|
diff: diffInfo
|
|
1009
1023
|
});
|
|
1010
|
-
this.
|
|
1024
|
+
this._upsertComparison(result);
|
|
1011
1025
|
return result;
|
|
1012
1026
|
}
|
|
1013
1027
|
} catch (error) {
|
|
@@ -1035,7 +1049,7 @@ export class TddService {
|
|
|
1035
1049
|
currentPath: currentImagePath,
|
|
1036
1050
|
properties: validatedProperties
|
|
1037
1051
|
});
|
|
1038
|
-
this.
|
|
1052
|
+
this._upsertComparison(result);
|
|
1039
1053
|
return result;
|
|
1040
1054
|
}
|
|
1041
1055
|
output.debug('comparison', `${sanitizedName}: error - ${error.message}`);
|
|
@@ -1047,7 +1061,7 @@ export class TddService {
|
|
|
1047
1061
|
properties: validatedProperties,
|
|
1048
1062
|
errorMessage: error.message
|
|
1049
1063
|
});
|
|
1050
|
-
this.
|
|
1064
|
+
this._upsertComparison(result);
|
|
1051
1065
|
return result;
|
|
1052
1066
|
}
|
|
1053
1067
|
}
|
|
@@ -1389,7 +1403,7 @@ export class TddService {
|
|
|
1389
1403
|
properties,
|
|
1390
1404
|
signature
|
|
1391
1405
|
};
|
|
1392
|
-
this.
|
|
1406
|
+
this._upsertComparison(result);
|
|
1393
1407
|
output.info(`Baseline created for ${name}`);
|
|
1394
1408
|
return result;
|
|
1395
1409
|
}
|
package/dist/utils/browser.js
CHANGED
|
@@ -5,24 +5,44 @@
|
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { platform } from 'node:os';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Validate URL is safe to open (prevent command injection)
|
|
10
|
+
* Only allows http://, https://, and file:// URLs
|
|
11
|
+
* @param {string} url - URL to validate
|
|
12
|
+
* @returns {boolean} True if safe
|
|
13
|
+
*/
|
|
14
|
+
function isValidUrl(url) {
|
|
15
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only allow safe URL schemes
|
|
20
|
+
let validSchemes = ['http://', 'https://', 'file://'];
|
|
21
|
+
return validSchemes.some(scheme => url.startsWith(scheme));
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
/**
|
|
9
25
|
* Open a URL in the default browser
|
|
10
|
-
* @param {string} url - URL to open
|
|
26
|
+
* @param {string} url - URL to open (must be http://, https://, or file://)
|
|
11
27
|
* @returns {Promise<boolean>} True if successful
|
|
12
28
|
*/
|
|
13
29
|
export async function openBrowser(url) {
|
|
30
|
+
// Validate URL to prevent command injection
|
|
31
|
+
if (!isValidUrl(url)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
14
34
|
return new Promise(resolve => {
|
|
15
35
|
let command;
|
|
16
36
|
let args;
|
|
17
|
-
|
|
18
|
-
switch (
|
|
37
|
+
let currentPlatform = platform();
|
|
38
|
+
switch (currentPlatform) {
|
|
19
39
|
case 'darwin':
|
|
20
40
|
// macOS
|
|
21
41
|
command = 'open';
|
|
22
42
|
args = [url];
|
|
23
43
|
break;
|
|
24
44
|
case 'win32':
|
|
25
|
-
// Windows
|
|
45
|
+
// Windows - use start command with validated URL
|
|
26
46
|
command = 'cmd.exe';
|
|
27
47
|
args = ['/c', 'start', '""', url];
|
|
28
48
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vizzly-testing/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Visual review platform for UI developers and designers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"visual-testing",
|
|
@@ -58,11 +58,12 @@
|
|
|
58
58
|
],
|
|
59
59
|
"scripts": {
|
|
60
60
|
"start": "node src/index.js",
|
|
61
|
-
"build": "npm run clean && npm run compile && npm run build:reporter && npm run copy-types",
|
|
61
|
+
"build": "npm run clean && npm run compile && npm run build:reporter && npm run build:reporter-ssr && npm run copy-types",
|
|
62
62
|
"clean": "rimraf dist",
|
|
63
63
|
"compile": "babel src --out-dir dist --ignore '**/*.test.js'",
|
|
64
64
|
"copy-types": "mkdir -p dist/types && cp src/types/*.d.ts dist/types/",
|
|
65
65
|
"build:reporter": "cd src/reporter && vite build",
|
|
66
|
+
"build:reporter-ssr": "cd src/reporter && vite build --config vite.ssr.config.js",
|
|
66
67
|
"dev:reporter": "cd src/reporter && vite --config vite.dev.config.js",
|
|
67
68
|
"test:types": "tsd",
|
|
68
69
|
"prepublishOnly": "npm run build",
|