@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.js +134 -0
- package/dist/api/core.js +341 -0
- package/dist/api/endpoints.js +314 -0
- package/dist/api/index.js +19 -0
- package/dist/auth/client.js +91 -0
- package/dist/auth/core.js +176 -0
- package/dist/auth/index.js +30 -0
- package/dist/auth/operations.js +148 -0
- package/dist/cli.js +178 -3
- package/dist/client/index.js +144 -77
- package/dist/commands/doctor.js +121 -36
- package/dist/commands/finalize.js +49 -18
- package/dist/commands/init.js +13 -18
- package/dist/commands/login.js +49 -55
- package/dist/commands/logout.js +17 -9
- package/dist/commands/project.js +100 -71
- package/dist/commands/run.js +189 -95
- package/dist/commands/status.js +101 -66
- package/dist/commands/tdd-daemon.js +61 -32
- package/dist/commands/tdd.js +104 -98
- package/dist/commands/upload.js +78 -34
- package/dist/commands/whoami.js +44 -42
- package/dist/config/core.js +438 -0
- package/dist/config/index.js +13 -0
- package/dist/config/operations.js +327 -0
- package/dist/index.js +1 -1
- package/dist/project/core.js +295 -0
- package/dist/project/index.js +13 -0
- package/dist/project/operations.js +393 -0
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +16 -16
- package/dist/screenshot-server/core.js +157 -0
- package/dist/screenshot-server/index.js +11 -0
- package/dist/screenshot-server/operations.js +183 -0
- package/dist/sdk/index.js +3 -2
- package/dist/server/handlers/api-handler.js +14 -5
- package/dist/server/handlers/tdd-handler.js +191 -53
- package/dist/server/http-server.js +9 -3
- package/dist/server/routers/baseline.js +58 -0
- package/dist/server/routers/dashboard.js +10 -6
- package/dist/server/routers/screenshot.js +32 -0
- package/dist/server-manager/core.js +186 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +209 -0
- package/dist/services/build-manager.js +2 -69
- package/dist/services/index.js +21 -48
- package/dist/services/screenshot-server.js +40 -74
- package/dist/services/server-manager.js +45 -80
- package/dist/services/test-runner.js +90 -250
- package/dist/services/uploader.js +56 -358
- package/dist/tdd/core/hotspot-coverage.js +112 -0
- package/dist/tdd/core/signature.js +101 -0
- package/dist/tdd/index.js +19 -0
- package/dist/tdd/metadata/baseline-metadata.js +103 -0
- package/dist/tdd/metadata/hotspot-metadata.js +93 -0
- package/dist/tdd/services/baseline-downloader.js +151 -0
- package/dist/tdd/services/baseline-manager.js +166 -0
- package/dist/tdd/services/comparison-service.js +230 -0
- package/dist/tdd/services/hotspot-service.js +71 -0
- package/dist/tdd/services/result-service.js +123 -0
- package/dist/tdd/tdd-service.js +1145 -0
- package/dist/test-runner/core.js +255 -0
- package/dist/test-runner/index.js +13 -0
- package/dist/test-runner/operations.js +483 -0
- package/dist/types/client.d.ts +25 -2
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/colors.js +187 -39
- package/dist/utils/config-loader.js +3 -6
- package/dist/utils/context.js +228 -0
- package/dist/utils/output.js +449 -14
- package/docs/api-reference.md +173 -8
- package/docs/tui-elements.md +560 -0
- package/package.json +13 -13
- package/dist/services/api-service.js +0 -412
- package/dist/services/auth-service.js +0 -226
- package/dist/services/config-service.js +0 -369
- package/dist/services/html-report-generator.js +0 -455
- package/dist/services/project-service.js +0 -326
- package/dist/services/report-generator/report.css +0 -411
- package/dist/services/report-generator/viewer.js +0 -102
- package/dist/services/static-report-generator.js +0 -207
- package/dist/services/tdd-service.js +0 -1437
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* API Service for Vizzly
|
|
3
|
-
* Handles HTTP requests to the Vizzly API
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import crypto from 'node:crypto';
|
|
7
|
-
import { URLSearchParams } from 'node:url';
|
|
8
|
-
import { AuthError, VizzlyError } from '../errors/vizzly-error.js';
|
|
9
|
-
import { getApiToken, getApiUrl, getUserAgent } from '../utils/environment-config.js';
|
|
10
|
-
import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
|
|
11
|
-
import { getPackageVersion } from '../utils/package-info.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* ApiService class for direct API communication
|
|
15
|
-
*/
|
|
16
|
-
export class ApiService {
|
|
17
|
-
constructor(options = {}) {
|
|
18
|
-
// Accept config as-is, no fallbacks to environment
|
|
19
|
-
// Config-loader handles all env/file resolution
|
|
20
|
-
this.baseUrl = options.apiUrl || options.baseUrl || getApiUrl();
|
|
21
|
-
this.token = options.apiKey || options.token || getApiToken(); // Accept both apiKey and token
|
|
22
|
-
this.uploadAll = options.uploadAll || false;
|
|
23
|
-
|
|
24
|
-
// Build User-Agent string
|
|
25
|
-
const command = options.command || 'run';
|
|
26
|
-
const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`;
|
|
27
|
-
const sdkUserAgent = options.userAgent || getUserAgent();
|
|
28
|
-
this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
|
|
29
|
-
if (!this.token && !options.allowNoToken) {
|
|
30
|
-
throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or link a project in the TDD dashboard.');
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Make an API request
|
|
36
|
-
* @param {string} endpoint - API endpoint
|
|
37
|
-
* @param {Object} options - Fetch options
|
|
38
|
-
* @param {boolean} isRetry - Internal flag to prevent infinite retry loops
|
|
39
|
-
* @returns {Promise<Object>} Response data
|
|
40
|
-
*/
|
|
41
|
-
async request(endpoint, options = {}, isRetry = false) {
|
|
42
|
-
const url = `${this.baseUrl}${endpoint}`;
|
|
43
|
-
const headers = {
|
|
44
|
-
'User-Agent': this.userAgent,
|
|
45
|
-
...options.headers
|
|
46
|
-
};
|
|
47
|
-
if (this.token) {
|
|
48
|
-
headers.Authorization = `Bearer ${this.token}`;
|
|
49
|
-
}
|
|
50
|
-
const response = await fetch(url, {
|
|
51
|
-
...options,
|
|
52
|
-
headers
|
|
53
|
-
});
|
|
54
|
-
if (!response.ok) {
|
|
55
|
-
let errorText = '';
|
|
56
|
-
try {
|
|
57
|
-
if (typeof response.text === 'function') {
|
|
58
|
-
errorText = await response.text();
|
|
59
|
-
} else {
|
|
60
|
-
errorText = response.statusText || '';
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// ignore
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Handle authentication errors with automatic token refresh
|
|
67
|
-
if (response.status === 401 && !isRetry) {
|
|
68
|
-
// Attempt to refresh token if we have refresh token in global config
|
|
69
|
-
const auth = await getAuthTokens();
|
|
70
|
-
if (auth?.refreshToken) {
|
|
71
|
-
try {
|
|
72
|
-
// Attempt token refresh
|
|
73
|
-
const refreshResponse = await fetch(`${this.baseUrl}/api/auth/cli/refresh`, {
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: {
|
|
76
|
-
'Content-Type': 'application/json',
|
|
77
|
-
'User-Agent': this.userAgent
|
|
78
|
-
},
|
|
79
|
-
body: JSON.stringify({
|
|
80
|
-
refreshToken: auth.refreshToken
|
|
81
|
-
})
|
|
82
|
-
});
|
|
83
|
-
if (refreshResponse.ok) {
|
|
84
|
-
const refreshData = await refreshResponse.json();
|
|
85
|
-
|
|
86
|
-
// Save new tokens to global config
|
|
87
|
-
await saveAuthTokens({
|
|
88
|
-
accessToken: refreshData.accessToken,
|
|
89
|
-
refreshToken: refreshData.refreshToken,
|
|
90
|
-
expiresAt: refreshData.expiresAt,
|
|
91
|
-
user: auth.user // Keep existing user data
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// Update token for this service instance
|
|
95
|
-
this.token = refreshData.accessToken;
|
|
96
|
-
|
|
97
|
-
// Retry the original request with new token
|
|
98
|
-
return this.request(endpoint, options, true);
|
|
99
|
-
}
|
|
100
|
-
} catch {
|
|
101
|
-
// Token refresh failed, fall through to auth error
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
|
|
105
|
-
}
|
|
106
|
-
if (response.status === 401) {
|
|
107
|
-
throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
|
|
108
|
-
}
|
|
109
|
-
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
|
|
110
|
-
}
|
|
111
|
-
return response.json();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Get build information
|
|
116
|
-
* @param {string} buildId - Build ID
|
|
117
|
-
* @param {string} include - Optional include parameter (e.g., 'screenshots')
|
|
118
|
-
* @returns {Promise<Object>} Build data
|
|
119
|
-
*/
|
|
120
|
-
async getBuild(buildId, include = null) {
|
|
121
|
-
const endpoint = include ? `/api/sdk/builds/${buildId}?include=${include}` : `/api/sdk/builds/${buildId}`;
|
|
122
|
-
return this.request(endpoint);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Get TDD baselines for a build
|
|
127
|
-
* Returns screenshots with pre-computed filenames for baseline download
|
|
128
|
-
* @param {string} buildId - Build ID
|
|
129
|
-
* @returns {Promise<Object>} { build, screenshots, signatureProperties }
|
|
130
|
-
*/
|
|
131
|
-
async getTddBaselines(buildId) {
|
|
132
|
-
return this.request(`/api/sdk/builds/${buildId}/tdd-baselines`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Get comparison information
|
|
137
|
-
* @param {string} comparisonId - Comparison ID
|
|
138
|
-
* @returns {Promise<Object>} Comparison data
|
|
139
|
-
*/
|
|
140
|
-
async getComparison(comparisonId) {
|
|
141
|
-
const response = await this.request(`/api/sdk/comparisons/${comparisonId}`);
|
|
142
|
-
return response.comparison;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Search for comparisons by name across builds
|
|
147
|
-
* @param {string} name - Screenshot name to search for
|
|
148
|
-
* @param {Object} filters - Optional filters (branch, limit, offset)
|
|
149
|
-
* @param {string} [filters.branch] - Filter by branch name
|
|
150
|
-
* @param {number} [filters.limit=50] - Maximum number of results (default: 50)
|
|
151
|
-
* @param {number} [filters.offset=0] - Pagination offset (default: 0)
|
|
152
|
-
* @returns {Promise<Object>} Search results with comparisons and pagination
|
|
153
|
-
*/
|
|
154
|
-
async searchComparisons(name, filters = {}) {
|
|
155
|
-
if (!name || typeof name !== 'string') {
|
|
156
|
-
throw new VizzlyError('name is required and must be a non-empty string');
|
|
157
|
-
}
|
|
158
|
-
const {
|
|
159
|
-
branch,
|
|
160
|
-
limit = 50,
|
|
161
|
-
offset = 0
|
|
162
|
-
} = filters;
|
|
163
|
-
const queryParams = new URLSearchParams({
|
|
164
|
-
name,
|
|
165
|
-
limit: String(limit),
|
|
166
|
-
offset: String(offset)
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Only add branch if provided
|
|
170
|
-
if (branch) queryParams.append('branch', branch);
|
|
171
|
-
return this.request(`/api/sdk/comparisons/search?${queryParams}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Get builds for a project
|
|
176
|
-
* @param {Object} filters - Filter options
|
|
177
|
-
* @returns {Promise<Array>} List of builds
|
|
178
|
-
*/
|
|
179
|
-
async getBuilds(filters = {}) {
|
|
180
|
-
const queryParams = new URLSearchParams(filters).toString();
|
|
181
|
-
const endpoint = `/api/sdk/builds${queryParams ? `?${queryParams}` : ''}`;
|
|
182
|
-
return this.request(endpoint);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Create a new build
|
|
187
|
-
* @param {Object} metadata - Build metadata
|
|
188
|
-
* @returns {Promise<Object>} Created build data
|
|
189
|
-
*/
|
|
190
|
-
async createBuild(metadata) {
|
|
191
|
-
return this.request('/api/sdk/builds', {
|
|
192
|
-
method: 'POST',
|
|
193
|
-
headers: {
|
|
194
|
-
'Content-Type': 'application/json'
|
|
195
|
-
},
|
|
196
|
-
body: JSON.stringify({
|
|
197
|
-
build: metadata
|
|
198
|
-
})
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Check if SHAs already exist on the server
|
|
204
|
-
* @param {string[]|Object[]} shas - Array of SHA256 hashes to check, or array of screenshot objects with metadata
|
|
205
|
-
* @param {string} buildId - Build ID for screenshot record creation
|
|
206
|
-
* @returns {Promise<Object>} Response with existing SHAs and screenshot data
|
|
207
|
-
*/
|
|
208
|
-
async checkShas(shas, buildId) {
|
|
209
|
-
try {
|
|
210
|
-
let requestBody;
|
|
211
|
-
|
|
212
|
-
// Check if we're using the new signature-based format (array of objects) or legacy format (array of strings)
|
|
213
|
-
if (Array.isArray(shas) && shas.length > 0 && typeof shas[0] === 'object' && shas[0].sha256) {
|
|
214
|
-
// New signature-based format
|
|
215
|
-
requestBody = {
|
|
216
|
-
buildId,
|
|
217
|
-
screenshots: shas
|
|
218
|
-
};
|
|
219
|
-
} else {
|
|
220
|
-
// Legacy SHA-only format
|
|
221
|
-
requestBody = {
|
|
222
|
-
shas,
|
|
223
|
-
buildId
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
const response = await this.request('/api/sdk/check-shas', {
|
|
227
|
-
method: 'POST',
|
|
228
|
-
headers: {
|
|
229
|
-
'Content-Type': 'application/json'
|
|
230
|
-
},
|
|
231
|
-
body: JSON.stringify(requestBody)
|
|
232
|
-
});
|
|
233
|
-
return response;
|
|
234
|
-
} catch (error) {
|
|
235
|
-
// Continue without deduplication on error
|
|
236
|
-
console.debug('SHA check failed, continuing without deduplication:', error.message);
|
|
237
|
-
// Extract SHAs for fallback response regardless of format
|
|
238
|
-
const shaList = Array.isArray(shas) && shas.length > 0 && typeof shas[0] === 'object' ? shas.map(s => s.sha256) : shas;
|
|
239
|
-
return {
|
|
240
|
-
existing: [],
|
|
241
|
-
missing: shaList,
|
|
242
|
-
screenshots: []
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Upload a screenshot with SHA checking
|
|
249
|
-
* @param {string} buildId - Build ID
|
|
250
|
-
* @param {string} name - Screenshot name
|
|
251
|
-
* @param {Buffer} buffer - Screenshot data
|
|
252
|
-
* @param {Object} metadata - Additional metadata
|
|
253
|
-
* @returns {Promise<Object>} Upload result
|
|
254
|
-
*/
|
|
255
|
-
async uploadScreenshot(buildId, name, buffer, metadata = {}) {
|
|
256
|
-
// Skip SHA deduplication entirely if uploadAll flag is set
|
|
257
|
-
if (this.uploadAll) {
|
|
258
|
-
// Upload directly without SHA calculation or checking
|
|
259
|
-
return this.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
260
|
-
method: 'POST',
|
|
261
|
-
headers: {
|
|
262
|
-
'Content-Type': 'application/json'
|
|
263
|
-
},
|
|
264
|
-
body: JSON.stringify({
|
|
265
|
-
name,
|
|
266
|
-
image_data: buffer.toString('base64'),
|
|
267
|
-
properties: metadata ?? {}
|
|
268
|
-
// No SHA included when bypassing deduplication
|
|
269
|
-
})
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Normal flow with SHA deduplication using signature-based format
|
|
274
|
-
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
275
|
-
|
|
276
|
-
// Create screenshot object with signature data for checking
|
|
277
|
-
const screenshotCheck = [{
|
|
278
|
-
sha256,
|
|
279
|
-
name,
|
|
280
|
-
browser: metadata?.browser || 'chrome',
|
|
281
|
-
viewport_width: metadata?.viewport?.width || 1920,
|
|
282
|
-
viewport_height: metadata?.viewport?.height || 1080
|
|
283
|
-
}];
|
|
284
|
-
|
|
285
|
-
// Check if this SHA with signature already exists
|
|
286
|
-
const checkResult = await this.checkShas(screenshotCheck, buildId);
|
|
287
|
-
if (checkResult.existing?.includes(sha256)) {
|
|
288
|
-
// File already exists with same signature, screenshot record was automatically created
|
|
289
|
-
const screenshot = checkResult.screenshots?.find(s => s.sha256 === sha256);
|
|
290
|
-
return {
|
|
291
|
-
message: 'Screenshot already exists, skipped upload',
|
|
292
|
-
sha256,
|
|
293
|
-
skipped: true,
|
|
294
|
-
screenshot,
|
|
295
|
-
fromExisting: true
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// File doesn't exist or has different signature, proceed with upload
|
|
300
|
-
return this.request(`/api/sdk/builds/${buildId}/screenshots`, {
|
|
301
|
-
method: 'POST',
|
|
302
|
-
headers: {
|
|
303
|
-
'Content-Type': 'application/json'
|
|
304
|
-
},
|
|
305
|
-
body: JSON.stringify({
|
|
306
|
-
name,
|
|
307
|
-
image_data: buffer.toString('base64'),
|
|
308
|
-
properties: metadata ?? {},
|
|
309
|
-
sha256 // Include SHA for server-side deduplication
|
|
310
|
-
})
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Update build status
|
|
316
|
-
* @param {string} buildId - Build ID
|
|
317
|
-
* @param {string} status - Build status (pending|running|completed|failed)
|
|
318
|
-
* @param {number} executionTimeMs - Execution time in milliseconds
|
|
319
|
-
* @returns {Promise<Object>} Updated build data
|
|
320
|
-
*/
|
|
321
|
-
async updateBuildStatus(buildId, status, executionTimeMs = null) {
|
|
322
|
-
const body = {
|
|
323
|
-
status
|
|
324
|
-
};
|
|
325
|
-
if (executionTimeMs !== null) {
|
|
326
|
-
body.executionTimeMs = executionTimeMs;
|
|
327
|
-
}
|
|
328
|
-
return this.request(`/api/sdk/builds/${buildId}/status`, {
|
|
329
|
-
method: 'PUT',
|
|
330
|
-
headers: {
|
|
331
|
-
'Content-Type': 'application/json'
|
|
332
|
-
},
|
|
333
|
-
body: JSON.stringify(body)
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Finalize a build (convenience method)
|
|
339
|
-
* @param {string} buildId - Build ID
|
|
340
|
-
* @param {boolean} success - Whether the build succeeded
|
|
341
|
-
* @param {number} executionTimeMs - Execution time in milliseconds
|
|
342
|
-
* @returns {Promise<Object>} Finalized build data
|
|
343
|
-
*/
|
|
344
|
-
async finalizeBuild(buildId, success = true, executionTimeMs = null) {
|
|
345
|
-
const status = success ? 'completed' : 'failed';
|
|
346
|
-
return this.updateBuildStatus(buildId, status, executionTimeMs);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Get token context (organization and project info)
|
|
351
|
-
* @returns {Promise<Object>} Token context data
|
|
352
|
-
*/
|
|
353
|
-
async getTokenContext() {
|
|
354
|
-
return this.request('/api/sdk/token/context');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Finalize a parallel build
|
|
359
|
-
* @param {string} parallelId - Parallel ID to finalize
|
|
360
|
-
* @returns {Promise<Object>} Finalization result
|
|
361
|
-
*/
|
|
362
|
-
async finalizeParallelBuild(parallelId) {
|
|
363
|
-
return this.request(`/api/sdk/parallel/${parallelId}/finalize`, {
|
|
364
|
-
method: 'POST',
|
|
365
|
-
headers: {
|
|
366
|
-
'Content-Type': 'application/json'
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Get hotspot analysis for a single screenshot
|
|
373
|
-
* @param {string} screenshotName - Screenshot name to get hotspots for
|
|
374
|
-
* @param {Object} options - Optional settings
|
|
375
|
-
* @param {number} [options.windowSize=20] - Number of historical builds to analyze
|
|
376
|
-
* @returns {Promise<Object>} Hotspot analysis data
|
|
377
|
-
*/
|
|
378
|
-
async getScreenshotHotspots(screenshotName, options = {}) {
|
|
379
|
-
const {
|
|
380
|
-
windowSize = 20
|
|
381
|
-
} = options;
|
|
382
|
-
const queryParams = new URLSearchParams({
|
|
383
|
-
windowSize: String(windowSize)
|
|
384
|
-
});
|
|
385
|
-
const encodedName = encodeURIComponent(screenshotName);
|
|
386
|
-
return this.request(`/api/sdk/screenshots/${encodedName}/hotspots?${queryParams}`);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Batch get hotspot analysis for multiple screenshots
|
|
391
|
-
* More efficient than calling getScreenshotHotspots for each screenshot
|
|
392
|
-
* @param {string[]} screenshotNames - Array of screenshot names
|
|
393
|
-
* @param {Object} options - Optional settings
|
|
394
|
-
* @param {number} [options.windowSize=20] - Number of historical builds to analyze
|
|
395
|
-
* @returns {Promise<Object>} Hotspots keyed by screenshot name
|
|
396
|
-
*/
|
|
397
|
-
async getBatchHotspots(screenshotNames, options = {}) {
|
|
398
|
-
const {
|
|
399
|
-
windowSize = 20
|
|
400
|
-
} = options;
|
|
401
|
-
return this.request('/api/sdk/screenshots/hotspots', {
|
|
402
|
-
method: 'POST',
|
|
403
|
-
headers: {
|
|
404
|
-
'Content-Type': 'application/json'
|
|
405
|
-
},
|
|
406
|
-
body: JSON.stringify({
|
|
407
|
-
screenshot_names: screenshotNames,
|
|
408
|
-
windowSize
|
|
409
|
-
})
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Authentication Service for Vizzly CLI
|
|
3
|
-
* Handles authentication flows with the Vizzly API
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { AuthError, VizzlyError } from '../errors/vizzly-error.js';
|
|
7
|
-
import { getApiUrl } from '../utils/environment-config.js';
|
|
8
|
-
import { clearAuthTokens, getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
|
|
9
|
-
import { getPackageVersion } from '../utils/package-info.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* AuthService class for CLI authentication
|
|
13
|
-
*/
|
|
14
|
-
export class AuthService {
|
|
15
|
-
constructor(options = {}) {
|
|
16
|
-
this.baseUrl = options.baseUrl || getApiUrl();
|
|
17
|
-
this.userAgent = `vizzly-cli/${getPackageVersion()} (auth)`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Make an unauthenticated API request
|
|
22
|
-
* @param {string} endpoint - API endpoint
|
|
23
|
-
* @param {Object} options - Fetch options
|
|
24
|
-
* @returns {Promise<Object>} Response data
|
|
25
|
-
*/
|
|
26
|
-
async request(endpoint, options = {}) {
|
|
27
|
-
const url = `${this.baseUrl}${endpoint}`;
|
|
28
|
-
const headers = {
|
|
29
|
-
'User-Agent': this.userAgent,
|
|
30
|
-
...options.headers
|
|
31
|
-
};
|
|
32
|
-
const response = await fetch(url, {
|
|
33
|
-
...options,
|
|
34
|
-
headers
|
|
35
|
-
});
|
|
36
|
-
if (!response.ok) {
|
|
37
|
-
let errorText = '';
|
|
38
|
-
let errorData = null;
|
|
39
|
-
try {
|
|
40
|
-
const contentType = response.headers.get('content-type');
|
|
41
|
-
if (contentType?.includes('application/json')) {
|
|
42
|
-
errorData = await response.json();
|
|
43
|
-
errorText = errorData.error || errorData.message || '';
|
|
44
|
-
} else {
|
|
45
|
-
errorText = await response.text();
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
errorText = response.statusText || '';
|
|
49
|
-
}
|
|
50
|
-
if (response.status === 401) {
|
|
51
|
-
throw new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.');
|
|
52
|
-
}
|
|
53
|
-
if (response.status === 429) {
|
|
54
|
-
throw new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR');
|
|
55
|
-
}
|
|
56
|
-
throw new VizzlyError(`Authentication request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR');
|
|
57
|
-
}
|
|
58
|
-
return response.json();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Make an authenticated API request
|
|
63
|
-
* @param {string} endpoint - API endpoint
|
|
64
|
-
* @param {Object} options - Fetch options
|
|
65
|
-
* @returns {Promise<Object>} Response data
|
|
66
|
-
*/
|
|
67
|
-
async authenticatedRequest(endpoint, options = {}) {
|
|
68
|
-
const auth = await getAuthTokens();
|
|
69
|
-
if (!auth || !auth.accessToken) {
|
|
70
|
-
throw new AuthError('No authentication token found. Please run "vizzly login" first.');
|
|
71
|
-
}
|
|
72
|
-
const url = `${this.baseUrl}${endpoint}`;
|
|
73
|
-
const headers = {
|
|
74
|
-
'User-Agent': this.userAgent,
|
|
75
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
76
|
-
...options.headers
|
|
77
|
-
};
|
|
78
|
-
const response = await fetch(url, {
|
|
79
|
-
...options,
|
|
80
|
-
headers
|
|
81
|
-
});
|
|
82
|
-
if (!response.ok) {
|
|
83
|
-
let errorText = '';
|
|
84
|
-
try {
|
|
85
|
-
const contentType = response.headers.get('content-type');
|
|
86
|
-
if (contentType?.includes('application/json')) {
|
|
87
|
-
const errorData = await response.json();
|
|
88
|
-
errorText = errorData.error || errorData.message || '';
|
|
89
|
-
} else {
|
|
90
|
-
errorText = await response.text();
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
errorText = response.statusText || '';
|
|
94
|
-
}
|
|
95
|
-
if (response.status === 401) {
|
|
96
|
-
throw new AuthError('Authentication token is invalid or expired. Please run "vizzly login" again.');
|
|
97
|
-
}
|
|
98
|
-
throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (${endpoint})`, 'API_REQUEST_ERROR');
|
|
99
|
-
}
|
|
100
|
-
return response.json();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Initiate OAuth device flow
|
|
105
|
-
* @returns {Promise<Object>} Device code, user code, verification URL
|
|
106
|
-
*/
|
|
107
|
-
async initiateDeviceFlow() {
|
|
108
|
-
return this.request('/api/auth/cli/device/initiate', {
|
|
109
|
-
method: 'POST',
|
|
110
|
-
headers: {
|
|
111
|
-
'Content-Type': 'application/json'
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Poll for device authorization
|
|
118
|
-
* @param {string} deviceCode - Device code from initiate
|
|
119
|
-
* @returns {Promise<Object>} Token data or pending status
|
|
120
|
-
*/
|
|
121
|
-
async pollDeviceAuthorization(deviceCode) {
|
|
122
|
-
return this.request('/api/auth/cli/device/poll', {
|
|
123
|
-
method: 'POST',
|
|
124
|
-
headers: {
|
|
125
|
-
'Content-Type': 'application/json'
|
|
126
|
-
},
|
|
127
|
-
body: JSON.stringify({
|
|
128
|
-
device_code: deviceCode
|
|
129
|
-
})
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Complete device flow and save tokens
|
|
135
|
-
* @param {Object} tokenData - Token response from poll
|
|
136
|
-
* @returns {Promise<Object>} Token data with user info
|
|
137
|
-
*/
|
|
138
|
-
async completeDeviceFlow(tokenData) {
|
|
139
|
-
// Save tokens to global config
|
|
140
|
-
await saveAuthTokens({
|
|
141
|
-
accessToken: tokenData.accessToken,
|
|
142
|
-
refreshToken: tokenData.refreshToken,
|
|
143
|
-
expiresAt: tokenData.expiresAt,
|
|
144
|
-
user: tokenData.user
|
|
145
|
-
});
|
|
146
|
-
return tokenData;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Refresh access token using refresh token
|
|
151
|
-
* @returns {Promise<Object>} New tokens
|
|
152
|
-
*/
|
|
153
|
-
async refresh() {
|
|
154
|
-
const auth = await getAuthTokens();
|
|
155
|
-
if (!auth || !auth.refreshToken) {
|
|
156
|
-
throw new AuthError('No refresh token found. Please run "vizzly login" first.');
|
|
157
|
-
}
|
|
158
|
-
const response = await this.request('/api/auth/cli/refresh', {
|
|
159
|
-
method: 'POST',
|
|
160
|
-
headers: {
|
|
161
|
-
'Content-Type': 'application/json'
|
|
162
|
-
},
|
|
163
|
-
body: JSON.stringify({
|
|
164
|
-
refreshToken: auth.refreshToken
|
|
165
|
-
})
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// Update tokens in global config
|
|
169
|
-
await saveAuthTokens({
|
|
170
|
-
accessToken: response.accessToken,
|
|
171
|
-
refreshToken: response.refreshToken,
|
|
172
|
-
expiresAt: response.expiresAt,
|
|
173
|
-
user: auth.user // Keep existing user data
|
|
174
|
-
});
|
|
175
|
-
return response;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Logout and revoke tokens
|
|
180
|
-
* @returns {Promise<void>}
|
|
181
|
-
*/
|
|
182
|
-
async logout() {
|
|
183
|
-
const auth = await getAuthTokens();
|
|
184
|
-
if (auth?.refreshToken) {
|
|
185
|
-
try {
|
|
186
|
-
// Attempt to revoke tokens on server
|
|
187
|
-
await this.request('/api/auth/cli/logout', {
|
|
188
|
-
method: 'POST',
|
|
189
|
-
headers: {
|
|
190
|
-
'Content-Type': 'application/json'
|
|
191
|
-
},
|
|
192
|
-
body: JSON.stringify({
|
|
193
|
-
refreshToken: auth.refreshToken
|
|
194
|
-
})
|
|
195
|
-
});
|
|
196
|
-
} catch (error) {
|
|
197
|
-
// If server request fails, still clear local tokens
|
|
198
|
-
console.warn('Warning: Failed to revoke tokens on server:', error.message);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Clear tokens from global config
|
|
203
|
-
await clearAuthTokens();
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Get current user information
|
|
208
|
-
* @returns {Promise<Object>} User and organization data
|
|
209
|
-
*/
|
|
210
|
-
async whoami() {
|
|
211
|
-
return this.authenticatedRequest('/api/auth/cli/whoami');
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Check if user is authenticated
|
|
216
|
-
* @returns {Promise<boolean>} True if authenticated
|
|
217
|
-
*/
|
|
218
|
-
async isAuthenticated() {
|
|
219
|
-
try {
|
|
220
|
-
await this.whoami();
|
|
221
|
-
return true;
|
|
222
|
-
} catch {
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|