@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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Runner Service
|
|
3
|
+
* Orchestrates the test execution flow
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BaseService } from './base-service.js';
|
|
7
|
+
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
export class TestRunner extends BaseService {
|
|
10
|
+
constructor(config, logger, buildManager, serverManager, tddService) {
|
|
11
|
+
super(config, logger);
|
|
12
|
+
this.buildManager = buildManager;
|
|
13
|
+
this.serverManager = serverManager;
|
|
14
|
+
this.tddService = tddService;
|
|
15
|
+
this.testProcess = null;
|
|
16
|
+
}
|
|
17
|
+
async run(options) {
|
|
18
|
+
const {
|
|
19
|
+
testCommand,
|
|
20
|
+
tdd,
|
|
21
|
+
allowNoToken
|
|
22
|
+
} = options;
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
let buildId = null;
|
|
25
|
+
if (!testCommand) {
|
|
26
|
+
throw new VizzlyError('No test command provided', 'TEST_COMMAND_MISSING');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// If no token is allowed and not in TDD mode, just run the command without Vizzly integration
|
|
30
|
+
if (allowNoToken && !this.config.apiKey && !tdd) {
|
|
31
|
+
const env = {
|
|
32
|
+
...process.env,
|
|
33
|
+
VIZZLY_ENABLED: 'false'
|
|
34
|
+
};
|
|
35
|
+
await this.executeTestCommand(testCommand, env);
|
|
36
|
+
return {
|
|
37
|
+
testsPassed: 1,
|
|
38
|
+
testsFailed: 0,
|
|
39
|
+
screenshotsCaptured: 0
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
let buildInfo = null;
|
|
44
|
+
let buildUrl = null;
|
|
45
|
+
let screenshotCount = 0;
|
|
46
|
+
if (tdd) {
|
|
47
|
+
// TDD mode: create local build for fast feedback
|
|
48
|
+
this.logger.debug('TDD mode: creating local build...');
|
|
49
|
+
const build = await this.buildManager.createBuild(options);
|
|
50
|
+
buildId = build.id;
|
|
51
|
+
this.logger.debug(`TDD build created with ID: ${build.id}`);
|
|
52
|
+
} else if (options.eager) {
|
|
53
|
+
// Eager mode: create build immediately via API
|
|
54
|
+
this.logger.debug('Eager mode: creating build via API...');
|
|
55
|
+
const apiService = await this.createApiService();
|
|
56
|
+
if (apiService) {
|
|
57
|
+
const buildResult = await apiService.createBuild({
|
|
58
|
+
build: {
|
|
59
|
+
name: options.buildName || `Test Run ${new Date().toISOString()}`,
|
|
60
|
+
branch: options.branch || 'main',
|
|
61
|
+
environment: options.environment || 'test',
|
|
62
|
+
commit_sha: options.commit,
|
|
63
|
+
commit_message: options.message
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
buildId = buildResult.id;
|
|
67
|
+
buildUrl = buildResult.url;
|
|
68
|
+
this.logger.debug(`Eager build created with ID: ${buildId}`);
|
|
69
|
+
if (buildUrl) {
|
|
70
|
+
this.logger.info(`Build URL: ${buildUrl}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Emit build created event for eager mode
|
|
74
|
+
this.emit('build-created', {
|
|
75
|
+
buildId: buildResult.id,
|
|
76
|
+
url: buildResult.url,
|
|
77
|
+
name: buildResult.name || options.buildName
|
|
78
|
+
});
|
|
79
|
+
} else {
|
|
80
|
+
this.logger.warn('No API key available for eager build creation, falling back to lazy mode');
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Lazy mode: prepare build info for API creation on first screenshot
|
|
84
|
+
buildInfo = {
|
|
85
|
+
buildName: options.buildName || `Test Run ${new Date().toISOString()}`,
|
|
86
|
+
branch: options.branch || 'main',
|
|
87
|
+
environment: options.environment || 'test',
|
|
88
|
+
commitSha: options.commit,
|
|
89
|
+
commitMessage: options.message
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Start server with appropriate configuration
|
|
94
|
+
const mode = tdd ? 'tdd' : options.eager ? 'eager' : 'lazy';
|
|
95
|
+
await this.serverManager.start(buildId, buildInfo, mode);
|
|
96
|
+
|
|
97
|
+
// Forward server events
|
|
98
|
+
if (this.serverManager.server && this.serverManager.server.emitter) {
|
|
99
|
+
this.serverManager.server.emitter.on('build-created', buildInfo => {
|
|
100
|
+
// Update local buildId and buildUrl from server
|
|
101
|
+
buildId = buildInfo.buildId;
|
|
102
|
+
buildUrl = buildInfo.url;
|
|
103
|
+
this.emit('build-created', buildInfo);
|
|
104
|
+
});
|
|
105
|
+
this.serverManager.server.emitter.on('screenshot-captured', screenshotInfo => {
|
|
106
|
+
screenshotCount++;
|
|
107
|
+
this.emit('screenshot-captured', screenshotInfo);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (tdd) {
|
|
111
|
+
this.logger.debug('TDD service ready for comparisons');
|
|
112
|
+
}
|
|
113
|
+
const env = {
|
|
114
|
+
...process.env,
|
|
115
|
+
VIZZLY_SERVER_URL: `http://localhost:${this.config.server.port}`,
|
|
116
|
+
VIZZLY_BUILD_ID: buildId || 'lazy',
|
|
117
|
+
// Use 'lazy' for API-driven builds
|
|
118
|
+
VIZZLY_ENABLED: 'true',
|
|
119
|
+
VIZZLY_SET_BASELINE: options.setBaseline || options['set-baseline'] ? 'true' : 'false'
|
|
120
|
+
};
|
|
121
|
+
await this.executeTestCommand(testCommand, env);
|
|
122
|
+
|
|
123
|
+
// Finalize builds based on mode
|
|
124
|
+
const executionTime = Date.now() - startTime;
|
|
125
|
+
await this.finalizeBuild(buildId, tdd, true, executionTime);
|
|
126
|
+
return {
|
|
127
|
+
buildId: buildId,
|
|
128
|
+
url: buildUrl,
|
|
129
|
+
testsPassed: 1,
|
|
130
|
+
testsFailed: 0,
|
|
131
|
+
screenshotsCaptured: screenshotCount
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
this.logger.error('Test run failed:', error);
|
|
135
|
+
|
|
136
|
+
// Finalize builds on failure too
|
|
137
|
+
const executionTime = Date.now() - startTime;
|
|
138
|
+
await this.finalizeBuild(buildId, tdd, false, executionTime);
|
|
139
|
+
throw error;
|
|
140
|
+
} finally {
|
|
141
|
+
await this.serverManager.stop();
|
|
142
|
+
if (tdd && this.tddService && typeof this.tddService.stop === 'function') {
|
|
143
|
+
await this.tddService.stop();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async createApiService() {
|
|
148
|
+
if (!this.config.apiKey) return null;
|
|
149
|
+
const {
|
|
150
|
+
ApiService
|
|
151
|
+
} = await import('./api-service.js');
|
|
152
|
+
return new ApiService({
|
|
153
|
+
...this.config,
|
|
154
|
+
command: 'run'
|
|
155
|
+
}, {
|
|
156
|
+
logger: this.logger
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async finalizeBuild(buildId, isTddMode, success, executionTime) {
|
|
160
|
+
if (!buildId) {
|
|
161
|
+
this.logger.debug('No buildId to finalize');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
if (isTddMode) {
|
|
166
|
+
// TDD mode: use buildManager for local builds
|
|
167
|
+
if (this.buildManager.getCurrentBuild()) {
|
|
168
|
+
await this.buildManager.finalizeBuild(buildId, {
|
|
169
|
+
success
|
|
170
|
+
});
|
|
171
|
+
this.logger.debug(`TDD build ${buildId} finalized with success: ${success}`);
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// API mode (eager/lazy): use API service to update build status
|
|
175
|
+
const apiService = await this.createApiService();
|
|
176
|
+
if (apiService) {
|
|
177
|
+
await apiService.finalizeBuild(buildId, success, executionTime);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Don't fail the entire run if build finalization fails
|
|
182
|
+
this.logger.warn(`Failed to finalize build ${buildId}:`, error.message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async executeTestCommand(testCommand, env) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
// Use shell to execute the full command string
|
|
188
|
+
this.testProcess = spawn(testCommand, {
|
|
189
|
+
env,
|
|
190
|
+
stdio: 'inherit',
|
|
191
|
+
shell: true
|
|
192
|
+
});
|
|
193
|
+
this.testProcess.on('error', error => {
|
|
194
|
+
reject(new VizzlyError(`Failed to run test command: ${error.message}`), 'TEST_COMMAND_FAILED');
|
|
195
|
+
});
|
|
196
|
+
this.testProcess.on('exit', code => {
|
|
197
|
+
if (code !== 0) {
|
|
198
|
+
reject(new VizzlyError(`Test command exited with code ${code}`, 'TEST_COMMAND_FAILED'));
|
|
199
|
+
} else {
|
|
200
|
+
resolve();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async cancel() {
|
|
206
|
+
if (this.testProcess) {
|
|
207
|
+
this.testProcess.kill('SIGTERM');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vizzly Screenshot Uploader
|
|
3
|
+
* Handles screenshot uploads to the Vizzly platform
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob';
|
|
7
|
+
import { readFile, stat } from 'fs/promises';
|
|
8
|
+
import { basename } from 'path';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { createUploaderLogger } from '../utils/logger-factory.js';
|
|
11
|
+
import { ApiService } from './api-service.js';
|
|
12
|
+
import { getDefaultBranch } from '../utils/git.js';
|
|
13
|
+
import { UploadError, TimeoutError, ValidationError } from '../errors/vizzly-error.js';
|
|
14
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
15
|
+
const DEFAULT_SHA_CHECK_BATCH_SIZE = 100;
|
|
16
|
+
const DEFAULT_TIMEOUT = 30000; // 30 seconds
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a new uploader instance
|
|
20
|
+
*/
|
|
21
|
+
export function createUploader({
|
|
22
|
+
apiKey,
|
|
23
|
+
apiUrl,
|
|
24
|
+
userAgent,
|
|
25
|
+
command,
|
|
26
|
+
upload: uploadConfig = {}
|
|
27
|
+
}, options = {}) {
|
|
28
|
+
const logger = options.logger || createUploaderLogger(options);
|
|
29
|
+
const signal = options.signal || new AbortController().signal;
|
|
30
|
+
const api = new ApiService({
|
|
31
|
+
baseUrl: apiUrl,
|
|
32
|
+
token: apiKey,
|
|
33
|
+
command: command || 'upload',
|
|
34
|
+
userAgent,
|
|
35
|
+
allowNoToken: true
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Resolve tunable parameters from options or config
|
|
39
|
+
const batchSize = Number(options.batchSize ?? uploadConfig?.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
40
|
+
const TIMEOUT_MS = Number(options.timeout ?? uploadConfig?.timeout ?? DEFAULT_TIMEOUT);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Upload screenshots to Vizzly
|
|
44
|
+
*/
|
|
45
|
+
async function upload({
|
|
46
|
+
screenshotsDir,
|
|
47
|
+
buildName,
|
|
48
|
+
branch,
|
|
49
|
+
commit,
|
|
50
|
+
message,
|
|
51
|
+
environment = 'production',
|
|
52
|
+
threshold,
|
|
53
|
+
onProgress = () => {}
|
|
54
|
+
}) {
|
|
55
|
+
try {
|
|
56
|
+
// Validate required config
|
|
57
|
+
if (!apiKey) {
|
|
58
|
+
throw new ValidationError('API key is required', {
|
|
59
|
+
config: {
|
|
60
|
+
apiKey,
|
|
61
|
+
apiUrl
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!screenshotsDir) {
|
|
66
|
+
throw new ValidationError('Screenshots directory is required');
|
|
67
|
+
}
|
|
68
|
+
const stats = await stat(screenshotsDir);
|
|
69
|
+
if (!stats.isDirectory()) {
|
|
70
|
+
throw new ValidationError(`${screenshotsDir} is not a directory`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Find screenshots
|
|
74
|
+
const files = await findScreenshots(screenshotsDir);
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
throw new UploadError('No screenshot files found', {
|
|
77
|
+
directory: screenshotsDir,
|
|
78
|
+
pattern: '**/*.png'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
onProgress({
|
|
82
|
+
phase: 'scanning',
|
|
83
|
+
total: files.length
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Process files to get metadata
|
|
87
|
+
const fileMetadata = await processFiles(files, signal, current => onProgress({
|
|
88
|
+
phase: 'processing',
|
|
89
|
+
current,
|
|
90
|
+
total: files.length
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
// Check which files need uploading
|
|
94
|
+
const {
|
|
95
|
+
toUpload,
|
|
96
|
+
existing
|
|
97
|
+
} = await checkExistingFiles(fileMetadata, api, signal);
|
|
98
|
+
onProgress({
|
|
99
|
+
phase: 'deduplication',
|
|
100
|
+
toUpload: toUpload.length,
|
|
101
|
+
existing: existing.length,
|
|
102
|
+
total: files.length
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Create build and upload files
|
|
106
|
+
const result = await uploadFiles({
|
|
107
|
+
toUpload,
|
|
108
|
+
existing,
|
|
109
|
+
buildInfo: {
|
|
110
|
+
name: buildName || `Upload ${new Date().toISOString()}`,
|
|
111
|
+
branch: branch || (await getDefaultBranch()) || 'main',
|
|
112
|
+
commitSha: commit,
|
|
113
|
+
commitMessage: message,
|
|
114
|
+
environment,
|
|
115
|
+
threshold
|
|
116
|
+
},
|
|
117
|
+
api,
|
|
118
|
+
signal,
|
|
119
|
+
batchSize: batchSize,
|
|
120
|
+
onProgress: current => onProgress({
|
|
121
|
+
phase: 'uploading',
|
|
122
|
+
current,
|
|
123
|
+
total: toUpload.length
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
onProgress({
|
|
127
|
+
phase: 'completed',
|
|
128
|
+
buildId: result.buildId,
|
|
129
|
+
url: result.url
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
success: true,
|
|
133
|
+
buildId: result.buildId,
|
|
134
|
+
url: result.url,
|
|
135
|
+
stats: {
|
|
136
|
+
total: files.length,
|
|
137
|
+
uploaded: toUpload.length,
|
|
138
|
+
skipped: existing.length
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger.error('Upload failed:', error);
|
|
143
|
+
|
|
144
|
+
// Re-throw if already a VizzlyError
|
|
145
|
+
if (error.name && error.name.includes('Error') && error.code) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wrap unknown errors
|
|
150
|
+
throw new UploadError(`Upload failed: ${error.message}`, {
|
|
151
|
+
originalError: error.message,
|
|
152
|
+
stack: error.stack
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wait for a build to complete
|
|
159
|
+
*/
|
|
160
|
+
async function waitForBuild(buildId, timeout = TIMEOUT_MS) {
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
while (Date.now() - startTime < timeout) {
|
|
163
|
+
if (signal.aborted) {
|
|
164
|
+
throw new UploadError('Operation cancelled', {
|
|
165
|
+
buildId
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
let resp;
|
|
169
|
+
try {
|
|
170
|
+
resp = await api.request(`/api/sdk/builds/${buildId}`, {
|
|
171
|
+
signal
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const match = String(err?.message || '').match(/API request failed: (\d+)/);
|
|
175
|
+
const code = match ? match[1] : 'unknown';
|
|
176
|
+
throw new UploadError(`Failed to check build status: ${code}`);
|
|
177
|
+
}
|
|
178
|
+
const build = resp?.build ?? resp;
|
|
179
|
+
if (build.status === 'completed') {
|
|
180
|
+
// Extract comparison data for the response
|
|
181
|
+
const result = {
|
|
182
|
+
status: 'completed',
|
|
183
|
+
build
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Add comparison summary if available
|
|
187
|
+
if (typeof build.comparisonsTotal === 'number') {
|
|
188
|
+
result.comparisons = build.comparisonsTotal;
|
|
189
|
+
result.passedComparisons = build.comparisonsPassed || 0;
|
|
190
|
+
result.failedComparisons = build.comparisonsFailed || 0;
|
|
191
|
+
} else {
|
|
192
|
+
// Ensure failedComparisons is always a number, even when comparison data is missing
|
|
193
|
+
// This prevents the run command exit code check from failing
|
|
194
|
+
result.passedComparisons = 0;
|
|
195
|
+
result.failedComparisons = 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Add build URL if available
|
|
199
|
+
if (build.url) {
|
|
200
|
+
result.url = build.url;
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
if (build.status === 'failed') {
|
|
205
|
+
throw new UploadError(`Build failed: ${build.error || 'Unknown error'}`);
|
|
206
|
+
}
|
|
207
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
208
|
+
}
|
|
209
|
+
throw new TimeoutError(`Build timed out after ${timeout}ms`, {
|
|
210
|
+
buildId,
|
|
211
|
+
timeout,
|
|
212
|
+
elapsed: Date.now() - startTime
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
upload,
|
|
217
|
+
waitForBuild
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Find all PNG screenshots in a directory
|
|
223
|
+
*/
|
|
224
|
+
async function findScreenshots(directory) {
|
|
225
|
+
const pattern = `${directory}/**/*.png`;
|
|
226
|
+
return glob(pattern, {
|
|
227
|
+
absolute: true
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Process files to extract metadata and compute hashes
|
|
233
|
+
*/
|
|
234
|
+
async function* processFilesGenerator(files, signal) {
|
|
235
|
+
for (const filePath of files) {
|
|
236
|
+
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
237
|
+
const buffer = await readFile(filePath);
|
|
238
|
+
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
239
|
+
yield {
|
|
240
|
+
path: filePath,
|
|
241
|
+
filename: basename(filePath),
|
|
242
|
+
buffer,
|
|
243
|
+
sha256
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function processFiles(files, signal, onProgress) {
|
|
248
|
+
const results = [];
|
|
249
|
+
let count = 0;
|
|
250
|
+
for await (const file of processFilesGenerator(files, signal)) {
|
|
251
|
+
results.push(file);
|
|
252
|
+
count++;
|
|
253
|
+
if (count % 10 === 0 || count === files.length) {
|
|
254
|
+
onProgress(count);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check which files already exist on the server
|
|
262
|
+
*/
|
|
263
|
+
async function checkExistingFiles(fileMetadata, api, signal) {
|
|
264
|
+
const allShas = fileMetadata.map(f => f.sha256);
|
|
265
|
+
const existingShas = new Set();
|
|
266
|
+
|
|
267
|
+
// Check in batches
|
|
268
|
+
for (let i = 0; i < allShas.length; i += DEFAULT_SHA_CHECK_BATCH_SIZE) {
|
|
269
|
+
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
270
|
+
const batch = allShas.slice(i, i + DEFAULT_SHA_CHECK_BATCH_SIZE);
|
|
271
|
+
try {
|
|
272
|
+
const res = await api.request('/api/sdk/check-shas', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: {
|
|
275
|
+
'Content-Type': 'application/json'
|
|
276
|
+
},
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
shas: batch
|
|
279
|
+
}),
|
|
280
|
+
signal
|
|
281
|
+
});
|
|
282
|
+
const {
|
|
283
|
+
existing = []
|
|
284
|
+
} = res || {};
|
|
285
|
+
existing.forEach(sha => existingShas.add(sha));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Continue without deduplication on error
|
|
288
|
+
console.debug('SHA check failed, continuing without deduplication:', error.message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
toUpload: fileMetadata.filter(f => !existingShas.has(f.sha256)),
|
|
293
|
+
existing: fileMetadata.filter(f => existingShas.has(f.sha256))
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Upload files to Vizzly
|
|
299
|
+
*/
|
|
300
|
+
async function uploadFiles({
|
|
301
|
+
toUpload,
|
|
302
|
+
existing,
|
|
303
|
+
buildInfo,
|
|
304
|
+
api,
|
|
305
|
+
signal,
|
|
306
|
+
batchSize,
|
|
307
|
+
onProgress
|
|
308
|
+
}) {
|
|
309
|
+
let buildId = null;
|
|
310
|
+
let result = null;
|
|
311
|
+
|
|
312
|
+
// If all files exist, just create a build
|
|
313
|
+
if (toUpload.length === 0) {
|
|
314
|
+
return createBuildWithExisting({
|
|
315
|
+
existing,
|
|
316
|
+
buildInfo,
|
|
317
|
+
api,
|
|
318
|
+
signal
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Upload in batches
|
|
323
|
+
for (let i = 0; i < toUpload.length; i += batchSize) {
|
|
324
|
+
if (signal.aborted) throw new UploadError('Operation cancelled');
|
|
325
|
+
const batch = toUpload.slice(i, i + batchSize);
|
|
326
|
+
const isFirstBatch = i === 0;
|
|
327
|
+
const form = new FormData();
|
|
328
|
+
if (isFirstBatch) {
|
|
329
|
+
// First batch creates the build
|
|
330
|
+
form.append('build_name', buildInfo.name);
|
|
331
|
+
form.append('branch', buildInfo.branch);
|
|
332
|
+
form.append('environment', buildInfo.environment);
|
|
333
|
+
if (buildInfo.commitSha) form.append('commit_sha', buildInfo.commitSha);
|
|
334
|
+
if (buildInfo.commitMessage) form.append('commit_message', buildInfo.commitMessage);
|
|
335
|
+
if (buildInfo.threshold !== undefined) form.append('threshold', buildInfo.threshold.toString());
|
|
336
|
+
|
|
337
|
+
// Include existing SHAs
|
|
338
|
+
if (existing.length > 0) {
|
|
339
|
+
form.append('existing_shas', JSON.stringify(existing.map(f => f.sha256)));
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
// Subsequent batches add to existing build
|
|
343
|
+
form.append('build_id', buildId);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Add files
|
|
347
|
+
for (const file of batch) {
|
|
348
|
+
const blob = new Blob([file.buffer], {
|
|
349
|
+
type: 'image/png'
|
|
350
|
+
});
|
|
351
|
+
form.append('screenshots', blob, file.filename);
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
result = await api.request('/api/sdk/upload', {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
body: form,
|
|
357
|
+
signal,
|
|
358
|
+
headers: {}
|
|
359
|
+
});
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new UploadError(`Upload failed: ${err.message}`, {
|
|
362
|
+
batch: i / batchSize + 1
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (isFirstBatch && result.build?.id) {
|
|
366
|
+
buildId = result.build.id;
|
|
367
|
+
}
|
|
368
|
+
onProgress(i + batch.length);
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
buildId: result.build?.id || buildId,
|
|
372
|
+
url: result.build?.url || result.url
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Create a build with only existing files
|
|
378
|
+
*/
|
|
379
|
+
async function createBuildWithExisting({
|
|
380
|
+
existing,
|
|
381
|
+
buildInfo,
|
|
382
|
+
api,
|
|
383
|
+
signal
|
|
384
|
+
}) {
|
|
385
|
+
const form = new FormData();
|
|
386
|
+
form.append('build_name', buildInfo.name);
|
|
387
|
+
form.append('branch', buildInfo.branch);
|
|
388
|
+
form.append('environment', buildInfo.environment);
|
|
389
|
+
form.append('existing_shas', JSON.stringify(existing.map(f => f.sha256)));
|
|
390
|
+
if (buildInfo.commitSha) form.append('commit_sha', buildInfo.commitSha);
|
|
391
|
+
if (buildInfo.commitMessage) form.append('commit_message', buildInfo.commitMessage);
|
|
392
|
+
if (buildInfo.threshold !== undefined) form.append('threshold', buildInfo.threshold.toString());
|
|
393
|
+
let result;
|
|
394
|
+
try {
|
|
395
|
+
result = await api.request('/api/sdk/upload', {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
body: form,
|
|
398
|
+
signal,
|
|
399
|
+
headers: {}
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
throw new UploadError(`Failed to create build: ${err.message}`);
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
buildId: result.build?.id,
|
|
406
|
+
url: result.build?.url || result.url
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Uploader class for handling screenshot uploads
|
|
412
|
+
*/
|
|
413
|
+
// Legacy Uploader class removed — all functionality lives in createUploader.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Take a screenshot for visual regression testing
|
|
3
|
+
*
|
|
4
|
+
* @param {string} name - Unique name for the screenshot
|
|
5
|
+
* @param {Buffer} imageBuffer - PNG image data as a Buffer
|
|
6
|
+
* @param {Object} [options] - Optional configuration
|
|
7
|
+
* @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
|
|
8
|
+
* @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
|
|
9
|
+
* @param {string} [options.variant] - Variant name for organizing screenshots
|
|
10
|
+
* @param {boolean} [options.fullPage=false] - Whether this is a full page screenshot
|
|
11
|
+
*
|
|
12
|
+
* @returns {Promise<void>}
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Basic usage
|
|
16
|
+
* import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
|
|
17
|
+
*
|
|
18
|
+
* const screenshot = await page.screenshot();
|
|
19
|
+
* await vizzlyScreenshot('homepage', screenshot);
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* // With properties and threshold
|
|
23
|
+
* await vizzlyScreenshot('checkout-form', screenshot, {
|
|
24
|
+
* properties: {
|
|
25
|
+
* browser: 'chrome',
|
|
26
|
+
* viewport: '1920x1080'
|
|
27
|
+
* },
|
|
28
|
+
* threshold: 5
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* @throws {VizzlyError} When screenshot capture fails or client is not initialized
|
|
32
|
+
*/
|
|
33
|
+
export function vizzlyScreenshot(name: string, imageBuffer: Buffer, options?: {
|
|
34
|
+
properties?: Record<string, any>;
|
|
35
|
+
threshold?: number;
|
|
36
|
+
variant?: string;
|
|
37
|
+
fullPage?: boolean;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Wait for all queued screenshots to be processed
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* afterAll(async () => {
|
|
46
|
+
* await vizzlyFlush();
|
|
47
|
+
* });
|
|
48
|
+
*/
|
|
49
|
+
export function vizzlyFlush(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Check if the Vizzly client is initialized and ready
|
|
52
|
+
*
|
|
53
|
+
* @returns {boolean} True if client is ready, false otherwise
|
|
54
|
+
*/
|
|
55
|
+
export function isVizzlyReady(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Configure the client with custom settings
|
|
58
|
+
*
|
|
59
|
+
* @param {Object} config - Configuration options
|
|
60
|
+
* @param {string} [config.serverUrl] - Server URL override
|
|
61
|
+
* @param {boolean} [config.enabled] - Enable/disable screenshots
|
|
62
|
+
*/
|
|
63
|
+
export function configure(config?: {
|
|
64
|
+
serverUrl?: string;
|
|
65
|
+
enabled?: boolean;
|
|
66
|
+
}): void;
|
|
67
|
+
/**
|
|
68
|
+
* Enable or disable screenshot capture
|
|
69
|
+
* @param {boolean} enabled - Whether to enable screenshots
|
|
70
|
+
*/
|
|
71
|
+
export function setEnabled(enabled: boolean): void;
|
|
72
|
+
/**
|
|
73
|
+
* Get information about Vizzly client state
|
|
74
|
+
* @returns {Object} Client information
|
|
75
|
+
*/
|
|
76
|
+
export function getVizzlyInfo(): any;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor command implementation - Run diagnostics to check environment
|
|
3
|
+
* @param {Object} options - Command options
|
|
4
|
+
* @param {Object} globalOptions - Global CLI options
|
|
5
|
+
*/
|
|
6
|
+
export function doctorCommand(options?: any, globalOptions?: any): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Validate doctor options (no specific validation needed)
|
|
9
|
+
* @param {Object} options - Command options
|
|
10
|
+
*/
|
|
11
|
+
export function validateDoctorOptions(): any[];
|