@vizzly-testing/cli 0.20.0 → 0.20.1-beta.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.
Files changed (72) hide show
  1. package/dist/api/client.js +134 -0
  2. package/dist/api/core.js +341 -0
  3. package/dist/api/endpoints.js +314 -0
  4. package/dist/api/index.js +19 -0
  5. package/dist/auth/client.js +91 -0
  6. package/dist/auth/core.js +176 -0
  7. package/dist/auth/index.js +30 -0
  8. package/dist/auth/operations.js +148 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/doctor.js +3 -3
  11. package/dist/commands/finalize.js +41 -15
  12. package/dist/commands/login.js +7 -6
  13. package/dist/commands/logout.js +4 -4
  14. package/dist/commands/project.js +5 -4
  15. package/dist/commands/run.js +158 -90
  16. package/dist/commands/status.js +22 -18
  17. package/dist/commands/tdd.js +105 -78
  18. package/dist/commands/upload.js +61 -26
  19. package/dist/commands/whoami.js +4 -4
  20. package/dist/config/core.js +438 -0
  21. package/dist/config/index.js +13 -0
  22. package/dist/config/operations.js +327 -0
  23. package/dist/index.js +1 -1
  24. package/dist/project/core.js +295 -0
  25. package/dist/project/index.js +13 -0
  26. package/dist/project/operations.js +393 -0
  27. package/dist/report-generator/core.js +315 -0
  28. package/dist/report-generator/index.js +8 -0
  29. package/dist/report-generator/operations.js +196 -0
  30. package/dist/reporter/reporter-bundle.iife.js +16 -16
  31. package/dist/screenshot-server/core.js +157 -0
  32. package/dist/screenshot-server/index.js +11 -0
  33. package/dist/screenshot-server/operations.js +183 -0
  34. package/dist/sdk/index.js +3 -2
  35. package/dist/server/handlers/api-handler.js +14 -5
  36. package/dist/server/handlers/tdd-handler.js +80 -48
  37. package/dist/server-manager/core.js +183 -0
  38. package/dist/server-manager/index.js +81 -0
  39. package/dist/server-manager/operations.js +208 -0
  40. package/dist/services/build-manager.js +2 -69
  41. package/dist/services/index.js +21 -48
  42. package/dist/services/screenshot-server.js +40 -74
  43. package/dist/services/server-manager.js +45 -80
  44. package/dist/services/static-report-generator.js +21 -163
  45. package/dist/services/test-runner.js +90 -250
  46. package/dist/services/uploader.js +56 -358
  47. package/dist/tdd/core/hotspot-coverage.js +112 -0
  48. package/dist/tdd/core/signature.js +101 -0
  49. package/dist/tdd/index.js +19 -0
  50. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  51. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  52. package/dist/tdd/services/baseline-downloader.js +151 -0
  53. package/dist/tdd/services/baseline-manager.js +166 -0
  54. package/dist/tdd/services/comparison-service.js +230 -0
  55. package/dist/tdd/services/hotspot-service.js +71 -0
  56. package/dist/tdd/services/result-service.js +123 -0
  57. package/dist/tdd/tdd-service.js +1081 -0
  58. package/dist/test-runner/core.js +255 -0
  59. package/dist/test-runner/index.js +13 -0
  60. package/dist/test-runner/operations.js +483 -0
  61. package/dist/uploader/core.js +396 -0
  62. package/dist/uploader/index.js +11 -0
  63. package/dist/uploader/operations.js +412 -0
  64. package/package.json +7 -12
  65. package/dist/services/api-service.js +0 -412
  66. package/dist/services/auth-service.js +0 -226
  67. package/dist/services/config-service.js +0 -369
  68. package/dist/services/html-report-generator.js +0 -455
  69. package/dist/services/project-service.js +0 -326
  70. package/dist/services/report-generator/report.css +0 -411
  71. package/dist/services/report-generator/viewer.js +0 -102
  72. package/dist/services/tdd-service.js +0 -1437
@@ -0,0 +1,134 @@
1
+ /**
2
+ * API Client Factory
3
+ *
4
+ * Creates a configured API client for making HTTP requests to Vizzly.
5
+ * The client handles authentication, token refresh, and error handling.
6
+ */
7
+
8
+ import { AuthError, VizzlyError } from '../errors/vizzly-error.js';
9
+ import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
10
+ import { getPackageVersion } from '../utils/package-info.js';
11
+ import { buildApiUrl, buildRequestHeaders, buildUserAgent, extractErrorBody, isAuthError, parseApiError, shouldRetryWithRefresh } from './core.js';
12
+
13
+ /**
14
+ * Default API URL
15
+ */
16
+ export const DEFAULT_API_URL = 'https://app.vizzly.dev';
17
+
18
+ /**
19
+ * Create an API client with the given configuration
20
+ *
21
+ * @param {Object} options - Client options
22
+ * @param {string} options.baseUrl - Base API URL
23
+ * @param {string} options.token - API token (apiKey)
24
+ * @param {string} options.command - Command name for user agent
25
+ * @param {string} options.sdkUserAgent - Optional SDK user agent string
26
+ * @param {boolean} options.allowNoToken - Allow requests without token
27
+ * @returns {Object} API client with request method
28
+ */
29
+ export function createApiClient(options = {}) {
30
+ let baseUrl = options.baseUrl || options.apiUrl || DEFAULT_API_URL;
31
+ let token = options.token || options.apiKey || null;
32
+ let command = options.command || 'api';
33
+ let version = getPackageVersion();
34
+ let userAgent = buildUserAgent(version, command, options.sdkUserAgent || options.userAgent);
35
+ let allowNoToken = options.allowNoToken || false;
36
+
37
+ // Validate token requirement
38
+ if (!token && !allowNoToken) {
39
+ throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or link a project in the TDD dashboard.');
40
+ }
41
+
42
+ /**
43
+ * Make an API request
44
+ *
45
+ * @param {string} endpoint - API endpoint (e.g., '/api/sdk/builds')
46
+ * @param {Object} fetchOptions - Fetch options (method, body, headers, etc.)
47
+ * @param {boolean} isRetry - Whether this is a retry after token refresh
48
+ * @returns {Promise<Object>} Parsed JSON response
49
+ */
50
+ async function request(endpoint, fetchOptions = {}, isRetry = false) {
51
+ let url = buildApiUrl(baseUrl, endpoint);
52
+ let headers = buildRequestHeaders({
53
+ token,
54
+ userAgent,
55
+ contentType: fetchOptions.headers?.['Content-Type'],
56
+ extra: fetchOptions.headers || {}
57
+ });
58
+ let response = await fetch(url, {
59
+ ...fetchOptions,
60
+ headers
61
+ });
62
+ if (!response.ok) {
63
+ let errorBody = await extractErrorBody(response);
64
+
65
+ // Handle 401 with token refresh
66
+ if (shouldRetryWithRefresh(response.status, isRetry, await hasRefreshToken())) {
67
+ let refreshed = await attemptTokenRefresh();
68
+ if (refreshed) {
69
+ token = refreshed;
70
+ return request(endpoint, fetchOptions, true);
71
+ }
72
+ }
73
+
74
+ // Auth error
75
+ if (isAuthError(response.status)) {
76
+ throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
77
+ }
78
+
79
+ // Other errors
80
+ let error = parseApiError(response.status, errorBody, url);
81
+ throw new VizzlyError(error.message, error.code);
82
+ }
83
+ return response.json();
84
+ }
85
+
86
+ /**
87
+ * Check if refresh token is available
88
+ */
89
+ async function hasRefreshToken() {
90
+ let auth = await getAuthTokens();
91
+ return !!auth?.refreshToken;
92
+ }
93
+
94
+ /**
95
+ * Attempt to refresh the access token
96
+ * @returns {Promise<string|null>} New token or null if refresh failed
97
+ */
98
+ async function attemptTokenRefresh() {
99
+ let auth = await getAuthTokens();
100
+ if (!auth?.refreshToken) return null;
101
+ try {
102
+ let refreshUrl = buildApiUrl(baseUrl, '/api/auth/cli/refresh');
103
+ let response = await fetch(refreshUrl, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ 'User-Agent': userAgent
108
+ },
109
+ body: JSON.stringify({
110
+ refreshToken: auth.refreshToken
111
+ })
112
+ });
113
+ if (!response.ok) return null;
114
+ let data = await response.json();
115
+
116
+ // Save new tokens
117
+ await saveAuthTokens({
118
+ accessToken: data.accessToken,
119
+ refreshToken: data.refreshToken,
120
+ expiresAt: data.expiresAt,
121
+ user: auth.user
122
+ });
123
+ return data.accessToken;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ return {
129
+ request,
130
+ getBaseUrl: () => baseUrl,
131
+ getToken: () => token,
132
+ getUserAgent: () => userAgent
133
+ };
134
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * API Core - Pure functions for building requests and parsing responses
3
+ *
4
+ * These functions have no side effects and are trivially testable.
5
+ * They handle header construction, payload building, error parsing, and SHA computation.
6
+ */
7
+
8
+ import crypto from 'node:crypto';
9
+ import { URLSearchParams } from 'node:url';
10
+
11
+ // ============================================================================
12
+ // Header Building
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Build Authorization header for Bearer token auth
17
+ * @param {string|null} token - API token
18
+ * @returns {Object} Headers object with Authorization if token provided
19
+ */
20
+ export function buildAuthHeader(token) {
21
+ if (!token) return {};
22
+ return {
23
+ Authorization: `Bearer ${token}`
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Build User-Agent string from components
29
+ * @param {string} version - CLI version
30
+ * @param {string} command - Command being executed (run, upload, tdd, etc.)
31
+ * @param {string|null} sdkUserAgent - Optional SDK user agent to append
32
+ * @returns {string} Complete User-Agent string
33
+ */
34
+ export function buildUserAgent(version, command, sdkUserAgent = null) {
35
+ let baseUserAgent = `vizzly-cli/${version} (${command})`;
36
+ if (sdkUserAgent) {
37
+ return `${baseUserAgent} ${sdkUserAgent}`;
38
+ }
39
+ return baseUserAgent;
40
+ }
41
+
42
+ /**
43
+ * Build complete request headers
44
+ * @param {Object} options - Header options
45
+ * @param {string|null} options.token - API token
46
+ * @param {string} options.userAgent - User-Agent string
47
+ * @param {string|null} options.contentType - Content-Type header
48
+ * @param {Object} options.extra - Additional headers to merge
49
+ * @returns {Object} Complete headers object
50
+ */
51
+ export function buildRequestHeaders({
52
+ token,
53
+ userAgent,
54
+ contentType = null,
55
+ extra = {}
56
+ }) {
57
+ let headers = {
58
+ 'User-Agent': userAgent,
59
+ ...buildAuthHeader(token),
60
+ ...extra
61
+ };
62
+ if (contentType) {
63
+ headers['Content-Type'] = contentType;
64
+ }
65
+ return headers;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Payload Construction
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Build payload for screenshot upload
74
+ * @param {string} name - Screenshot name
75
+ * @param {Buffer} buffer - Image data
76
+ * @param {Object} metadata - Screenshot metadata (viewport, browser, etc.)
77
+ * @param {string|null} sha256 - Pre-computed SHA256 hash (optional)
78
+ * @returns {Object} Screenshot upload payload
79
+ */
80
+ export function buildScreenshotPayload(name, buffer, metadata = {}, sha256 = null) {
81
+ let payload = {
82
+ name,
83
+ image_data: buffer.toString('base64'),
84
+ properties: metadata ?? {}
85
+ };
86
+ if (sha256) {
87
+ payload.sha256 = sha256;
88
+ }
89
+ return payload;
90
+ }
91
+
92
+ /**
93
+ * Build payload for build creation
94
+ * @param {Object} options - Build options
95
+ * @returns {Object} Build creation payload
96
+ */
97
+ export function buildBuildPayload(options) {
98
+ let payload = {
99
+ name: options.name || options.buildName,
100
+ branch: options.branch,
101
+ environment: options.environment
102
+ };
103
+ if (options.commit || options.commit_sha) {
104
+ payload.commit_sha = options.commit || options.commit_sha;
105
+ }
106
+ if (options.message || options.commit_message) {
107
+ payload.commit_message = options.message || options.commit_message;
108
+ }
109
+ if (options.pullRequestNumber || options.github_pull_request_number) {
110
+ payload.github_pull_request_number = options.pullRequestNumber || options.github_pull_request_number;
111
+ }
112
+ if (options.parallelId || options.parallel_id) {
113
+ payload.parallel_id = options.parallelId || options.parallel_id;
114
+ }
115
+ if (options.threshold != null) {
116
+ payload.threshold = options.threshold;
117
+ }
118
+ if (options.metadata) {
119
+ payload.metadata = options.metadata;
120
+ }
121
+ return payload;
122
+ }
123
+
124
+ /**
125
+ * Build URL query parameters from filter object
126
+ * @param {Object} filters - Filter key-value pairs
127
+ * @returns {string} URL-encoded query string (without leading ?)
128
+ */
129
+ export function buildQueryParams(filters) {
130
+ let params = new URLSearchParams();
131
+ for (let [key, value] of Object.entries(filters)) {
132
+ if (value != null && value !== '') {
133
+ params.append(key, String(value));
134
+ }
135
+ }
136
+ return params.toString();
137
+ }
138
+
139
+ /**
140
+ * Build payload for SHA existence check (signature-based format)
141
+ * @param {Array<Object>} screenshots - Screenshots with sha256 and metadata
142
+ * @param {string} buildId - Build ID for screenshot record creation
143
+ * @returns {Object} SHA check request payload
144
+ */
145
+ export function buildShaCheckPayload(screenshots, buildId) {
146
+ // Check if using new signature-based format or legacy SHA-only format
147
+ if (screenshots.length > 0 && typeof screenshots[0] === 'object' && screenshots[0].sha256) {
148
+ return {
149
+ buildId,
150
+ screenshots
151
+ };
152
+ }
153
+
154
+ // Legacy format: array of SHA strings
155
+ return {
156
+ shas: screenshots,
157
+ buildId
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Build screenshot object for SHA checking
163
+ * @param {string} sha256 - SHA256 hash of image
164
+ * @param {string} name - Screenshot name
165
+ * @param {Object} metadata - Screenshot metadata
166
+ * @returns {Object} Screenshot check object
167
+ */
168
+ export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
169
+ let meta = metadata || {};
170
+ return {
171
+ sha256,
172
+ name,
173
+ browser: meta.browser || 'chrome',
174
+ viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
175
+ viewport_height: meta.viewport?.height || meta.viewport_height || 1080
176
+ };
177
+ }
178
+
179
+ // ============================================================================
180
+ // Response/Error Parsing
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Check if HTTP status indicates an auth error
185
+ * @param {number} status - HTTP status code
186
+ * @returns {boolean} True if auth error
187
+ */
188
+ export function isAuthError(status) {
189
+ return status === 401;
190
+ }
191
+
192
+ /**
193
+ * Check if HTTP status indicates rate limiting
194
+ * @param {number} status - HTTP status code
195
+ * @returns {boolean} True if rate limited
196
+ */
197
+ export function isRateLimited(status) {
198
+ return status === 429;
199
+ }
200
+
201
+ /**
202
+ * Determine if request should retry with token refresh
203
+ * @param {number} status - HTTP status code
204
+ * @param {boolean} isRetry - Whether this is already a retry
205
+ * @param {boolean} hasRefreshToken - Whether refresh token is available
206
+ * @returns {boolean} True if should attempt refresh
207
+ */
208
+ export function shouldRetryWithRefresh(status, isRetry, hasRefreshToken) {
209
+ return status === 401 && !isRetry && hasRefreshToken;
210
+ }
211
+
212
+ /**
213
+ * Parse error information from API response
214
+ * @param {number} status - HTTP status code
215
+ * @param {string} body - Response body text
216
+ * @param {string} url - Request URL
217
+ * @returns {Object} Parsed error info with message and code
218
+ */
219
+ export function parseApiError(status, body, url) {
220
+ let message = `API request failed: ${status}`;
221
+ if (body) {
222
+ message += ` - ${body}`;
223
+ }
224
+ message += ` (URL: ${url})`;
225
+ let code = 'API_ERROR';
226
+ if (status === 401) code = 'AUTH_ERROR';
227
+ if (status === 403) code = 'FORBIDDEN';
228
+ if (status === 404) code = 'NOT_FOUND';
229
+ if (status === 429) code = 'RATE_LIMITED';
230
+ if (status >= 500) code = 'SERVER_ERROR';
231
+ return {
232
+ message,
233
+ code,
234
+ status
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Extract error message from response body (JSON or text)
240
+ * @param {Response} response - Fetch Response object
241
+ * @returns {Promise<string>} Error message
242
+ */
243
+ export async function extractErrorBody(response) {
244
+ try {
245
+ if (typeof response.text === 'function') {
246
+ return await response.text();
247
+ }
248
+ return response.statusText || '';
249
+ } catch {
250
+ return '';
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // SHA/Hash Computation
256
+ // ============================================================================
257
+
258
+ /**
259
+ * Compute SHA256 hash of buffer
260
+ * @param {Buffer} buffer - Data to hash
261
+ * @returns {string} Hex-encoded SHA256 hash
262
+ */
263
+ export function computeSha256(buffer) {
264
+ return crypto.createHash('sha256').update(buffer).digest('hex');
265
+ }
266
+
267
+ // ============================================================================
268
+ // Deduplication Helpers
269
+ // ============================================================================
270
+
271
+ /**
272
+ * Partition screenshots by SHA existence
273
+ * @param {Array<Object>} screenshots - Screenshots with sha256 property
274
+ * @param {Set<string>|Array<string>} existingShas - SHAs that already exist
275
+ * @returns {Object} { toUpload, existing } partitioned arrays
276
+ */
277
+ export function partitionByShaExistence(screenshots, existingShas) {
278
+ let existingSet = existingShas instanceof Set ? existingShas : new Set(existingShas);
279
+ let toUpload = [];
280
+ let existing = [];
281
+ for (let screenshot of screenshots) {
282
+ if (existingSet.has(screenshot.sha256)) {
283
+ existing.push(screenshot);
284
+ } else {
285
+ toUpload.push(screenshot);
286
+ }
287
+ }
288
+ return {
289
+ toUpload,
290
+ existing
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Check if SHA check result indicates file exists
296
+ * @param {Object} checkResult - Result from checkShas endpoint
297
+ * @param {string} sha256 - SHA to check
298
+ * @returns {boolean} True if file exists
299
+ */
300
+ export function shaExists(checkResult, sha256) {
301
+ return checkResult?.existing?.includes(sha256) ?? false;
302
+ }
303
+
304
+ /**
305
+ * Find screenshot record from SHA check result
306
+ * @param {Object} checkResult - Result from checkShas endpoint
307
+ * @param {string} sha256 - SHA to find
308
+ * @returns {Object|null} Screenshot record or null
309
+ */
310
+ export function findScreenshotBySha(checkResult, sha256) {
311
+ return checkResult?.screenshots?.find(s => s.sha256 === sha256) ?? null;
312
+ }
313
+
314
+ // ============================================================================
315
+ // URL Building
316
+ // ============================================================================
317
+
318
+ /**
319
+ * Build full API URL from base and endpoint
320
+ * @param {string} baseUrl - Base API URL
321
+ * @param {string} endpoint - API endpoint (should start with /)
322
+ * @returns {string} Full URL
323
+ */
324
+ export function buildApiUrl(baseUrl, endpoint) {
325
+ // Remove trailing slash from base, ensure endpoint starts with /
326
+ let base = baseUrl.replace(/\/$/, '');
327
+ let path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
328
+ return `${base}${path}`;
329
+ }
330
+
331
+ /**
332
+ * Build endpoint URL with optional query params
333
+ * @param {string} endpoint - Base endpoint
334
+ * @param {Object} params - Query parameters
335
+ * @returns {string} Endpoint with query string
336
+ */
337
+ export function buildEndpointWithParams(endpoint, params = {}) {
338
+ let query = buildQueryParams(params);
339
+ if (!query) return endpoint;
340
+ return `${endpoint}?${query}`;
341
+ }