@vizzly-testing/cli 0.1.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/LICENSE +21 -0
- package/README.md +363 -0
- package/bin/vizzly.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/client/index.js +237 -0
- package/dist/commands/doctor.js +158 -0
- package/dist/commands/init.js +102 -0
- package/dist/commands/run.js +224 -0
- package/dist/commands/status.js +164 -0
- package/dist/commands/tdd.js +212 -0
- package/dist/commands/upload.js +181 -0
- package/dist/container/index.js +184 -0
- package/dist/errors/vizzly-error.js +149 -0
- package/dist/index.js +31 -0
- package/dist/screenshot-wrapper.js +68 -0
- package/dist/sdk/index.js +364 -0
- package/dist/server/index.js +522 -0
- package/dist/services/api-service.js +215 -0
- package/dist/services/base-service.js +154 -0
- package/dist/services/build-manager.js +214 -0
- package/dist/services/screenshot-server.js +96 -0
- package/dist/services/server-manager.js +61 -0
- package/dist/services/service-utils.js +171 -0
- package/dist/services/tdd-service.js +444 -0
- package/dist/services/test-runner.js +210 -0
- package/dist/services/uploader.js +413 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client/index.d.ts +76 -0
- package/dist/types/commands/doctor.d.ts +11 -0
- package/dist/types/commands/init.d.ts +14 -0
- package/dist/types/commands/run.d.ts +13 -0
- package/dist/types/commands/status.d.ts +13 -0
- package/dist/types/commands/tdd.d.ts +13 -0
- package/dist/types/commands/upload.d.ts +13 -0
- package/dist/types/container/index.d.ts +61 -0
- package/dist/types/errors/vizzly-error.d.ts +75 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.js +153 -0
- package/dist/types/screenshot-wrapper.d.ts +27 -0
- package/dist/types/sdk/index.d.ts +108 -0
- package/dist/types/server/index.d.ts +38 -0
- package/dist/types/services/api-service.d.ts +77 -0
- package/dist/types/services/base-service.d.ts +72 -0
- package/dist/types/services/build-manager.d.ts +68 -0
- package/dist/types/services/screenshot-server.d.ts +10 -0
- package/dist/types/services/server-manager.d.ts +8 -0
- package/dist/types/services/service-utils.d.ts +45 -0
- package/dist/types/services/tdd-service.d.ts +55 -0
- package/dist/types/services/test-runner.d.ts +25 -0
- package/dist/types/services/uploader.d.ts +34 -0
- package/dist/types/types/index.d.ts +373 -0
- package/dist/types/utils/colors.d.ts +12 -0
- package/dist/types/utils/config-helpers.d.ts +6 -0
- package/dist/types/utils/config-loader.d.ts +22 -0
- package/dist/types/utils/console-ui.d.ts +61 -0
- package/dist/types/utils/diagnostics.d.ts +69 -0
- package/dist/types/utils/environment-config.d.ts +54 -0
- package/dist/types/utils/environment.d.ts +36 -0
- package/dist/types/utils/error-messages.d.ts +42 -0
- package/dist/types/utils/fetch-utils.d.ts +1 -0
- package/dist/types/utils/framework-detector.d.ts +5 -0
- package/dist/types/utils/git.d.ts +44 -0
- package/dist/types/utils/help.d.ts +11 -0
- package/dist/types/utils/image-comparison.d.ts +42 -0
- package/dist/types/utils/logger-factory.d.ts +26 -0
- package/dist/types/utils/logger.d.ts +79 -0
- package/dist/types/utils/package-info.d.ts +15 -0
- package/dist/types/utils/package.d.ts +1 -0
- package/dist/types/utils/project-detection.d.ts +19 -0
- package/dist/types/utils/ui-helpers.d.ts +23 -0
- package/dist/utils/colors.js +66 -0
- package/dist/utils/config-helpers.js +8 -0
- package/dist/utils/config-loader.js +120 -0
- package/dist/utils/console-ui.js +226 -0
- package/dist/utils/diagnostics.js +184 -0
- package/dist/utils/environment-config.js +93 -0
- package/dist/utils/environment.js +109 -0
- package/dist/utils/error-messages.js +34 -0
- package/dist/utils/fetch-utils.js +9 -0
- package/dist/utils/framework-detector.js +40 -0
- package/dist/utils/git.js +226 -0
- package/dist/utils/help.js +66 -0
- package/dist/utils/image-comparison.js +172 -0
- package/dist/utils/logger-factory.js +76 -0
- package/dist/utils/logger.js +231 -0
- package/dist/utils/package-info.js +38 -0
- package/dist/utils/package.js +9 -0
- package/dist/utils/project-detection.js +145 -0
- package/dist/utils/ui-helpers.js +86 -0
- package/package.json +103 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Manager Service
|
|
3
|
+
* Manages the Vizzly HTTP server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BaseService } from './base-service.js';
|
|
7
|
+
import { VizzlyServer } from '../server/index.js';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
export class ServerManager extends BaseService {
|
|
10
|
+
constructor(config, logger) {
|
|
11
|
+
super(config, {
|
|
12
|
+
logger
|
|
13
|
+
});
|
|
14
|
+
this.server = null;
|
|
15
|
+
}
|
|
16
|
+
async start(buildId = null, buildInfo = null, mode = 'lazy') {
|
|
17
|
+
if (this.started) {
|
|
18
|
+
this.logger.warn(`${this.constructor.name} already started`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Create event emitter for server events
|
|
23
|
+
const emitter = new EventEmitter();
|
|
24
|
+
this.server = new VizzlyServer({
|
|
25
|
+
port: this.config?.server?.port || 47392,
|
|
26
|
+
config: this.config,
|
|
27
|
+
buildId,
|
|
28
|
+
buildInfo,
|
|
29
|
+
vizzlyApi: buildInfo || mode === 'eager' ? await this.createApiService() : null,
|
|
30
|
+
tddMode: mode === 'tdd',
|
|
31
|
+
// TDD mode only when explicitly set
|
|
32
|
+
baselineBuild: this.config?.baselineBuildId,
|
|
33
|
+
baselineComparison: this.config?.baselineComparisonId,
|
|
34
|
+
workingDir: process.cwd(),
|
|
35
|
+
emitter // Pass the emitter to the server
|
|
36
|
+
});
|
|
37
|
+
await super.start();
|
|
38
|
+
}
|
|
39
|
+
async onStart() {
|
|
40
|
+
if (this.server) {
|
|
41
|
+
await this.server.start();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async createApiService() {
|
|
45
|
+
if (!this.config.apiKey) return null;
|
|
46
|
+
const {
|
|
47
|
+
ApiService
|
|
48
|
+
} = await import('./api-service.js');
|
|
49
|
+
return new ApiService({
|
|
50
|
+
...this.config,
|
|
51
|
+
command: 'run'
|
|
52
|
+
}, {
|
|
53
|
+
logger: this.logger
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async onStop() {
|
|
57
|
+
if (this.server) {
|
|
58
|
+
await this.server.stop();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for service composition using higher-order functions
|
|
5
|
+
* and event-based architecture patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create an event emitter with enhanced functionality
|
|
12
|
+
* @returns {EventEmitter} Enhanced event emitter
|
|
13
|
+
*/
|
|
14
|
+
export function createEventEmitter() {
|
|
15
|
+
const emitter = new EventEmitter();
|
|
16
|
+
|
|
17
|
+
// Add helper methods
|
|
18
|
+
emitter.emitProgress = (stage, message, data = {}) => {
|
|
19
|
+
const progressData = {
|
|
20
|
+
stage,
|
|
21
|
+
message,
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
...data
|
|
24
|
+
};
|
|
25
|
+
emitter.emit('progress', progressData);
|
|
26
|
+
};
|
|
27
|
+
emitter.emitError = (error, context = {}) => {
|
|
28
|
+
emitter.emit('error', {
|
|
29
|
+
error: error.message,
|
|
30
|
+
stack: error.stack,
|
|
31
|
+
context,
|
|
32
|
+
timestamp: new Date().toISOString()
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
return emitter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a cleanup manager
|
|
40
|
+
* @returns {Object} Cleanup manager with add/execute methods
|
|
41
|
+
*/
|
|
42
|
+
export function createCleanupManager() {
|
|
43
|
+
const cleanupFunctions = [];
|
|
44
|
+
return {
|
|
45
|
+
add: fn => cleanupFunctions.push(fn),
|
|
46
|
+
execute: async () => {
|
|
47
|
+
for (const fn of cleanupFunctions) {
|
|
48
|
+
try {
|
|
49
|
+
await fn();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Cleanup error:', error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cleanupFunctions.length = 0;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create signal handlers for graceful shutdown
|
|
61
|
+
* @param {Function} onSignal - Function to call on signal
|
|
62
|
+
* @returns {Function} Cleanup function to remove handlers
|
|
63
|
+
*/
|
|
64
|
+
export function createSignalHandlers(onSignal) {
|
|
65
|
+
const handleSignal = async signal => {
|
|
66
|
+
await onSignal(signal);
|
|
67
|
+
process.exit(signal === 'SIGINT' ? 130 : 1);
|
|
68
|
+
};
|
|
69
|
+
process.once('SIGINT', () => handleSignal('SIGINT'));
|
|
70
|
+
process.once('SIGTERM', () => handleSignal('SIGTERM'));
|
|
71
|
+
return () => {
|
|
72
|
+
process.removeAllListeners('SIGINT');
|
|
73
|
+
process.removeAllListeners('SIGTERM');
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Higher-order function to add error handling to any function
|
|
79
|
+
* @param {Function} fn - Function to wrap
|
|
80
|
+
* @param {Object} options - Error handling options
|
|
81
|
+
* @returns {Function} Wrapped function with error handling
|
|
82
|
+
*/
|
|
83
|
+
export function withErrorHandling(fn, options = {}) {
|
|
84
|
+
return async (...args) => {
|
|
85
|
+
try {
|
|
86
|
+
return await fn(...args);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (options.onError) {
|
|
89
|
+
options.onError(error);
|
|
90
|
+
}
|
|
91
|
+
if (options.rethrow !== false) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return options.defaultReturn;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Higher-order function to add logging to any function
|
|
101
|
+
* @param {Function} fn - Function to wrap
|
|
102
|
+
* @param {Object} logger - Logger instance
|
|
103
|
+
* @param {string} operation - Operation name for logging
|
|
104
|
+
* @returns {Function} Wrapped function with logging
|
|
105
|
+
*/
|
|
106
|
+
export function withLogging(fn, logger, operation) {
|
|
107
|
+
return async (...args) => {
|
|
108
|
+
logger.debug(`Starting ${operation}`);
|
|
109
|
+
const start = Date.now();
|
|
110
|
+
try {
|
|
111
|
+
const result = await fn(...args);
|
|
112
|
+
const duration = Date.now() - start;
|
|
113
|
+
logger.debug(`Completed ${operation} in ${duration}ms`);
|
|
114
|
+
return result;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const duration = Date.now() - start;
|
|
117
|
+
logger.error(`Failed ${operation} after ${duration}ms:`, error.message);
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compose multiple functions together
|
|
125
|
+
* @param {...Function} fns - Functions to compose
|
|
126
|
+
* @returns {Function} Composed function
|
|
127
|
+
*/
|
|
128
|
+
export function compose(...fns) {
|
|
129
|
+
return value => fns.reduceRight((acc, fn) => fn(acc), value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a service context with shared functionality
|
|
134
|
+
* @param {Object} config - Service configuration
|
|
135
|
+
* @param {Object} options - Service options
|
|
136
|
+
* @returns {Object} Service context
|
|
137
|
+
*/
|
|
138
|
+
export function createServiceContext(config, options = {}) {
|
|
139
|
+
const emitter = createEventEmitter(options);
|
|
140
|
+
const cleanup = createCleanupManager();
|
|
141
|
+
let isRunning = false;
|
|
142
|
+
const context = {
|
|
143
|
+
config,
|
|
144
|
+
emitter,
|
|
145
|
+
cleanup,
|
|
146
|
+
get isRunning() {
|
|
147
|
+
return isRunning;
|
|
148
|
+
},
|
|
149
|
+
set isRunning(value) {
|
|
150
|
+
isRunning = value;
|
|
151
|
+
},
|
|
152
|
+
// Convenience methods
|
|
153
|
+
emitProgress: emitter.emitProgress,
|
|
154
|
+
emitError: emitter.emitError,
|
|
155
|
+
emit: emitter.emit.bind(emitter),
|
|
156
|
+
on: emitter.on.bind(emitter),
|
|
157
|
+
off: emitter.off.bind(emitter),
|
|
158
|
+
once: emitter.once.bind(emitter),
|
|
159
|
+
// Signal handling
|
|
160
|
+
setupSignalHandlers: () => {
|
|
161
|
+
const removeHandlers = createSignalHandlers(async signal => {
|
|
162
|
+
if (isRunning) {
|
|
163
|
+
emitter.emitProgress('cleanup', `Received ${signal}, cleaning up...`);
|
|
164
|
+
await cleanup.execute();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
cleanup.add(removeHandlers);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
return context;
|
|
171
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { ApiService } from '../services/api-service.js';
|
|
4
|
+
import { createServiceLogger } from '../utils/logger-factory.js';
|
|
5
|
+
import { colors } from '../utils/colors.js';
|
|
6
|
+
import { getDefaultBranch } from '../utils/git.js';
|
|
7
|
+
import { fetchWithTimeout } from '../utils/fetch-utils.js';
|
|
8
|
+
import { NetworkError } from '../errors/vizzly-error.js';
|
|
9
|
+
const logger = createServiceLogger('TDD');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a new TDD service instance
|
|
13
|
+
*/
|
|
14
|
+
export function createTDDService(config, options = {}) {
|
|
15
|
+
return new TddService(config, options.workingDir);
|
|
16
|
+
}
|
|
17
|
+
export class TddService {
|
|
18
|
+
constructor(config, workingDir = process.cwd()) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.api = new ApiService({
|
|
21
|
+
baseUrl: config.apiUrl,
|
|
22
|
+
token: config.apiKey,
|
|
23
|
+
command: 'tdd',
|
|
24
|
+
allowNoToken: true // TDD can run without a token to create new screenshots
|
|
25
|
+
});
|
|
26
|
+
this.workingDir = workingDir;
|
|
27
|
+
this.baselinePath = join(workingDir, '.vizzly', 'baselines');
|
|
28
|
+
this.currentPath = join(workingDir, '.vizzly', 'current');
|
|
29
|
+
this.diffPath = join(workingDir, '.vizzly', 'diffs');
|
|
30
|
+
this.baselineData = null;
|
|
31
|
+
this.comparisons = [];
|
|
32
|
+
this.threshold = config.comparison?.threshold || 0.01;
|
|
33
|
+
|
|
34
|
+
// Ensure directories exist
|
|
35
|
+
[this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, {
|
|
38
|
+
recursive: true
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
|
|
44
|
+
logger.info('š Looking for baseline build...');
|
|
45
|
+
|
|
46
|
+
// If no branch specified, try to detect the default branch
|
|
47
|
+
if (!branch) {
|
|
48
|
+
branch = await getDefaultBranch();
|
|
49
|
+
if (!branch) {
|
|
50
|
+
// If we can't detect a default branch, use 'main' as fallback
|
|
51
|
+
branch = 'main';
|
|
52
|
+
logger.warn(`ā ļø Could not detect default branch, using 'main' as fallback`);
|
|
53
|
+
} else {
|
|
54
|
+
logger.debug(`Using detected default branch: ${branch}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
let baselineBuild;
|
|
59
|
+
if (buildId) {
|
|
60
|
+
// Use specific build ID
|
|
61
|
+
logger.info(`š Using specified build: ${buildId}`);
|
|
62
|
+
baselineBuild = await this.api.getBuild(buildId);
|
|
63
|
+
} else if (comparisonId) {
|
|
64
|
+
// Use specific comparison ID
|
|
65
|
+
logger.info(`š Using comparison: ${comparisonId}`);
|
|
66
|
+
const comparison = await this.api.getComparison(comparisonId);
|
|
67
|
+
baselineBuild = comparison.baselineBuild;
|
|
68
|
+
} else {
|
|
69
|
+
// Get the latest passed build for this environment and branch
|
|
70
|
+
const builds = await this.api.getBuilds({
|
|
71
|
+
environment,
|
|
72
|
+
branch,
|
|
73
|
+
status: 'passed',
|
|
74
|
+
limit: 1
|
|
75
|
+
});
|
|
76
|
+
if (!builds.data || builds.data.length === 0) {
|
|
77
|
+
logger.warn(`ā ļø No baseline builds found for ${environment}/${branch}`);
|
|
78
|
+
logger.info('š” Run a build in normal mode first to create baselines');
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
baselineBuild = builds.data[0];
|
|
82
|
+
}
|
|
83
|
+
logger.info(`š„ Found baseline build: ${colors.cyan(baselineBuild.name)} (${baselineBuild.id})`);
|
|
84
|
+
|
|
85
|
+
// Get build details with screenshots
|
|
86
|
+
const buildDetails = await this.api.getBuild(baselineBuild.id, 'screenshots');
|
|
87
|
+
if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
|
|
88
|
+
logger.warn('ā ļø No screenshots found in baseline build');
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
logger.info(`šø Downloading ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
|
|
92
|
+
|
|
93
|
+
// Download each screenshot
|
|
94
|
+
for (const screenshot of buildDetails.screenshots) {
|
|
95
|
+
const imagePath = join(this.baselinePath, `${screenshot.name}.png`);
|
|
96
|
+
|
|
97
|
+
// Download the image
|
|
98
|
+
const response = await fetchWithTimeout(screenshot.url);
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
throw new NetworkError(`Failed to download ${screenshot.name}: ${response.statusText}`);
|
|
101
|
+
}
|
|
102
|
+
const imageBuffer = await response.buffer();
|
|
103
|
+
writeFileSync(imagePath, imageBuffer);
|
|
104
|
+
logger.debug(`ā Downloaded ${screenshot.name}.png`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Store baseline metadata
|
|
108
|
+
this.baselineData = {
|
|
109
|
+
buildId: baselineBuild.id,
|
|
110
|
+
buildName: baselineBuild.name,
|
|
111
|
+
environment,
|
|
112
|
+
branch,
|
|
113
|
+
threshold: this.threshold,
|
|
114
|
+
screenshots: buildDetails.screenshots.map(s => ({
|
|
115
|
+
name: s.name,
|
|
116
|
+
properties: s.properties || {},
|
|
117
|
+
path: join(this.baselinePath, `${s.name}.png`)
|
|
118
|
+
}))
|
|
119
|
+
};
|
|
120
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
121
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
122
|
+
logger.info(`ā
Baseline downloaded successfully`);
|
|
123
|
+
return this.baselineData;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error(`ā Failed to download baseline: ${error.message}`);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async loadBaseline() {
|
|
130
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
131
|
+
if (!existsSync(metadataPath)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
136
|
+
this.baselineData = metadata;
|
|
137
|
+
this.threshold = metadata.threshold || this.threshold;
|
|
138
|
+
return metadata;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.error(`ā Failed to load baseline metadata: ${error.message}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async compareScreenshot(name, imageBuffer, properties = {}) {
|
|
145
|
+
const currentImagePath = join(this.currentPath, `${name}.png`);
|
|
146
|
+
const baselineImagePath = join(this.baselinePath, `${name}.png`);
|
|
147
|
+
const diffImagePath = join(this.diffPath, `${name}.png`);
|
|
148
|
+
|
|
149
|
+
// Save current screenshot
|
|
150
|
+
writeFileSync(currentImagePath, imageBuffer);
|
|
151
|
+
|
|
152
|
+
// Check if we're in baseline update mode - skip all comparisons
|
|
153
|
+
const setBaseline = process.env.VIZZLY_SET_BASELINE === 'true';
|
|
154
|
+
if (setBaseline) {
|
|
155
|
+
return this.updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check if baseline exists
|
|
159
|
+
if (!existsSync(baselineImagePath)) {
|
|
160
|
+
logger.warn(`ā ļø No baseline found for ${name} - creating baseline`);
|
|
161
|
+
|
|
162
|
+
// Copy current screenshot to baseline directory for future comparisons
|
|
163
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
164
|
+
|
|
165
|
+
// Update or create baseline metadata
|
|
166
|
+
if (!this.baselineData) {
|
|
167
|
+
this.baselineData = {
|
|
168
|
+
buildId: 'local-baseline',
|
|
169
|
+
buildName: 'Local TDD Baseline',
|
|
170
|
+
environment: 'test',
|
|
171
|
+
branch: 'local',
|
|
172
|
+
threshold: this.threshold,
|
|
173
|
+
screenshots: []
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add screenshot to baseline metadata
|
|
178
|
+
const screenshotEntry = {
|
|
179
|
+
name,
|
|
180
|
+
properties: properties || {},
|
|
181
|
+
path: baselineImagePath
|
|
182
|
+
};
|
|
183
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
|
|
184
|
+
if (existingIndex >= 0) {
|
|
185
|
+
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
186
|
+
} else {
|
|
187
|
+
this.baselineData.screenshots.push(screenshotEntry);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Save updated metadata
|
|
191
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
192
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
193
|
+
logger.info(`ā
Created baseline for ${name}`);
|
|
194
|
+
const result = {
|
|
195
|
+
name,
|
|
196
|
+
status: 'new',
|
|
197
|
+
baseline: baselineImagePath,
|
|
198
|
+
current: currentImagePath,
|
|
199
|
+
diff: null,
|
|
200
|
+
properties
|
|
201
|
+
};
|
|
202
|
+
this.comparisons.push(result);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
// Use odiff Node.js API to compare images
|
|
207
|
+
const {
|
|
208
|
+
compare
|
|
209
|
+
} = await import('odiff-bin');
|
|
210
|
+
logger.debug(`Comparing ${baselineImagePath} vs ${currentImagePath}`);
|
|
211
|
+
const result = await compare(baselineImagePath, currentImagePath, diffImagePath, {
|
|
212
|
+
threshold: this.threshold,
|
|
213
|
+
outputDiffMask: true
|
|
214
|
+
});
|
|
215
|
+
if (result.match) {
|
|
216
|
+
// Images match
|
|
217
|
+
const comparison = {
|
|
218
|
+
name,
|
|
219
|
+
status: 'passed',
|
|
220
|
+
baseline: baselineImagePath,
|
|
221
|
+
current: currentImagePath,
|
|
222
|
+
diff: null,
|
|
223
|
+
properties,
|
|
224
|
+
threshold: this.threshold
|
|
225
|
+
};
|
|
226
|
+
logger.info(`ā
${colors.green('PASSED')} ${name}`);
|
|
227
|
+
this.comparisons.push(comparison);
|
|
228
|
+
return comparison;
|
|
229
|
+
} else {
|
|
230
|
+
// Images differ
|
|
231
|
+
let diffInfo = '';
|
|
232
|
+
if (result.reason === 'pixel-diff') {
|
|
233
|
+
diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffCount} pixels)`;
|
|
234
|
+
} else if (result.reason === 'layout-diff') {
|
|
235
|
+
diffInfo = ' (layout difference)';
|
|
236
|
+
}
|
|
237
|
+
const comparison = {
|
|
238
|
+
name,
|
|
239
|
+
status: 'failed',
|
|
240
|
+
baseline: baselineImagePath,
|
|
241
|
+
current: currentImagePath,
|
|
242
|
+
diff: diffImagePath,
|
|
243
|
+
properties,
|
|
244
|
+
threshold: this.threshold,
|
|
245
|
+
diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
|
|
246
|
+
diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
|
|
247
|
+
reason: result.reason
|
|
248
|
+
};
|
|
249
|
+
logger.warn(`ā ${colors.red('FAILED')} ${name} - differences detected${diffInfo}`);
|
|
250
|
+
logger.info(` Diff saved to: ${diffImagePath}`);
|
|
251
|
+
this.comparisons.push(comparison);
|
|
252
|
+
return comparison;
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// Handle file errors or other issues
|
|
256
|
+
logger.error(`ā Error comparing ${name}: ${error.message}`);
|
|
257
|
+
const comparison = {
|
|
258
|
+
name,
|
|
259
|
+
status: 'error',
|
|
260
|
+
baseline: baselineImagePath,
|
|
261
|
+
current: currentImagePath,
|
|
262
|
+
diff: null,
|
|
263
|
+
properties,
|
|
264
|
+
error: error.message
|
|
265
|
+
};
|
|
266
|
+
this.comparisons.push(comparison);
|
|
267
|
+
return comparison;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
getResults() {
|
|
271
|
+
const passed = this.comparisons.filter(c => c.status === 'passed').length;
|
|
272
|
+
const failed = this.comparisons.filter(c => c.status === 'failed').length;
|
|
273
|
+
const newScreenshots = this.comparisons.filter(c => c.status === 'new').length;
|
|
274
|
+
const errors = this.comparisons.filter(c => c.status === 'error').length;
|
|
275
|
+
return {
|
|
276
|
+
total: this.comparisons.length,
|
|
277
|
+
passed,
|
|
278
|
+
failed,
|
|
279
|
+
new: newScreenshots,
|
|
280
|
+
errors,
|
|
281
|
+
comparisons: this.comparisons,
|
|
282
|
+
baseline: this.baselineData
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
printResults() {
|
|
286
|
+
const results = this.getResults();
|
|
287
|
+
logger.info('\nš TDD Results:');
|
|
288
|
+
logger.info(`Total: ${colors.cyan(results.total)}`);
|
|
289
|
+
logger.info(`Passed: ${colors.green(results.passed)}`);
|
|
290
|
+
if (results.failed > 0) {
|
|
291
|
+
logger.info(`Failed: ${colors.red(results.failed)}`);
|
|
292
|
+
}
|
|
293
|
+
if (results.new > 0) {
|
|
294
|
+
logger.info(`New: ${colors.yellow(results.new)}`);
|
|
295
|
+
}
|
|
296
|
+
if (results.errors > 0) {
|
|
297
|
+
logger.info(`Errors: ${colors.red(results.errors)}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Show failed comparisons
|
|
301
|
+
const failedComparisons = results.comparisons.filter(c => c.status === 'failed');
|
|
302
|
+
if (failedComparisons.length > 0) {
|
|
303
|
+
logger.info('\nā Failed comparisons:');
|
|
304
|
+
failedComparisons.forEach(comp => {
|
|
305
|
+
logger.info(` ⢠${comp.name}`);
|
|
306
|
+
logger.info(` Baseline: ${comp.baseline}`);
|
|
307
|
+
logger.info(` Current: ${comp.current}`);
|
|
308
|
+
logger.info(` Diff: ${comp.diff}`);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Show new screenshots
|
|
313
|
+
const newComparisons = results.comparisons.filter(c => c.status === 'new');
|
|
314
|
+
if (newComparisons.length > 0) {
|
|
315
|
+
logger.info('\nšø New screenshots:');
|
|
316
|
+
newComparisons.forEach(comp => {
|
|
317
|
+
logger.info(` ⢠${comp.name}`);
|
|
318
|
+
logger.info(` Current: ${comp.current}`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
logger.info(`\nš Results saved to: ${colors.dim('.vizzly/')}`);
|
|
322
|
+
return results;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Update baselines with current screenshots (accept changes)
|
|
327
|
+
* @returns {number} Number of baselines updated
|
|
328
|
+
*/
|
|
329
|
+
updateBaselines() {
|
|
330
|
+
if (this.comparisons.length === 0) {
|
|
331
|
+
logger.warn('No comparisons found - nothing to update');
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
let updatedCount = 0;
|
|
335
|
+
|
|
336
|
+
// Initialize baseline data if it doesn't exist
|
|
337
|
+
if (!this.baselineData) {
|
|
338
|
+
this.baselineData = {
|
|
339
|
+
buildId: 'local-baseline',
|
|
340
|
+
buildName: 'Local TDD Baseline',
|
|
341
|
+
environment: 'test',
|
|
342
|
+
branch: 'local',
|
|
343
|
+
threshold: this.threshold,
|
|
344
|
+
screenshots: []
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
for (const comparison of this.comparisons) {
|
|
348
|
+
const {
|
|
349
|
+
name,
|
|
350
|
+
current
|
|
351
|
+
} = comparison;
|
|
352
|
+
if (!current || !existsSync(current)) {
|
|
353
|
+
logger.warn(`Current screenshot not found for ${name}, skipping`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const baselineImagePath = join(this.baselinePath, `${name}.png`);
|
|
357
|
+
try {
|
|
358
|
+
// Copy current screenshot to baseline
|
|
359
|
+
const currentBuffer = readFileSync(current);
|
|
360
|
+
writeFileSync(baselineImagePath, currentBuffer);
|
|
361
|
+
|
|
362
|
+
// Update baseline metadata
|
|
363
|
+
const screenshotEntry = {
|
|
364
|
+
name,
|
|
365
|
+
properties: comparison.properties || {},
|
|
366
|
+
path: baselineImagePath
|
|
367
|
+
};
|
|
368
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
|
|
369
|
+
if (existingIndex >= 0) {
|
|
370
|
+
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
371
|
+
} else {
|
|
372
|
+
this.baselineData.screenshots.push(screenshotEntry);
|
|
373
|
+
}
|
|
374
|
+
updatedCount++;
|
|
375
|
+
logger.info(`ā
Updated baseline for ${name}`);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger.error(`ā Failed to update baseline for ${name}: ${error.message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Save updated metadata
|
|
382
|
+
if (updatedCount > 0) {
|
|
383
|
+
try {
|
|
384
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
385
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
386
|
+
logger.info(`ā
Updated ${updatedCount} baseline(s)`);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
logger.error(`ā Failed to save baseline metadata: ${error.message}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return updatedCount;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Update a single baseline with current screenshot
|
|
396
|
+
* @private
|
|
397
|
+
*/
|
|
398
|
+
updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
|
|
399
|
+
logger.info(`š» Setting baseline for ${name}`);
|
|
400
|
+
|
|
401
|
+
// Copy current screenshot to baseline directory
|
|
402
|
+
writeFileSync(baselineImagePath, imageBuffer);
|
|
403
|
+
|
|
404
|
+
// Update or create baseline metadata
|
|
405
|
+
if (!this.baselineData) {
|
|
406
|
+
this.baselineData = {
|
|
407
|
+
buildId: 'local-baseline',
|
|
408
|
+
buildName: 'Local TDD Baseline',
|
|
409
|
+
environment: 'test',
|
|
410
|
+
branch: 'local',
|
|
411
|
+
threshold: this.threshold,
|
|
412
|
+
screenshots: []
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Add screenshot to baseline metadata
|
|
417
|
+
const screenshotEntry = {
|
|
418
|
+
name,
|
|
419
|
+
properties: properties || {},
|
|
420
|
+
path: baselineImagePath
|
|
421
|
+
};
|
|
422
|
+
const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
|
|
423
|
+
if (existingIndex >= 0) {
|
|
424
|
+
this.baselineData.screenshots[existingIndex] = screenshotEntry;
|
|
425
|
+
} else {
|
|
426
|
+
this.baselineData.screenshots.push(screenshotEntry);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Save updated metadata
|
|
430
|
+
const metadataPath = join(this.baselinePath, 'metadata.json');
|
|
431
|
+
writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
|
|
432
|
+
const result = {
|
|
433
|
+
name,
|
|
434
|
+
status: 'baseline-updated',
|
|
435
|
+
baseline: baselineImagePath,
|
|
436
|
+
current: currentImagePath,
|
|
437
|
+
diff: null,
|
|
438
|
+
properties
|
|
439
|
+
};
|
|
440
|
+
this.comparisons.push(result);
|
|
441
|
+
logger.info(`š» Baseline set for ${name}`);
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
}
|