qase-javascript-commons 2.4.11 → 2.4.13
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/changelog.md +13 -0
- package/dist/client/clientV1.d.ts +15 -2
- package/dist/client/clientV1.js +153 -19
- package/package.json +3 -3
package/changelog.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# qase-javascript-commons@2.4.13
|
|
2
|
+
|
|
3
|
+
## What's new
|
|
4
|
+
|
|
5
|
+
Improved the upload mechanism for attachments.
|
|
6
|
+
Now the reporter will upload attachments in batches of 20 files.
|
|
7
|
+
|
|
8
|
+
# qase-javascript-commons@2.4.12
|
|
9
|
+
|
|
10
|
+
## What's new
|
|
11
|
+
|
|
12
|
+
Improved the retry mechanism for uploading attachments.
|
|
13
|
+
|
|
1
14
|
# qase-javascript-commons@2.4.11
|
|
2
15
|
|
|
3
16
|
## What's new
|
|
@@ -21,11 +21,19 @@ export declare class ClientV1 implements IClient {
|
|
|
21
21
|
private getErrorMessage;
|
|
22
22
|
uploadAttachment(attachment: Attachment): Promise<string>;
|
|
23
23
|
protected uploadAttachments(attachments: Attachment[]): Promise<string[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Group attachments into batches respecting API limits:
|
|
26
|
+
* - Up to 20 files per batch
|
|
27
|
+
* - Up to 128 MB per batch
|
|
28
|
+
* @param attachments Array of attachments to group
|
|
29
|
+
* @returns Array of attachment batches
|
|
30
|
+
*/
|
|
31
|
+
private groupAttachmentsIntoBatches;
|
|
24
32
|
/**
|
|
25
33
|
* Upload attachment with retry logic for 429 errors
|
|
26
34
|
* @param project Project code
|
|
27
|
-
* @param data Attachment data
|
|
28
|
-
* @param
|
|
35
|
+
* @param data Attachment data array (can contain multiple files)
|
|
36
|
+
* @param attachmentNames Attachment names for logging (comma-separated for batches)
|
|
29
37
|
* @param maxRetries Maximum number of retry attempts
|
|
30
38
|
* @param initialDelay Initial delay in milliseconds
|
|
31
39
|
* @returns Promise with upload response
|
|
@@ -42,6 +50,11 @@ export declare class ClientV1 implements IClient {
|
|
|
42
50
|
* @param ms Milliseconds to delay
|
|
43
51
|
*/
|
|
44
52
|
private delay;
|
|
53
|
+
/**
|
|
54
|
+
* Ensure attachment size is calculated if not set or is 0
|
|
55
|
+
* @param attachment Attachment to ensure size for
|
|
56
|
+
*/
|
|
57
|
+
private ensureAttachmentSize;
|
|
45
58
|
private prepareAttachmentData;
|
|
46
59
|
private getEnvironmentId;
|
|
47
60
|
private prepareRunObject;
|
package/dist/client/clientV1.js
CHANGED
|
@@ -15,6 +15,10 @@ const DEFAULT_API_HOST = 'qase.io';
|
|
|
15
15
|
const API_BASE_URL = 'https://api-';
|
|
16
16
|
const APP_BASE_URL = 'https://';
|
|
17
17
|
const API_VERSION = '/v1';
|
|
18
|
+
// Attachment upload limits
|
|
19
|
+
const MAX_FILE_SIZE = 32 * 1024 * 1024; // 32 MB per file
|
|
20
|
+
const MAX_REQUEST_SIZE = 128 * 1024 * 1024; // 128 MB per request
|
|
21
|
+
const MAX_FILES_PER_REQUEST = 20; // 20 files per request
|
|
18
22
|
var ApiErrorCode;
|
|
19
23
|
(function (ApiErrorCode) {
|
|
20
24
|
ApiErrorCode[ApiErrorCode["UNAUTHORIZED"] = 401] = "UNAUTHORIZED";
|
|
@@ -145,41 +149,122 @@ class ClientV1 {
|
|
|
145
149
|
return [];
|
|
146
150
|
}
|
|
147
151
|
const uploadedHashes = [];
|
|
148
|
-
|
|
149
|
-
|
|
152
|
+
// Filter out invalid attachments and check file size limits
|
|
153
|
+
const validAttachments = [];
|
|
154
|
+
for (const attachment of attachments) {
|
|
150
155
|
if (!attachment) {
|
|
151
156
|
continue;
|
|
152
157
|
}
|
|
158
|
+
// Ensure attachment size is calculated if not set or is 0
|
|
159
|
+
this.ensureAttachmentSize(attachment);
|
|
160
|
+
// Skip attachments with unknown size (0)
|
|
161
|
+
if (attachment.size === 0) {
|
|
162
|
+
this.logger.logError(`Cannot determine size for attachment "${attachment.file_path ?? attachment.file_name}". Skipping.`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Check if file exceeds maximum size per file (32 MB)
|
|
166
|
+
if (attachment.size > MAX_FILE_SIZE) {
|
|
167
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum file size (32 MB). ` +
|
|
168
|
+
`File size: ${(attachment.size / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
validAttachments.push(attachment);
|
|
172
|
+
}
|
|
173
|
+
if (validAttachments.length === 0) {
|
|
174
|
+
return uploadedHashes;
|
|
175
|
+
}
|
|
176
|
+
// Add initial random delay to spread out requests from different workers/shard
|
|
177
|
+
// This helps prevent all workers from hitting the API at the same time
|
|
178
|
+
const initialJitter = Math.random() * 500; // 0-500ms random delay
|
|
179
|
+
await this.delay(initialJitter);
|
|
180
|
+
// Group attachments into batches that respect API limits
|
|
181
|
+
const batches = this.groupAttachmentsIntoBatches(validAttachments);
|
|
182
|
+
this.logger.logDebug(`Uploading ${validAttachments.length} attachments in ${batches.length} batch(es)`);
|
|
183
|
+
// Upload each batch
|
|
184
|
+
for (let i = 0; i < batches.length; i++) {
|
|
185
|
+
const batch = batches[i];
|
|
186
|
+
if (!batch || batch.length === 0) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
153
189
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
190
|
+
const batchNames = batch.map(a => a.file_path ?? a.file_name).join(', ');
|
|
191
|
+
this.logger.logDebug(`Uploading batch ${i + 1}/${batches.length} with ${batch.length} file(s): ${batchNames}`);
|
|
192
|
+
const batchData = batch.map(attachment => this.prepareAttachmentData(attachment));
|
|
193
|
+
const response = await this.uploadAttachmentWithRetry(this.config.project, batchData, batchNames);
|
|
194
|
+
// Extract all hashes from the response
|
|
195
|
+
if (response.data.result) {
|
|
196
|
+
for (const result of response.data.result) {
|
|
197
|
+
if (result.hash) {
|
|
198
|
+
uploadedHashes.push(result.hash);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
160
201
|
}
|
|
161
202
|
}
|
|
162
203
|
catch (error) {
|
|
163
|
-
this.logger.logError(
|
|
204
|
+
this.logger.logError(`Cannot upload batch ${i + 1}:`, error);
|
|
205
|
+
// Continue with next batch even if current batch fails
|
|
164
206
|
}
|
|
165
|
-
// Add delay between
|
|
166
|
-
// Skip delay after the last
|
|
167
|
-
if (i <
|
|
168
|
-
|
|
207
|
+
// Add delay between batches to avoid rate limiting
|
|
208
|
+
// Skip delay after the last batch
|
|
209
|
+
if (i < batches.length - 1) {
|
|
210
|
+
// Increased delay with random jitter to prevent synchronization
|
|
211
|
+
const baseDelay = 1000; // 1000ms (1 second) base delay
|
|
212
|
+
const jitter = Math.random() * 300; // 0-300ms random jitter
|
|
213
|
+
await this.delay(baseDelay + jitter);
|
|
169
214
|
}
|
|
170
215
|
}
|
|
171
216
|
return uploadedHashes;
|
|
172
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Group attachments into batches respecting API limits:
|
|
220
|
+
* - Up to 20 files per batch
|
|
221
|
+
* - Up to 128 MB per batch
|
|
222
|
+
* @param attachments Array of attachments to group
|
|
223
|
+
* @returns Array of attachment batches
|
|
224
|
+
*/
|
|
225
|
+
groupAttachmentsIntoBatches(attachments) {
|
|
226
|
+
const batches = [];
|
|
227
|
+
let currentBatch = [];
|
|
228
|
+
let currentBatchSize = 0;
|
|
229
|
+
for (const attachment of attachments) {
|
|
230
|
+
const attachmentSize = attachment.size;
|
|
231
|
+
// Check if adding this attachment would exceed limits
|
|
232
|
+
const wouldExceedFileLimit = currentBatch.length >= MAX_FILES_PER_REQUEST;
|
|
233
|
+
const wouldExceedSizeLimit = currentBatchSize + attachmentSize > MAX_REQUEST_SIZE;
|
|
234
|
+
// If current batch is full or would exceed limits, start a new batch
|
|
235
|
+
if (wouldExceedFileLimit || wouldExceedSizeLimit) {
|
|
236
|
+
if (currentBatch.length > 0) {
|
|
237
|
+
batches.push(currentBatch);
|
|
238
|
+
currentBatch = [];
|
|
239
|
+
currentBatchSize = 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// If a single file exceeds request size limit, it should have been filtered earlier
|
|
243
|
+
// but we check again as a safety measure
|
|
244
|
+
if (attachmentSize > MAX_REQUEST_SIZE) {
|
|
245
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum request size (128 MB). ` +
|
|
246
|
+
`File size: ${(attachmentSize / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
currentBatch.push(attachment);
|
|
250
|
+
currentBatchSize += attachmentSize;
|
|
251
|
+
}
|
|
252
|
+
// Add the last batch if it's not empty
|
|
253
|
+
if (currentBatch.length > 0) {
|
|
254
|
+
batches.push(currentBatch);
|
|
255
|
+
}
|
|
256
|
+
return batches;
|
|
257
|
+
}
|
|
173
258
|
/**
|
|
174
259
|
* Upload attachment with retry logic for 429 errors
|
|
175
260
|
* @param project Project code
|
|
176
|
-
* @param data Attachment data
|
|
177
|
-
* @param
|
|
261
|
+
* @param data Attachment data array (can contain multiple files)
|
|
262
|
+
* @param attachmentNames Attachment names for logging (comma-separated for batches)
|
|
178
263
|
* @param maxRetries Maximum number of retry attempts
|
|
179
264
|
* @param initialDelay Initial delay in milliseconds
|
|
180
265
|
* @returns Promise with upload response
|
|
181
266
|
*/
|
|
182
|
-
async uploadAttachmentWithRetry(project, data,
|
|
267
|
+
async uploadAttachmentWithRetry(project, data, attachmentNames, maxRetries = 5, initialDelay = 1000) {
|
|
183
268
|
let lastError;
|
|
184
269
|
let delay = initialDelay;
|
|
185
270
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -193,15 +278,20 @@ class ClientV1 {
|
|
|
193
278
|
if (error.response?.status === 429) {
|
|
194
279
|
if (attempt < maxRetries) {
|
|
195
280
|
const retryAfter = this.getRetryAfter(error);
|
|
196
|
-
const
|
|
197
|
-
|
|
281
|
+
const baseWaitTime = retryAfter ?? delay;
|
|
282
|
+
// Add jitter (random delay) to prevent all workers from retrying simultaneously
|
|
283
|
+
// Jitter is 10-30% of the wait time to spread out retry attempts
|
|
284
|
+
const jitterPercent = 0.1 + Math.random() * 0.2; // 10-30%
|
|
285
|
+
const jitter = baseWaitTime * jitterPercent;
|
|
286
|
+
const waitTime = Math.floor(baseWaitTime + jitter);
|
|
287
|
+
this.logger.logDebug(`Rate limit exceeded (429) for attachment(s) "${attachmentNames}". ` +
|
|
198
288
|
`Retrying in ${waitTime}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
199
289
|
await this.delay(waitTime);
|
|
200
290
|
// Exponential backoff: double the delay for next attempt
|
|
201
291
|
delay = Math.min(delay * 2, 30000); // Cap at 30 seconds
|
|
202
292
|
}
|
|
203
293
|
else {
|
|
204
|
-
this.logger.logError(`Failed to upload attachment "${
|
|
294
|
+
this.logger.logError(`Failed to upload attachment(s) "${attachmentNames}" after ${maxRetries} retries due to rate limiting`);
|
|
205
295
|
}
|
|
206
296
|
}
|
|
207
297
|
else {
|
|
@@ -244,6 +334,50 @@ class ClientV1 {
|
|
|
244
334
|
delay(ms) {
|
|
245
335
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
246
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Ensure attachment size is calculated if not set or is 0
|
|
339
|
+
* @param attachment Attachment to ensure size for
|
|
340
|
+
*/
|
|
341
|
+
ensureAttachmentSize(attachment) {
|
|
342
|
+
// If size is already set and greater than 0, use it
|
|
343
|
+
if (attachment.size > 0) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
if (attachment.file_path) {
|
|
348
|
+
// Get file size from file system
|
|
349
|
+
const stats = (0, fs_1.statSync)(attachment.file_path);
|
|
350
|
+
attachment.size = stats.size;
|
|
351
|
+
}
|
|
352
|
+
else if (attachment.content) {
|
|
353
|
+
// Calculate size from content
|
|
354
|
+
if (typeof attachment.content === 'string') {
|
|
355
|
+
// For strings, check if it's base64 encoded
|
|
356
|
+
if (attachment.content.match(/^[A-Za-z0-9+/=]+$/)) {
|
|
357
|
+
// Base64 encoded string
|
|
358
|
+
attachment.size = Buffer.from(attachment.content, 'base64').length;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
// Regular string - use byte length
|
|
362
|
+
attachment.size = Buffer.byteLength(attachment.content, 'utf8');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else if (Buffer.isBuffer(attachment.content)) {
|
|
366
|
+
// Buffer - use length
|
|
367
|
+
attachment.size = attachment.content.length;
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
// Fallback: try to convert to string and get byte length
|
|
371
|
+
attachment.size = Buffer.byteLength(String(attachment.content), 'utf8');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
// If we can't determine size, log warning and set to 0
|
|
377
|
+
this.logger.logDebug(`Could not determine size for attachment "${attachment.file_path ?? attachment.file_name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
378
|
+
attachment.size = 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
247
381
|
prepareAttachmentData(attachment) {
|
|
248
382
|
if (attachment.file_path) {
|
|
249
383
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qase-javascript-commons",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.13",
|
|
4
4
|
"description": "Qase JS Reporters",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"lodash.merge": "^4.6.2",
|
|
34
34
|
"lodash.mergewith": "^4.6.2",
|
|
35
35
|
"mime-types": "^2.1.35",
|
|
36
|
-
"qase-api-client": "~1.1.
|
|
37
|
-
"qase-api-v2-client": "~1.0.
|
|
36
|
+
"qase-api-client": "~1.1.1",
|
|
37
|
+
"qase-api-v2-client": "~1.0.4",
|
|
38
38
|
"strip-ansi": "^6.0.1",
|
|
39
39
|
"uuid": "^9.0.1"
|
|
40
40
|
},
|