qase-javascript-commons 2.4.12 → 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,10 @@
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
+
1
8
  # qase-javascript-commons@2.4.12
2
9
 
3
10
  ## 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,32 +149,64 @@ class ClientV1 {
145
149
  return [];
146
150
  }
147
151
  const uploadedHashes = [];
152
+ // Filter out invalid attachments and check file size limits
153
+ const validAttachments = [];
154
+ for (const attachment of attachments) {
155
+ if (!attachment) {
156
+ continue;
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
+ }
148
176
  // Add initial random delay to spread out requests from different workers/shard
149
177
  // This helps prevent all workers from hitting the API at the same time
150
- if (attachments.length > 0) {
151
- const initialJitter = Math.random() * 500; // 0-500ms random delay
152
- await this.delay(initialJitter);
153
- }
154
- for (let i = 0; i < attachments.length; i++) {
155
- const attachment = attachments[i];
156
- if (!attachment) {
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) {
157
187
  continue;
158
188
  }
159
189
  try {
160
- this.logger.logDebug(`Uploading attachment: ${attachment.file_path ?? attachment.file_name}`);
161
- const data = this.prepareAttachmentData(attachment);
162
- const response = await this.uploadAttachmentWithRetry(this.config.project, [data], attachment.file_path ?? attachment.file_name);
163
- const hash = response.data.result?.[0]?.hash;
164
- if (hash) {
165
- 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
+ }
166
201
  }
167
202
  }
168
203
  catch (error) {
169
- 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
170
206
  }
171
- // Add delay between requests to avoid rate limiting
172
- // Skip delay after the last attachment
173
- if (i < attachments.length - 1) {
207
+ // Add delay between batches to avoid rate limiting
208
+ // Skip delay after the last batch
209
+ if (i < batches.length - 1) {
174
210
  // Increased delay with random jitter to prevent synchronization
175
211
  const baseDelay = 1000; // 1000ms (1 second) base delay
176
212
  const jitter = Math.random() * 300; // 0-300ms random jitter
@@ -179,16 +215,56 @@ class ClientV1 {
179
215
  }
180
216
  return uploadedHashes;
181
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
+ }
182
258
  /**
183
259
  * Upload attachment with retry logic for 429 errors
184
260
  * @param project Project code
185
- * @param data Attachment data
186
- * @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)
187
263
  * @param maxRetries Maximum number of retry attempts
188
264
  * @param initialDelay Initial delay in milliseconds
189
265
  * @returns Promise with upload response
190
266
  */
191
- async uploadAttachmentWithRetry(project, data, attachmentName, maxRetries = 5, initialDelay = 1000) {
267
+ async uploadAttachmentWithRetry(project, data, attachmentNames, maxRetries = 5, initialDelay = 1000) {
192
268
  let lastError;
193
269
  let delay = initialDelay;
194
270
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -208,14 +284,14 @@ class ClientV1 {
208
284
  const jitterPercent = 0.1 + Math.random() * 0.2; // 10-30%
209
285
  const jitter = baseWaitTime * jitterPercent;
210
286
  const waitTime = Math.floor(baseWaitTime + jitter);
211
- this.logger.logDebug(`Rate limit exceeded (429) for attachment "${attachmentName}". ` +
287
+ this.logger.logDebug(`Rate limit exceeded (429) for attachment(s) "${attachmentNames}". ` +
212
288
  `Retrying in ${waitTime}ms (attempt ${attempt + 1}/${maxRetries})`);
213
289
  await this.delay(waitTime);
214
290
  // Exponential backoff: double the delay for next attempt
215
291
  delay = Math.min(delay * 2, 30000); // Cap at 30 seconds
216
292
  }
217
293
  else {
218
- 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`);
219
295
  }
220
296
  }
221
297
  else {
@@ -258,6 +334,50 @@ class ClientV1 {
258
334
  delay(ms) {
259
335
  return new Promise(resolve => setTimeout(resolve, ms));
260
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
+ }
261
381
  prepareAttachmentData(attachment) {
262
382
  if (attachment.file_path) {
263
383
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qase-javascript-commons",
3
- "version": "2.4.12",
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
  },