@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
|
@@ -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
|
+
}
|
package/dist/api/core.js
ADDED
|
@@ -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
|
+
}
|