@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,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Service for Vizzly
|
|
3
|
+
* Handles HTTP requests to the Vizzly API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { URLSearchParams } from 'url';
|
|
7
|
+
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { getPackageVersion } from '../utils/package-info.js';
|
|
10
|
+
import { getApiUrl, getApiToken, getUserAgent } from '../utils/environment-config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ApiService class for direct API communication
|
|
14
|
+
*/
|
|
15
|
+
export class ApiService {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.baseUrl = options.baseUrl || getApiUrl();
|
|
18
|
+
this.token = options.token || getApiToken();
|
|
19
|
+
|
|
20
|
+
// Build User-Agent string
|
|
21
|
+
const command = options.command || 'run'; // Default to 'run' for API service
|
|
22
|
+
const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`;
|
|
23
|
+
const sdkUserAgent = options.userAgent || getUserAgent();
|
|
24
|
+
this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
|
|
25
|
+
if (!this.token && !options.allowNoToken) {
|
|
26
|
+
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Make an API request
|
|
32
|
+
* @param {string} endpoint - API endpoint
|
|
33
|
+
* @param {Object} options - Fetch options
|
|
34
|
+
* @returns {Promise<Object>} Response data
|
|
35
|
+
*/
|
|
36
|
+
async request(endpoint, options = {}) {
|
|
37
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
38
|
+
const headers = {
|
|
39
|
+
'User-Agent': this.userAgent,
|
|
40
|
+
...options.headers
|
|
41
|
+
};
|
|
42
|
+
if (this.token) {
|
|
43
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
44
|
+
}
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
...options,
|
|
47
|
+
headers
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
let errorText = '';
|
|
51
|
+
try {
|
|
52
|
+
if (typeof response.text === 'function') {
|
|
53
|
+
errorText = await response.text();
|
|
54
|
+
} else {
|
|
55
|
+
errorText = response.statusText || '';
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
|
|
61
|
+
}
|
|
62
|
+
return response.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get build information
|
|
67
|
+
* @param {string} buildId - Build ID
|
|
68
|
+
* @param {string} include - Optional include parameter (e.g., 'screenshots')
|
|
69
|
+
* @returns {Promise<Object>} Build data
|
|
70
|
+
*/
|
|
71
|
+
async getBuild(buildId, include = null) {
|
|
72
|
+
const endpoint = include ? `/api/sdk/builds/${buildId}?include=${include}` : `/api/sdk/builds/${buildId}`;
|
|
73
|
+
return this.request(endpoint);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get comparison information
|
|
78
|
+
* @param {string} comparisonId - Comparison ID
|
|
79
|
+
* @returns {Promise<Object>} Comparison data
|
|
80
|
+
*/
|
|
81
|
+
async getComparison(comparisonId) {
|
|
82
|
+
return this.request(`/api/sdk/comparisons/${comparisonId}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get builds for a project
|
|
87
|
+
* @param {Object} filters - Filter options
|
|
88
|
+
* @returns {Promise<Array>} List of builds
|
|
89
|
+
*/
|
|
90
|
+
async getBuilds(filters = {}) {
|
|
91
|
+
const queryParams = new URLSearchParams(filters).toString();
|
|
92
|
+
const endpoint = `/api/sdk/builds${queryParams ? `?${queryParams}` : ''}`;
|
|
93
|
+
return this.request(endpoint);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a new build
|
|
98
|
+
* @param {Object} metadata - Build metadata
|
|
99
|
+
* @returns {Promise<Object>} Created build data
|
|
100
|
+
*/
|
|
101
|
+
async createBuild(metadata) {
|
|
102
|
+
return this.request('/api/sdk/builds', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/json'
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(metadata)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if SHAs already exist on the server
|
|
113
|
+
* @param {string[]} shas - Array of SHA256 hashes to check
|
|
114
|
+
* @returns {Promise<string[]>} Array of existing SHAs
|
|
115
|
+
*/
|
|
116
|
+
async checkShas(shas) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await this.request('/api/sdk/check-shas', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/json'
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
shas
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
return response.existing || [];
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// Continue without deduplication on error
|
|
130
|
+
console.debug('SHA check failed, continuing without deduplication:', error.message);
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Upload a screenshot with SHA checking
|
|
137
|
+
* @param {string} buildId - Build ID
|
|
138
|
+
* @param {string} name - Screenshot name
|
|
139
|
+
* @param {Buffer} buffer - Screenshot data
|
|
140
|
+
* @param {Object} metadata - Additional metadata
|
|
141
|
+
* @returns {Promise<Object>} Upload result
|
|
142
|
+
*/
|
|
143
|
+
async uploadScreenshot(buildId, name, buffer, metadata = {}) {
|
|
144
|
+
// Calculate SHA256 of the image
|
|
145
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
146
|
+
|
|
147
|
+
// Check if this SHA already exists
|
|
148
|
+
const existingShas = await this.checkShas([sha256]);
|
|
149
|
+
if (existingShas.includes(sha256)) {
|
|
150
|
+
// File already exists, skip upload but still register the screenshot
|
|
151
|
+
return {
|
|
152
|
+
message: 'Screenshot already exists, skipped upload',
|
|
153
|
+
sha256,
|
|
154
|
+
skipped: true
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// File doesn't exist, proceed with upload
|
|
159
|
+
return this.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
'Content-Type': 'application/json'
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
name,
|
|
166
|
+
image_data: buffer.toString('base64'),
|
|
167
|
+
properties: metadata ?? {},
|
|
168
|
+
sha256 // Include SHA for server-side deduplication
|
|
169
|
+
})
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Update build status
|
|
175
|
+
* @param {string} buildId - Build ID
|
|
176
|
+
* @param {string} status - Build status (pending|running|completed|failed)
|
|
177
|
+
* @param {number} executionTimeMs - Execution time in milliseconds
|
|
178
|
+
* @returns {Promise<Object>} Updated build data
|
|
179
|
+
*/
|
|
180
|
+
async updateBuildStatus(buildId, status, executionTimeMs = null) {
|
|
181
|
+
const body = {
|
|
182
|
+
status
|
|
183
|
+
};
|
|
184
|
+
if (executionTimeMs !== null) {
|
|
185
|
+
body.executionTimeMs = executionTimeMs;
|
|
186
|
+
}
|
|
187
|
+
return this.request(`/api/sdk/builds/${buildId}/status`, {
|
|
188
|
+
method: 'PUT',
|
|
189
|
+
headers: {
|
|
190
|
+
'Content-Type': 'application/json'
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify(body)
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Finalize a build (convenience method)
|
|
198
|
+
* @param {string} buildId - Build ID
|
|
199
|
+
* @param {boolean} success - Whether the build succeeded
|
|
200
|
+
* @param {number} executionTimeMs - Execution time in milliseconds
|
|
201
|
+
* @returns {Promise<Object>} Finalized build data
|
|
202
|
+
*/
|
|
203
|
+
async finalizeBuild(buildId, success = true, executionTimeMs = null) {
|
|
204
|
+
const status = success ? 'completed' : 'failed';
|
|
205
|
+
return this.updateBuildStatus(buildId, status, executionTimeMs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get token context (organization and project info)
|
|
210
|
+
* @returns {Promise<Object>} Token context data
|
|
211
|
+
*/
|
|
212
|
+
async getTokenContext() {
|
|
213
|
+
return this.request('/api/sdk/token/context');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Service Class
|
|
3
|
+
* Provides common functionality for all services
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
8
|
+
import { createStandardLogger } from '../utils/logger-factory.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} ServiceOptions
|
|
12
|
+
* @property {Object} logger - Logger instance
|
|
13
|
+
* @property {AbortSignal} [signal] - Abort signal for cancellation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base class for all services
|
|
18
|
+
* @extends EventEmitter
|
|
19
|
+
*/
|
|
20
|
+
export class BaseService extends EventEmitter {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} config - Service configuration
|
|
23
|
+
* @param {ServiceOptions} options - Service options
|
|
24
|
+
*/
|
|
25
|
+
constructor(config, options = {}) {
|
|
26
|
+
super();
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.logger = options.logger || createStandardLogger({
|
|
29
|
+
level: 'info'
|
|
30
|
+
});
|
|
31
|
+
this.signal = options.signal;
|
|
32
|
+
this.started = false;
|
|
33
|
+
this.stopping = false;
|
|
34
|
+
|
|
35
|
+
// Setup signal handling
|
|
36
|
+
if (this.signal) {
|
|
37
|
+
this.signal.addEventListener('abort', () => this.stop());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start the service
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async start() {
|
|
46
|
+
if (this.started) {
|
|
47
|
+
this.logger.warn(`${this.constructor.name} already started`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
this.emit('starting');
|
|
52
|
+
await this.onStart();
|
|
53
|
+
this.started = true;
|
|
54
|
+
this.emit('started');
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.emit('error', error);
|
|
57
|
+
throw new VizzlyError(`Failed to start ${this.constructor.name}`, 'SERVICE_START_FAILED', {
|
|
58
|
+
service: this.constructor.name,
|
|
59
|
+
error: error.message
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop the service
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async stop() {
|
|
69
|
+
if (!this.started || this.stopping) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.stopping = true;
|
|
73
|
+
try {
|
|
74
|
+
this.emit('stopping');
|
|
75
|
+
await this.onStop();
|
|
76
|
+
this.started = false;
|
|
77
|
+
this.emit('stopped');
|
|
78
|
+
} catch (error) {
|
|
79
|
+
this.emit('error', error);
|
|
80
|
+
throw new VizzlyError(`Failed to stop ${this.constructor.name}`, 'SERVICE_STOP_FAILED', {
|
|
81
|
+
service: this.constructor.name,
|
|
82
|
+
error: error.message
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
this.stopping = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hook for service-specific start logic
|
|
91
|
+
* @protected
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
async onStart() {
|
|
95
|
+
// Override in subclasses
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Hook for service-specific stop logic
|
|
100
|
+
* @protected
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
async onStop() {
|
|
104
|
+
// Override in subclasses
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Emit a progress event
|
|
109
|
+
* @param {string} phase - Progress phase
|
|
110
|
+
* @param {string} message - Progress message
|
|
111
|
+
* @param {Object} [data] - Additional data
|
|
112
|
+
*/
|
|
113
|
+
emitProgress(phase, message, data = {}) {
|
|
114
|
+
this.emit('progress', {
|
|
115
|
+
phase,
|
|
116
|
+
message,
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
...data
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if service is running
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
isRunning() {
|
|
127
|
+
return this.started && !this.stopping;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wait for service to be ready
|
|
132
|
+
* @param {number} [timeout=30000] - Timeout in milliseconds
|
|
133
|
+
* @returns {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
async waitForReady(timeout = 30000) {
|
|
136
|
+
if (this.started) return;
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
reject(new VizzlyError('Service start timeout', 'SERVICE_TIMEOUT', {
|
|
140
|
+
service: this.constructor.name,
|
|
141
|
+
timeout
|
|
142
|
+
}));
|
|
143
|
+
}, timeout);
|
|
144
|
+
this.once('started', () => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
this.once('error', error => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
reject(error);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Manager Service
|
|
3
|
+
* Manages the build lifecycle and coordinates test execution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
8
|
+
import { BaseService } from './base-service.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate unique build ID for local build management only.
|
|
12
|
+
* Note: The API generates its own UUIDs for actual builds - this local ID
|
|
13
|
+
* is only used for CLI internal tracking and is not sent to the API.
|
|
14
|
+
* @returns {string} Build ID
|
|
15
|
+
*/
|
|
16
|
+
export function generateBuildId() {
|
|
17
|
+
return `build-${crypto.randomUUID()}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create build object
|
|
22
|
+
* @param {Object} buildOptions - Build configuration
|
|
23
|
+
* @returns {Object} Build object
|
|
24
|
+
*/
|
|
25
|
+
export function createBuildObject(buildOptions) {
|
|
26
|
+
const {
|
|
27
|
+
name,
|
|
28
|
+
branch,
|
|
29
|
+
commit,
|
|
30
|
+
environment = 'test',
|
|
31
|
+
metadata = {}
|
|
32
|
+
} = buildOptions;
|
|
33
|
+
return {
|
|
34
|
+
id: generateBuildId(),
|
|
35
|
+
name: name || `build-${Date.now()}`,
|
|
36
|
+
branch,
|
|
37
|
+
commit,
|
|
38
|
+
environment,
|
|
39
|
+
metadata,
|
|
40
|
+
status: 'pending',
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
screenshots: []
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Update build with new status and data
|
|
48
|
+
* @param {Object} build - Current build
|
|
49
|
+
* @param {string} status - New status
|
|
50
|
+
* @param {Object} updates - Additional updates
|
|
51
|
+
* @returns {Object} Updated build
|
|
52
|
+
*/
|
|
53
|
+
export function updateBuild(build, status, updates = {}) {
|
|
54
|
+
return {
|
|
55
|
+
...build,
|
|
56
|
+
status,
|
|
57
|
+
updatedAt: new Date().toISOString(),
|
|
58
|
+
...updates
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Add screenshot to build
|
|
64
|
+
* @param {Object} build - Current build
|
|
65
|
+
* @param {Object} screenshot - Screenshot data
|
|
66
|
+
* @returns {Object} Updated build
|
|
67
|
+
*/
|
|
68
|
+
export function addScreenshotToBuild(build, screenshot) {
|
|
69
|
+
return {
|
|
70
|
+
...build,
|
|
71
|
+
screenshots: [...build.screenshots, {
|
|
72
|
+
...screenshot,
|
|
73
|
+
addedAt: new Date().toISOString()
|
|
74
|
+
}]
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Finalize build with result
|
|
80
|
+
* @param {Object} build - Current build
|
|
81
|
+
* @param {Object} result - Build result
|
|
82
|
+
* @returns {Object} Finalized build
|
|
83
|
+
*/
|
|
84
|
+
export function finalizeBuildObject(build, result = {}) {
|
|
85
|
+
const finalStatus = result.success ? 'completed' : 'failed';
|
|
86
|
+
return {
|
|
87
|
+
...build,
|
|
88
|
+
status: finalStatus,
|
|
89
|
+
completedAt: new Date().toISOString(),
|
|
90
|
+
result
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create queued build item
|
|
96
|
+
* @param {Object} buildOptions - Build options
|
|
97
|
+
* @returns {Object} Queued build item
|
|
98
|
+
*/
|
|
99
|
+
export function createQueuedBuild(buildOptions) {
|
|
100
|
+
return {
|
|
101
|
+
...buildOptions,
|
|
102
|
+
queuedAt: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate build options
|
|
108
|
+
* @param {Object} buildOptions - Build options to validate
|
|
109
|
+
* @returns {Object} Validation result
|
|
110
|
+
*/
|
|
111
|
+
export function validateBuildOptions(buildOptions) {
|
|
112
|
+
const errors = [];
|
|
113
|
+
if (!buildOptions.name && !buildOptions.branch) {
|
|
114
|
+
errors.push('Either name or branch is required');
|
|
115
|
+
}
|
|
116
|
+
if (buildOptions.environment && !['test', 'staging', 'production'].includes(buildOptions.environment)) {
|
|
117
|
+
errors.push('Environment must be one of: test, staging, production');
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
valid: errors.length === 0,
|
|
121
|
+
errors
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export class BuildManager extends BaseService {
|
|
125
|
+
constructor(config, logger) {
|
|
126
|
+
super(config, {
|
|
127
|
+
logger
|
|
128
|
+
});
|
|
129
|
+
this.currentBuild = null;
|
|
130
|
+
this.buildQueue = [];
|
|
131
|
+
}
|
|
132
|
+
async onStart() {
|
|
133
|
+
this.emitProgress('initializing', 0);
|
|
134
|
+
}
|
|
135
|
+
async onStop() {
|
|
136
|
+
if (this.currentBuild && this.currentBuild.status === 'pending') {
|
|
137
|
+
await this.updateBuildStatus(this.currentBuild.id, 'cancelled');
|
|
138
|
+
}
|
|
139
|
+
this.buildQueue.length = 0;
|
|
140
|
+
this.currentBuild = null;
|
|
141
|
+
}
|
|
142
|
+
async createBuild(buildOptions) {
|
|
143
|
+
this.emitProgress('creating', 'Creating new build...');
|
|
144
|
+
const build = createBuildObject(buildOptions);
|
|
145
|
+
this.currentBuild = build;
|
|
146
|
+
this.emitProgress('created', `Build created: ${build.name}`, {
|
|
147
|
+
build
|
|
148
|
+
});
|
|
149
|
+
return build;
|
|
150
|
+
}
|
|
151
|
+
async updateBuildStatus(buildId, status, updates = {}) {
|
|
152
|
+
if (!this.currentBuild || this.currentBuild.id !== buildId) {
|
|
153
|
+
throw new VizzlyError(`Build ${buildId} not found`, 'BUILD_NOT_FOUND');
|
|
154
|
+
}
|
|
155
|
+
this.currentBuild = updateBuild(this.currentBuild, status, updates);
|
|
156
|
+
this.emitProgress('updated', `Build status: ${status}`, {
|
|
157
|
+
buildId,
|
|
158
|
+
status,
|
|
159
|
+
build: this.currentBuild
|
|
160
|
+
});
|
|
161
|
+
return this.currentBuild;
|
|
162
|
+
}
|
|
163
|
+
async addScreenshot(buildId, screenshot) {
|
|
164
|
+
if (!this.currentBuild || this.currentBuild.id !== buildId) {
|
|
165
|
+
throw new VizzlyError(`Build ${buildId} not found`, 'BUILD_NOT_FOUND');
|
|
166
|
+
}
|
|
167
|
+
this.currentBuild = addScreenshotToBuild(this.currentBuild, screenshot);
|
|
168
|
+
this.emitProgress('screenshot-added', 'Screenshot added to build', {
|
|
169
|
+
buildId,
|
|
170
|
+
screenshotCount: this.currentBuild.screenshots.length,
|
|
171
|
+
screenshot
|
|
172
|
+
});
|
|
173
|
+
return this.currentBuild;
|
|
174
|
+
}
|
|
175
|
+
async finalizeBuild(buildId, result = {}) {
|
|
176
|
+
if (!this.currentBuild || this.currentBuild.id !== buildId) {
|
|
177
|
+
throw new VizzlyError(`Build ${buildId} not found`, 'BUILD_NOT_FOUND');
|
|
178
|
+
}
|
|
179
|
+
this.currentBuild = finalizeBuildObject(this.currentBuild, result);
|
|
180
|
+
this.emitProgress('finalized', `Build ${this.currentBuild.status}`, {
|
|
181
|
+
buildId,
|
|
182
|
+
build: this.currentBuild,
|
|
183
|
+
result
|
|
184
|
+
});
|
|
185
|
+
return this.currentBuild;
|
|
186
|
+
}
|
|
187
|
+
getCurrentBuild() {
|
|
188
|
+
return this.currentBuild;
|
|
189
|
+
}
|
|
190
|
+
queueBuild(buildOptions) {
|
|
191
|
+
const queuedBuild = createQueuedBuild(buildOptions);
|
|
192
|
+
this.buildQueue.push(queuedBuild);
|
|
193
|
+
this.emitProgress('queued', 'Build queued for processing', {
|
|
194
|
+
queueLength: this.buildQueue.length
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async processNextBuild() {
|
|
198
|
+
if (this.buildQueue.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const buildOptions = this.buildQueue.shift();
|
|
202
|
+
return await this.createBuild(buildOptions);
|
|
203
|
+
}
|
|
204
|
+
getQueueStatus() {
|
|
205
|
+
return {
|
|
206
|
+
length: this.buildQueue.length,
|
|
207
|
+
items: this.buildQueue.map(item => ({
|
|
208
|
+
name: item.name,
|
|
209
|
+
branch: item.branch,
|
|
210
|
+
queuedAt: item.queuedAt
|
|
211
|
+
}))
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot Server Service
|
|
3
|
+
* Listens for and processes screenshots from the test runner
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import { BaseService } from './base-service.js';
|
|
8
|
+
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
9
|
+
export class ScreenshotServer extends BaseService {
|
|
10
|
+
constructor(config, logger, buildManager) {
|
|
11
|
+
super(config, logger);
|
|
12
|
+
this.buildManager = buildManager;
|
|
13
|
+
this.server = null;
|
|
14
|
+
}
|
|
15
|
+
async onStart() {
|
|
16
|
+
this.server = createServer(this.handleRequest.bind(this));
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
this.server.listen(this.config.server.port, '127.0.0.1', error => {
|
|
19
|
+
if (error) {
|
|
20
|
+
reject(new VizzlyError(`Failed to start screenshot server: ${error.message}`, 'SERVER_ERROR'));
|
|
21
|
+
} else {
|
|
22
|
+
this.logger.info(`Screenshot server listening on http://127.0.0.1:${this.config.server.port}`);
|
|
23
|
+
resolve();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async onStop() {
|
|
29
|
+
if (this.server) {
|
|
30
|
+
return new Promise(resolve => {
|
|
31
|
+
this.server.close(() => {
|
|
32
|
+
this.logger.info('Screenshot server stopped');
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async handleRequest(req, res) {
|
|
39
|
+
if (req.method === 'POST' && req.url === '/screenshot') {
|
|
40
|
+
try {
|
|
41
|
+
const body = await this.parseRequestBody(req);
|
|
42
|
+
const {
|
|
43
|
+
buildId,
|
|
44
|
+
name,
|
|
45
|
+
image,
|
|
46
|
+
properties
|
|
47
|
+
} = body;
|
|
48
|
+
if (!buildId || !name || !image) {
|
|
49
|
+
res.statusCode = 400;
|
|
50
|
+
res.end(JSON.stringify({
|
|
51
|
+
error: 'buildId, name, and image are required'
|
|
52
|
+
}));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await this.buildManager.addScreenshot(buildId, {
|
|
56
|
+
name,
|
|
57
|
+
image,
|
|
58
|
+
properties
|
|
59
|
+
});
|
|
60
|
+
res.statusCode = 200;
|
|
61
|
+
res.end(JSON.stringify({
|
|
62
|
+
success: true
|
|
63
|
+
}));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
this.logger.error('Failed to process screenshot:', error);
|
|
66
|
+
res.statusCode = 500;
|
|
67
|
+
res.end(JSON.stringify({
|
|
68
|
+
error: 'Internal server error'
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
res.statusCode = 404;
|
|
73
|
+
res.end(JSON.stringify({
|
|
74
|
+
error: 'Not found'
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async parseRequestBody(req) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let body = '';
|
|
81
|
+
req.on('data', chunk => {
|
|
82
|
+
body += chunk.toString();
|
|
83
|
+
});
|
|
84
|
+
req.on('end', () => {
|
|
85
|
+
try {
|
|
86
|
+
resolve(JSON.parse(body));
|
|
87
|
+
} catch {
|
|
88
|
+
reject(new VizzlyError('Invalid JSON in request body', 'INVALID_JSON'));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
req.on('error', error => {
|
|
92
|
+
reject(new VizzlyError(`Request error: ${error.message}`, 'REQUEST_ERROR'));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|