@vizzly-testing/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,10 +39,11 @@ export class TestRunner extends BaseService {
39
39
  screenshotsCaptured: 0
40
40
  };
41
41
  }
42
+ let buildUrl = null;
43
+ let screenshotCount = 0;
44
+ let testSuccess = false;
45
+ let testError = null;
42
46
  try {
43
- let buildUrl = null;
44
- let screenshotCount = 0;
45
-
46
47
  // Create build based on mode
47
48
  buildId = await this.createBuild(options, tdd);
48
49
  if (!tdd && buildId) {
@@ -62,7 +63,7 @@ export class TestRunner extends BaseService {
62
63
  }
63
64
 
64
65
  // Start server with appropriate handler
65
- await this.serverManager.start(buildId, tdd);
66
+ await this.serverManager.start(buildId, tdd, options.setBaseline);
66
67
 
67
68
  // Forward server events
68
69
  if (this.serverManager.server?.emitter) {
@@ -78,28 +79,46 @@ export class TestRunner extends BaseService {
78
79
  VIZZLY_ENABLED: 'true',
79
80
  VIZZLY_SET_BASELINE: options.setBaseline || options['set-baseline'] ? 'true' : 'false'
80
81
  };
81
- await this.executeTestCommand(testCommand, env);
82
-
83
- // Finalize build
84
- const executionTime = Date.now() - startTime;
85
- await this.finalizeBuild(buildId, tdd, true, executionTime);
86
- return {
87
- buildId: buildId,
88
- url: buildUrl,
89
- testsPassed: 1,
90
- testsFailed: 0,
91
- screenshotsCaptured: screenshotCount
92
- };
82
+ try {
83
+ await this.executeTestCommand(testCommand, env);
84
+ testSuccess = true;
85
+ } catch (error) {
86
+ testError = error;
87
+ testSuccess = false;
88
+ }
93
89
  } catch (error) {
94
- this.logger.error('Test run failed:', error);
95
-
96
- // Finalize build on failure
97
- const executionTime = Date.now() - startTime;
98
- await this.finalizeBuild(buildId, tdd, false, executionTime);
99
- throw error;
90
+ // Error in setup phase
91
+ testError = error;
92
+ testSuccess = false;
100
93
  } finally {
101
- await this.serverManager.stop();
94
+ // Always finalize the build and stop the server
95
+ const executionTime = Date.now() - startTime;
96
+ if (buildId) {
97
+ try {
98
+ await this.finalizeBuild(buildId, tdd, testSuccess, executionTime);
99
+ } catch (finalizeError) {
100
+ this.logger.error('Failed to finalize build:', finalizeError);
101
+ }
102
+ }
103
+ try {
104
+ await this.serverManager.stop();
105
+ } catch (stopError) {
106
+ this.logger.error('Failed to stop server:', stopError);
107
+ }
102
108
  }
109
+
110
+ // If there was a test error, throw it now (after cleanup)
111
+ if (testError) {
112
+ this.logger.error('Test run failed:', testError);
113
+ throw testError;
114
+ }
115
+ return {
116
+ buildId: buildId,
117
+ url: buildUrl,
118
+ testsPassed: testSuccess ? 1 : 0,
119
+ testsFailed: testSuccess ? 0 : 1,
120
+ screenshotsCaptured: screenshotCount
121
+ };
103
122
  }
104
123
  async createBuild(options, tdd) {
105
124
  if (tdd) {
@@ -190,8 +209,11 @@ export class TestRunner extends BaseService {
190
209
  this.testProcess.on('error', error => {
191
210
  reject(new VizzlyError(`Failed to run test command: ${error.message}`), 'TEST_COMMAND_FAILED');
192
211
  });
193
- this.testProcess.on('exit', code => {
194
- if (code !== 0) {
212
+ this.testProcess.on('exit', (code, signal) => {
213
+ // If process was killed by SIGINT, treat as interruption
214
+ if (signal === 'SIGINT') {
215
+ reject(new VizzlyError('Test command was interrupted', 'TEST_COMMAND_INTERRUPTED'));
216
+ } else if (code !== 0) {
195
217
  reject(new VizzlyError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
196
218
  } else {
197
219
  resolve();
@@ -200,8 +222,13 @@ export class TestRunner extends BaseService {
200
222
  });
201
223
  }
202
224
  async cancel() {
203
- if (this.testProcess) {
204
- this.testProcess.kill('SIGTERM');
225
+ if (this.testProcess && !this.testProcess.killed) {
226
+ this.testProcess.kill('SIGKILL');
227
+ }
228
+
229
+ // Stop server manager if running
230
+ if (this.serverManager) {
231
+ await this.serverManager.stop();
205
232
  }
206
233
  }
207
234
  }
@@ -1,7 +1,18 @@
1
- export function createTddHandler(config: any, workingDir: any, baselineBuild: any, baselineComparison: any): {
1
+ export function createTddHandler(config: any, workingDir: any, baselineBuild: any, baselineComparison: any, setBaseline?: boolean): {
2
2
  initialize: () => Promise<void>;
3
3
  registerBuild: (buildId: any) => void;
4
4
  handleScreenshot: (buildId: any, name: any, image: any, properties?: {}) => Promise<{
5
+ statusCode: number;
6
+ body: {
7
+ error: string;
8
+ details: any;
9
+ tddMode: boolean;
10
+ comparison?: undefined;
11
+ status?: undefined;
12
+ message?: undefined;
13
+ success?: undefined;
14
+ };
15
+ } | {
5
16
  statusCode: number;
6
17
  body: {
7
18
  error: string;
@@ -12,6 +23,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
12
23
  baseline: any;
13
24
  current: any;
14
25
  diff: any;
26
+ diffPercentage: any;
27
+ threshold: any;
15
28
  };
16
29
  tddMode: boolean;
17
30
  status?: undefined;
@@ -29,6 +42,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
29
42
  baseline: any;
30
43
  current: any;
31
44
  diff?: undefined;
45
+ diffPercentage?: undefined;
46
+ threshold?: undefined;
32
47
  };
33
48
  tddMode: boolean;
34
49
  error?: undefined;
@@ -56,6 +71,8 @@ export function createTddHandler(config: any, workingDir: any, baselineBuild: an
56
71
  baseline?: undefined;
57
72
  current?: undefined;
58
73
  diff?: undefined;
74
+ diffPercentage?: undefined;
75
+ threshold?: undefined;
59
76
  };
60
77
  tddMode: boolean;
61
78
  error?: undefined;
@@ -0,0 +1,52 @@
1
+ export class HtmlReportGenerator {
2
+ constructor(workingDir: any, config: any);
3
+ workingDir: any;
4
+ config: any;
5
+ reportDir: string;
6
+ reportPath: string;
7
+ cssPath: string;
8
+ /**
9
+ * Sanitize HTML content to prevent XSS attacks
10
+ * @param {string} text - Text to sanitize
11
+ * @returns {string} Sanitized text
12
+ */
13
+ sanitizeHtml(text: string): string;
14
+ /**
15
+ * Sanitize build info object
16
+ * @param {Object} buildInfo - Build information to sanitize
17
+ * @returns {Object} Sanitized build info
18
+ */
19
+ sanitizeBuildInfo(buildInfo?: any): any;
20
+ /**
21
+ * Generate HTML report from TDD results
22
+ * @param {Object} results - TDD comparison results
23
+ * @param {Object} buildInfo - Build information
24
+ * @returns {string} Path to generated report
25
+ */
26
+ generateReport(results: any, buildInfo?: any): string;
27
+ /**
28
+ * Process comparison data for HTML report
29
+ * @param {Object} comparison - Comparison object
30
+ * @returns {Object} Processed comparison data
31
+ */
32
+ processComparison(comparison: any): any;
33
+ /**
34
+ * Get relative path from report directory to image file
35
+ * @param {string} imagePath - Absolute path to image
36
+ * @param {string} reportDir - Report directory path
37
+ * @returns {string|null} Relative path or null if invalid
38
+ */
39
+ getRelativePath(imagePath: string, reportDir: string): string | null;
40
+ /**
41
+ * Generate the complete HTML template
42
+ * @param {Object} data - Report data
43
+ * @returns {string} HTML content
44
+ */
45
+ generateHtmlTemplate(data: any): string;
46
+ /**
47
+ * Generate HTML for a single comparison
48
+ * @param {Object} comparison - Comparison data
49
+ * @returns {string} HTML content
50
+ */
51
+ generateComparisonHtml(comparison: any): string;
52
+ }
@@ -9,6 +9,17 @@ export class ServerManager extends BaseService {
9
9
  initialize: () => Promise<void>;
10
10
  registerBuild: (buildId: any) => void;
11
11
  handleScreenshot: (buildId: any, name: any, image: any, properties?: {}) => Promise<{
12
+ statusCode: number;
13
+ body: {
14
+ error: string;
15
+ details: any;
16
+ tddMode: boolean;
17
+ comparison?: undefined;
18
+ status?: undefined;
19
+ message?: undefined;
20
+ success?: undefined;
21
+ };
22
+ } | {
12
23
  statusCode: number;
13
24
  body: {
14
25
  error: string;
@@ -19,6 +30,8 @@ export class ServerManager extends BaseService {
19
30
  baseline: any;
20
31
  current: any;
21
32
  diff: any;
33
+ diffPercentage: any;
34
+ threshold: any;
22
35
  };
23
36
  tddMode: boolean;
24
37
  status?: undefined;
@@ -36,6 +49,8 @@ export class ServerManager extends BaseService {
36
49
  baseline: any;
37
50
  current: any;
38
51
  diff?: undefined;
52
+ diffPercentage?: undefined;
53
+ threshold?: undefined;
39
54
  };
40
55
  tddMode: boolean;
41
56
  error?: undefined;
@@ -63,6 +78,8 @@ export class ServerManager extends BaseService {
63
78
  baseline?: undefined;
64
79
  current?: undefined;
65
80
  diff?: undefined;
81
+ diffPercentage?: undefined;
82
+ threshold?: undefined;
66
83
  };
67
84
  tddMode: boolean;
68
85
  error?: undefined;
@@ -139,9 +156,10 @@ export class ServerManager extends BaseService {
139
156
  cleanup: () => void;
140
157
  };
141
158
  emitter: EventEmitter<[never]>;
142
- start(buildId?: any, tddMode?: boolean): Promise<void>;
159
+ start(buildId?: any, tddMode?: boolean, setBaseline?: boolean): Promise<void>;
143
160
  buildId: any;
144
161
  tddMode: boolean;
162
+ setBaseline: boolean;
145
163
  createApiService(): Promise<import("./api-service.js").ApiService>;
146
164
  get server(): {
147
165
  emitter: EventEmitter<[never]>;
@@ -3,8 +3,9 @@
3
3
  */
4
4
  export function createTDDService(config: any, options?: {}): TddService;
5
5
  export class TddService {
6
- constructor(config: any, workingDir?: string);
6
+ constructor(config: any, workingDir?: string, setBaseline?: boolean);
7
7
  config: any;
8
+ setBaseline: boolean;
8
9
  api: ApiService;
9
10
  workingDir: string;
10
11
  baselinePath: string;
@@ -14,6 +15,11 @@ export class TddService {
14
15
  comparisons: any[];
15
16
  threshold: any;
16
17
  downloadBaselines(environment?: string, branch?: any, buildId?: any, comparisonId?: any): Promise<any>;
18
+ /**
19
+ * Handle local baseline logic (either load existing or prepare for new baselines)
20
+ * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
21
+ */
22
+ handleLocalBaselines(): Promise<any | null>;
17
23
  loadBaseline(): Promise<any>;
18
24
  compareScreenshot(name: any, imageBuffer: any, properties?: {}): Promise<{
19
25
  name: any;
@@ -32,7 +38,7 @@ export class TddService {
32
38
  comparisons: any[];
33
39
  baseline: any;
34
40
  };
35
- printResults(): {
41
+ printResults(): Promise<{
36
42
  total: number;
37
43
  passed: number;
38
44
  failed: number;
@@ -40,12 +46,27 @@ export class TddService {
40
46
  errors: number;
41
47
  comparisons: any[];
42
48
  baseline: any;
43
- };
49
+ }>;
50
+ /**
51
+ * Generate HTML report for TDD results
52
+ * @param {Object} results - TDD comparison results
53
+ */
54
+ generateHtmlReport(results: any): Promise<string>;
55
+ /**
56
+ * Open HTML report in default browser
57
+ * @param {string} reportPath - Path to HTML report
58
+ */
59
+ openReport(reportPath: string): Promise<void>;
44
60
  /**
45
61
  * Update baselines with current screenshots (accept changes)
46
62
  * @returns {number} Number of baselines updated
47
63
  */
48
64
  updateBaselines(): number;
65
+ /**
66
+ * Create a new baseline (used during --set-baseline mode)
67
+ * @private
68
+ */
69
+ private createNewBaseline;
49
70
  /**
50
71
  * Update a single baseline with current screenshot
51
72
  * @private
@@ -16,6 +16,9 @@ export function loadConfig(configPath?: any, cliOverrides?: {}): Promise<{
16
16
  comparison: {
17
17
  threshold: number;
18
18
  };
19
+ tdd: {
20
+ openReport: boolean;
21
+ };
19
22
  apiKey: string;
20
23
  apiUrl: string;
21
24
  }>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
3
+ * @param {string} name - Original screenshot name
4
+ * @param {number} maxLength - Maximum allowed length (default: 255)
5
+ * @returns {string} Sanitized screenshot name
6
+ */
7
+ export function sanitizeScreenshotName(name: string, maxLength?: number): string;
8
+ /**
9
+ * Validates that a path stays within the allowed working directory bounds
10
+ * @param {string} targetPath - Path to validate
11
+ * @param {string} workingDir - Working directory that serves as the security boundary
12
+ * @returns {string} Resolved and normalized path if valid
13
+ * @throws {Error} If path is invalid or outside bounds
14
+ */
15
+ export function validatePathSecurity(targetPath: string, workingDir: string): string;
16
+ /**
17
+ * Safely constructs a path within the working directory
18
+ * @param {string} workingDir - Base working directory
19
+ * @param {...string} pathSegments - Path segments to join
20
+ * @returns {string} Safely constructed path
21
+ * @throws {Error} If resulting path would be outside working directory
22
+ */
23
+ export function safePath(workingDir: string, ...pathSegments: string[]): string;
24
+ /**
25
+ * Validates screenshot properties object for safe values
26
+ * @param {Object} properties - Properties to validate
27
+ * @returns {Object} Validated properties object
28
+ */
29
+ export function validateScreenshotProperties(properties?: any): any;
@@ -25,6 +25,10 @@ const DEFAULT_CONFIG = {
25
25
  // Comparison Configuration
26
26
  comparison: {
27
27
  threshold: 0.1
28
+ },
29
+ // TDD Configuration
30
+ tdd: {
31
+ openReport: false // Whether to auto-open HTML report in browser
28
32
  }
29
33
  };
30
34
  export async function loadConfig(configPath = null, cliOverrides = {}) {
@@ -42,6 +46,9 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
42
46
  },
43
47
  comparison: {
44
48
  ...DEFAULT_CONFIG.comparison
49
+ },
50
+ tdd: {
51
+ ...DEFAULT_CONFIG.tdd
45
52
  }
46
53
  };
47
54
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Security utilities for path sanitization and validation
3
+ * Protects against path traversal attacks and ensures safe file operations
4
+ */
5
+
6
+ import { resolve, normalize, isAbsolute, join } from 'path';
7
+ import { createServiceLogger } from './logger-factory.js';
8
+ const logger = createServiceLogger('SECURITY');
9
+
10
+ /**
11
+ * Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
12
+ * @param {string} name - Original screenshot name
13
+ * @param {number} maxLength - Maximum allowed length (default: 255)
14
+ * @returns {string} Sanitized screenshot name
15
+ */
16
+ export function sanitizeScreenshotName(name, maxLength = 255) {
17
+ if (typeof name !== 'string' || name.length === 0) {
18
+ throw new Error('Screenshot name must be a non-empty string');
19
+ }
20
+ if (name.length > maxLength) {
21
+ throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
22
+ }
23
+
24
+ // Block directory traversal patterns
25
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
26
+ throw new Error('Screenshot name contains invalid path characters');
27
+ }
28
+
29
+ // Block absolute paths
30
+ if (isAbsolute(name)) {
31
+ throw new Error('Screenshot name cannot be an absolute path');
32
+ }
33
+
34
+ // Allow only safe characters: alphanumeric, hyphens, underscores, and dots
35
+ // Replace other characters with underscores
36
+ let sanitized = name.replace(/[^a-zA-Z0-9._-]/g, '_');
37
+
38
+ // Prevent names that start with dots (hidden files)
39
+ if (sanitized.startsWith('.')) {
40
+ sanitized = 'file_' + sanitized;
41
+ }
42
+
43
+ // Ensure we have a valid filename
44
+ if (sanitized.length === 0 || sanitized === '.' || sanitized === '..') {
45
+ sanitized = 'unnamed_screenshot';
46
+ }
47
+ return sanitized;
48
+ }
49
+
50
+ /**
51
+ * Validates that a path stays within the allowed working directory bounds
52
+ * @param {string} targetPath - Path to validate
53
+ * @param {string} workingDir - Working directory that serves as the security boundary
54
+ * @returns {string} Resolved and normalized path if valid
55
+ * @throws {Error} If path is invalid or outside bounds
56
+ */
57
+ export function validatePathSecurity(targetPath, workingDir) {
58
+ if (typeof targetPath !== 'string' || targetPath.length === 0) {
59
+ throw new Error('Path must be a non-empty string');
60
+ }
61
+ if (typeof workingDir !== 'string' || workingDir.length === 0) {
62
+ throw new Error('Working directory must be a non-empty string');
63
+ }
64
+
65
+ // Normalize and resolve both paths
66
+ let resolvedWorkingDir = resolve(normalize(workingDir));
67
+ let resolvedTargetPath = resolve(normalize(targetPath));
68
+
69
+ // Ensure the target path starts with the working directory
70
+ if (!resolvedTargetPath.startsWith(resolvedWorkingDir)) {
71
+ logger.warn(`Path traversal attempt blocked: ${targetPath} (resolved: ${resolvedTargetPath}) is outside working directory: ${resolvedWorkingDir}`);
72
+ throw new Error('Path is outside the allowed working directory');
73
+ }
74
+ return resolvedTargetPath;
75
+ }
76
+
77
+ /**
78
+ * Safely constructs a path within the working directory
79
+ * @param {string} workingDir - Base working directory
80
+ * @param {...string} pathSegments - Path segments to join
81
+ * @returns {string} Safely constructed path
82
+ * @throws {Error} If resulting path would be outside working directory
83
+ */
84
+ export function safePath(workingDir, ...pathSegments) {
85
+ if (pathSegments.length === 0) {
86
+ return validatePathSecurity(workingDir, workingDir);
87
+ }
88
+
89
+ // Sanitize each path segment
90
+ let sanitizedSegments = pathSegments.map(segment => {
91
+ if (typeof segment !== 'string') {
92
+ throw new Error('Path segment must be a string');
93
+ }
94
+
95
+ // Block directory traversal in segments
96
+ if (segment.includes('..')) {
97
+ throw new Error('Path segment contains directory traversal sequence');
98
+ }
99
+ return segment;
100
+ });
101
+ let targetPath = join(workingDir, ...sanitizedSegments);
102
+ return validatePathSecurity(targetPath, workingDir);
103
+ }
104
+
105
+ /**
106
+ * Validates screenshot properties object for safe values
107
+ * @param {Object} properties - Properties to validate
108
+ * @returns {Object} Validated properties object
109
+ */
110
+ export function validateScreenshotProperties(properties = {}) {
111
+ if (properties === null || typeof properties !== 'object') {
112
+ return {};
113
+ }
114
+ let validated = {};
115
+
116
+ // Validate common properties with safe constraints
117
+ if (properties.browser && typeof properties.browser === 'string') {
118
+ try {
119
+ validated.browser = sanitizeScreenshotName(properties.browser, 50);
120
+ } catch (error) {
121
+ // Skip invalid browser names, don't include them
122
+ logger.warn(`Invalid browser name '${properties.browser}': ${error.message}`);
123
+ }
124
+ }
125
+ if (properties.viewport && typeof properties.viewport === 'object') {
126
+ let viewport = {};
127
+ if (typeof properties.viewport.width === 'number' && properties.viewport.width > 0 && properties.viewport.width <= 10000) {
128
+ viewport.width = Math.floor(properties.viewport.width);
129
+ }
130
+ if (typeof properties.viewport.height === 'number' && properties.viewport.height > 0 && properties.viewport.height <= 10000) {
131
+ viewport.height = Math.floor(properties.viewport.height);
132
+ }
133
+ if (Object.keys(viewport).length > 0) {
134
+ validated.viewport = viewport;
135
+ }
136
+ }
137
+
138
+ // Allow other safe string properties but sanitize them
139
+ for (let [key, value] of Object.entries(properties)) {
140
+ if (key === 'browser' || key === 'viewport') continue; // Already handled
141
+
142
+ if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
143
+ if (typeof value === 'string' && value.length <= 200) {
144
+ // Store sanitized version of string values
145
+ validated[key] = value.replace(/[<>&"']/g, ''); // Basic HTML entity prevention
146
+ } else if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
147
+ validated[key] = value;
148
+ } else if (typeof value === 'boolean') {
149
+ validated[key] = value;
150
+ }
151
+ }
152
+ }
153
+ return validated;
154
+ }