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 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 attachmentName Attachment name for logging
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;
@@ -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
- for (let i = 0; i < attachments.length; i++) {
149
- const attachment = attachments[i];
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
- this.logger.logDebug(`Uploading attachment: ${attachment.file_path ?? attachment.file_name}`);
155
- const data = this.prepareAttachmentData(attachment);
156
- const response = await this.uploadAttachmentWithRetry(this.config.project, [data], attachment.file_path ?? attachment.file_name);
157
- const hash = response.data.result?.[0]?.hash;
158
- if (hash) {
159
- uploadedHashes.push(hash);
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('Cannot upload attachment:', error);
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 requests to avoid rate limiting
166
- // Skip delay after the last attachment
167
- if (i < attachments.length - 1) {
168
- await this.delay(100); // 100ms delay between requests
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 attachmentName Attachment name for logging
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, attachmentName, maxRetries = 5, initialDelay = 1000) {
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 waitTime = retryAfter ?? delay;
197
- this.logger.logDebug(`Rate limit exceeded (429) for attachment "${attachmentName}". ` +
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 "${attachmentName}" after ${maxRetries} retries due to rate limiting`);
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.11",
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.0",
37
- "qase-api-v2-client": "~1.0.3",
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
  },