@allurereport/plugin-testops 3.15.0 → 3.16.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/README.md CHANGED
@@ -80,6 +80,8 @@ The plugin accepts the following options:
80
80
  | `autocloseLaunch` | When `true` (default), the launch is closed automatically when the plugin finishes; set to `false` to keep the launch open | `boolean` | `true` |
81
81
  | `gitFlow` | When `true`, collect Git metadata for TestOps Git Flow on CI uploads (opt-in) | `boolean` | `false` |
82
82
  | `ancestorLimit` | How many ancestor commits to attach to the launch for history linking in TestOps | `number` | `100` |
83
+ | `uploadRateLimit` | Caps how fast uploads are sent to TestOps within a rolling time window (requests/files/bytes per window); pass `false` to disable pacing entirely | `{ windowMs: number; maxRequestsPerWindow?: number; maxFilesPerWindow?: number; maxBytesPerWindow?: number } \| false` | `20` req/s, `1000` files/s, `1 GiB`/s |
84
+ | `reopenClosedLaunch` | When `true`, a launch that TestOps reports as closed is reopened automatically instead of failing the upload | `boolean` | `false` |
83
85
 
84
86
  ### Using options from environment variables
85
87
 
package/dist/client.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ClientRequest } from "http";
2
2
  import type { AttachmentLink, CiDescriptor, EnvironmentIdentity, TestError, TestResult, TestStatus } from "@allurereport/core-api";
3
3
  import type { QualityGateValidationResult } from "@allurereport/plugin-api";
4
4
  import { AxiosError, type AxiosResponse } from "axios";
5
+ import type { RetryOptions } from "./errors.js";
5
6
  import type { LaunchGitContextDto } from "./gitFlow/index.js";
6
7
  import type { AttachmentForUpload, AttachmentsResolver, FixtureResolver, LaunchCategoryBulkItem, LaunchCategoryBulkResult, TestOpsClientParams, TestOpsNamedEnv, TestOpsPluginTestResult } from "./model.js";
7
8
  declare class TestOpsClientError extends AxiosError<{
@@ -23,13 +24,14 @@ export declare class TestOpsClient {
23
24
  get launchUrl(): string | undefined;
24
25
  get launchId(): number | undefined;
25
26
  closeLaunch(launchId: number): Promise<void>;
27
+ reopenLaunch(launchId: number): Promise<void>;
26
28
  createLaunchCategoriesBulk(launchId: number, items: LaunchCategoryBulkItem[]): Promise<LaunchCategoryBulkResult[]>;
27
- startUpload(ci: CiDescriptor): Promise<void>;
29
+ startUpload(ci: CiDescriptor, jobRunId?: number): Promise<void>;
28
30
  stopUpload(ci: CiDescriptor, status: TestStatus): Promise<void>;
29
31
  createLaunch(launchName: string, launchTags: string[], gitContext?: LaunchGitContextDto): Promise<void>;
32
+ attachToLaunch(launchId: number): void;
30
33
  checkLaunchProgress(): Promise<boolean>;
31
- createSession(environment?: Record<string, unknown>): Promise<void>;
32
- get namedEnvs(): MapIterator<TestOpsNamedEnv>;
34
+ createSession(environment?: Record<string, unknown>, reopenClosedLaunch?: boolean): Promise<void>;
33
35
  getNamedEnvFor(id: string): TestOpsNamedEnv | undefined;
34
36
  createNamedEnvs(environments: EnvironmentIdentity[], onProgress?: (percent: number, total: number) => void): Promise<void>;
35
37
  uploadGlobalAttachments(params: {
@@ -44,6 +46,8 @@ export declare class TestOpsClient {
44
46
  attachmentsResolver: AttachmentsResolver;
45
47
  fixturesResolver: FixtureResolver;
46
48
  onProgress?: () => void;
49
+ onRetry?: RetryOptions["onRetry"];
50
+ onChunkUploaded?: (uploadedChunkTrs: TestResult[]) => void;
47
51
  }): Promise<TestResult[]>;
48
52
  uploadQualityGateResults(results: QualityGateValidationResult[], onProgress?: (percent: number, total: number) => void): Promise<void>;
49
53
  }
package/dist/client.js CHANGED
@@ -9,20 +9,27 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _TestOpsClient_instances, _TestOpsClient_baseUrl, _TestOpsClient_logger, _TestOpsClient_accessToken, _TestOpsClient_projectId, _TestOpsClient_client, _TestOpsClient_launch, _TestOpsClient_session, _TestOpsClient_uploadInProgress, _TestOpsClient_uploadLimit, _TestOpsClient_namedEnvsIdsByEnv, _TestOpsClient_postTestResultsChunk, _TestOpsClient_uploadChunkAttachmentsAndFixtures, _TestOpsClient_uploadAttachmentsForResult, _TestOpsClient_uploadFixturesForResult;
13
- import { createServiceHttpClient } from "@allurereport/service";
12
+ var _TestOpsClient_instances, _TestOpsClient_baseUrl, _TestOpsClient_logger, _TestOpsClient_accessToken, _TestOpsClient_projectId, _TestOpsClient_client, _TestOpsClient_launch, _TestOpsClient_jobRunId, _TestOpsClient_session, _TestOpsClient_uploadInProgress, _TestOpsClient_uploadLimit, _TestOpsClient_uploadPacer, _TestOpsClient_namedEnvsIdsByEnv, _TestOpsClient_getExistingJob, _TestOpsClient_postTestResultsChunk, _TestOpsClient_uploadChunkAttachmentsAndFixtures, _TestOpsClient_uploadAttachmentsForResult, _TestOpsClient_logResultUploadFailure, _TestOpsClient_uploadFixturesForResult;
13
+ import { isLocalCiDescriptor } from "@allurereport/ci";
14
+ import { KnownError, createServiceHttpClient } from "@allurereport/service";
14
15
  import { AxiosError, isAxiosError } from "axios";
15
16
  import FormData from "form-data";
16
17
  import { chunk } from "lodash-es";
17
18
  import pLimit from "p-limit";
18
19
  import { bold } from "yoctocolors";
20
+ import { withUploadRetry } from "./errors.js";
19
21
  import { Logger } from "./logger.js";
22
+ import { UploadPacer } from "./uploadPacer.js";
20
23
  import { toUploadFixturesResultsDto } from "./utils/fixtures.js";
24
+ import { attachmentByteLength, retryRequest } from "./utils/httpRetry.js";
25
+ import { TESTOPS_CI_TYPE, ciEndpoint, externalParameters } from "./utils/jobParameters.js";
21
26
  import { testStatusToLaunchStatus } from "./utils/launches.js";
22
27
  import { normalizeTestStepsResults, toUploadResultsDto } from "./utils/testResults.js";
23
28
  import { validateExecutableName } from "./utils/validation.js";
24
29
  class TestOpsClientError extends AxiosError {
25
30
  }
31
+ const UNKNOWN_ERROR_MESSAGE = "Unknown error";
32
+ const MAX_LAUNCH_NAME_LENGTH = 255;
26
33
  const CHUNK_SIZE = 100;
27
34
  const BULK_UPLOAD_CHUNK_SIZE = 1000;
28
35
  export class TestOpsClient {
@@ -34,9 +41,11 @@ export class TestOpsClient {
34
41
  _TestOpsClient_projectId.set(this, void 0);
35
42
  _TestOpsClient_client.set(this, void 0);
36
43
  _TestOpsClient_launch.set(this, void 0);
44
+ _TestOpsClient_jobRunId.set(this, void 0);
37
45
  _TestOpsClient_session.set(this, void 0);
38
46
  _TestOpsClient_uploadInProgress.set(this, false);
39
47
  _TestOpsClient_uploadLimit.set(this, 1);
48
+ _TestOpsClient_uploadPacer.set(this, void 0);
40
49
  _TestOpsClient_namedEnvsIdsByEnv.set(this, new Map());
41
50
  if (!params.accessToken) {
42
51
  throw new Error("accessToken is required");
@@ -59,6 +68,7 @@ export class TestOpsClient {
59
68
  if (params.limit) {
60
69
  __classPrivateFieldSet(this, _TestOpsClient_uploadLimit, params.limit, "f");
61
70
  }
71
+ __classPrivateFieldSet(this, _TestOpsClient_uploadPacer, new UploadPacer(params.uploadRateLimit), "f");
62
72
  }
63
73
  isTestOpsClientError(error) {
64
74
  return (isAxiosError(error) &&
@@ -79,6 +89,11 @@ export class TestOpsClient {
79
89
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/launch/${launchId}/close`);
80
90
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Launch closed");
81
91
  }
92
+ async reopenLaunch(launchId) {
93
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose(`Reopening launch ${launchId}…`);
94
+ await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/launch/${launchId}/reopen`);
95
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Launch reopened");
96
+ }
82
97
  async createLaunchCategoriesBulk(launchId, items) {
83
98
  if (items.length === 0) {
84
99
  return [];
@@ -114,29 +129,52 @@ export class TestOpsClient {
114
129
  }
115
130
  return results;
116
131
  }
117
- async startUpload(ci) {
118
- if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
132
+ async startUpload(ci, jobRunId) {
133
+ if (!jobRunId && !__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
119
134
  throw new Error("Launch isn't created! Call createLaunch first");
120
135
  }
136
+ if (!jobRunId && isLocalCiDescriptor(ci)) {
137
+ __classPrivateFieldSet(this, _TestOpsClient_uploadInProgress, true, "f");
138
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("CI upload started (manual, no CI detected)");
139
+ return;
140
+ }
141
+ const endpoint = ciEndpoint(ci);
142
+ const existingJob = await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_getExistingJob).call(this, ci);
143
+ const parameters = externalParameters(ci, existingJob?.parameters);
144
+ const jobPayload = {
145
+ name: ci.jobName || ci.jobUid,
146
+ uid: ci.jobUid,
147
+ ...(existingJob?.id ? { id: existingJob.id } : {}),
148
+ ...(ci.jobUrl ? { url: ci.jobUrl } : {}),
149
+ ...(parameters.job.length ? { parameters: parameters.job } : {}),
150
+ };
151
+ const jobRunPayload = {
152
+ ...(jobRunId ? { id: jobRunId } : {}),
153
+ uid: ci.jobRunUid,
154
+ ...(ci.jobRunName ? { name: ci.jobRunName } : {}),
155
+ ...(ci.jobRunUrl ? { url: ci.jobRunUrl } : {}),
156
+ ...(parameters.jobRun.length ? { parameters: parameters.jobRun } : {}),
157
+ };
121
158
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose(`Starting CI upload (${ci.type})…`);
122
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/start", {
159
+ const data = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/start", {
123
160
  body: {
124
161
  projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
125
162
  ci: {
126
- name: ci.type,
127
- },
128
- job: {
129
- name: ci.jobUid,
130
- uid: ci.jobUid,
131
- },
132
- jobRun: {
133
- uid: ci.jobRunUid,
134
- },
135
- launch: {
136
- id: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
163
+ type: TESTOPS_CI_TYPE[ci.type] ?? ci.type,
164
+ ...(endpoint ? { endpoint } : {}),
137
165
  },
166
+ job: jobPayload,
167
+ jobRun: jobRunPayload,
168
+ ...(__classPrivateFieldGet(this, _TestOpsClient_launch, "f") ? { launch: { id: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id } } : {}),
138
169
  },
139
- });
170
+ }));
171
+ __classPrivateFieldSet(this, _TestOpsClient_jobRunId, data?.jobRunId ?? jobRunId, "f");
172
+ if (jobRunId) {
173
+ if (typeof data?.launchId !== "number") {
174
+ throw new Error("TestOps didn't return a launch id for the job run");
175
+ }
176
+ __classPrivateFieldSet(this, _TestOpsClient_launch, { id: data.launchId }, "f");
177
+ }
140
178
  __classPrivateFieldSet(this, _TestOpsClient_uploadInProgress, true, "f");
141
179
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("CI upload started");
142
180
  }
@@ -144,14 +182,16 @@ export class TestOpsClient {
144
182
  if (!__classPrivateFieldGet(this, _TestOpsClient_uploadInProgress, "f")) {
145
183
  throw new Error("Upload isn't started! Call startUpload first");
146
184
  }
147
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/stop", {
148
- body: {
149
- jobRunUid: ci.jobRunUid,
150
- jobUid: ci.jobUid,
151
- projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
152
- status: testStatusToLaunchStatus(status),
153
- },
154
- });
185
+ if (!isLocalCiDescriptor(ci)) {
186
+ await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/stop", {
187
+ body: {
188
+ jobRunUid: ci.jobRunUid,
189
+ jobUid: ci.jobUid,
190
+ projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
191
+ status: testStatusToLaunchStatus(status),
192
+ },
193
+ });
194
+ }
155
195
  __classPrivateFieldSet(this, _TestOpsClient_uploadInProgress, false, "f");
156
196
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose(`CI upload stopped (status: ${status})`);
157
197
  }
@@ -159,7 +199,7 @@ export class TestOpsClient {
159
199
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Creating launch…");
160
200
  const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch", {
161
201
  body: {
162
- name: launchName,
202
+ name: launchName.slice(0, MAX_LAUNCH_NAME_LENGTH),
163
203
  projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
164
204
  autoclose: true,
165
205
  external: true,
@@ -170,42 +210,44 @@ export class TestOpsClient {
170
210
  __classPrivateFieldSet(this, _TestOpsClient_launch, data, "f");
171
211
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").debug(`Launch created: id=${bold(data.id.toString())}`);
172
212
  }
213
+ attachToLaunch(launchId) {
214
+ __classPrivateFieldSet(this, _TestOpsClient_launch, { id: launchId }, "f");
215
+ }
173
216
  async checkLaunchProgress() {
174
217
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
175
218
  throw new Error("Launch isn't created! Call createLaunch first");
176
219
  }
177
220
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Retrieving launch progress status…");
178
- const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").get(`/api/launch/${__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id}/progress`);
221
+ const data = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").get(`/api/launch/${__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id}/progress`));
179
222
  return data.ready;
180
223
  }
181
- async createSession(environment = {}) {
224
+ async createSession(environment = {}, reopenClosedLaunch = false) {
182
225
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
183
226
  throw new Error("Launch isn't created! Call createLaunch first");
184
227
  }
185
- const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/session", {
186
- body: {
187
- launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
188
- environment: Object.entries(environment).map(([key, value]) => ({
189
- key,
190
- value: String(value),
191
- })),
192
- },
193
- params: {
194
- manual: "true",
195
- },
196
- });
228
+ const currentLaunch = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").get(`/api/launch/${__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id}`));
229
+ if (currentLaunch.closed) {
230
+ if (!reopenClosedLaunch) {
231
+ throw new Error(`Launch ${__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id} is closed`);
232
+ }
233
+ await this.reopenLaunch(__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id);
234
+ }
235
+ const sessionEnvironment = Object.entries(environment).map(([key, value]) => ({
236
+ key,
237
+ value: String(value),
238
+ }));
239
+ const data = __classPrivateFieldGet(this, _TestOpsClient_jobRunId, "f")
240
+ ? await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/session", {
241
+ body: { jobRunId: __classPrivateFieldGet(this, _TestOpsClient_jobRunId, "f"), environment: sessionEnvironment },
242
+ })
243
+ : await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/session", {
244
+ body: { launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id, environment: sessionEnvironment },
245
+ params: { manual: "true" },
246
+ });
197
247
  __classPrivateFieldSet(this, _TestOpsClient_session, data, "f");
198
248
  }
199
- get namedEnvs() {
200
- return __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").values();
201
- }
202
249
  getNamedEnvFor(id) {
203
- for (const [, env] of __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f")) {
204
- if (env.externalId === id) {
205
- return env;
206
- }
207
- }
208
- return undefined;
250
+ return __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").get(id);
209
251
  }
210
252
  async createNamedEnvs(environments, onProgress) {
211
253
  if (!__classPrivateFieldGet(this, _TestOpsClient_session, "f")) {
@@ -247,17 +289,26 @@ export class TestOpsClient {
247
289
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
248
290
  throw new Error("Launch isn't created! Call createLaunch first");
249
291
  }
292
+ const launchId = __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id;
250
293
  const formData = new FormData();
294
+ let hasResolvedAttachments = false;
295
+ let totalBytes = 0;
251
296
  for (const attachmentLink of attachments) {
252
297
  const attachment = await attachmentsResolver(attachmentLink);
253
298
  if (!attachment) {
254
299
  continue;
255
300
  }
301
+ totalBytes += attachmentByteLength(attachment);
256
302
  formData.append("file", attachment.content, {
257
303
  filename: attachment.originalFileName,
258
304
  contentType: attachment.contentType,
259
305
  });
306
+ hasResolvedAttachments = true;
260
307
  }
308
+ if (!hasResolvedAttachments) {
309
+ return;
310
+ }
311
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1, files: attachments.length, bytes: totalBytes });
261
312
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/attachment", {
262
313
  body: formData,
263
314
  onUploadProgress(progressEvent) {
@@ -265,7 +316,7 @@ export class TestOpsClient {
265
316
  const percent = total > 0 ? Math.min(100, Math.max(0, (progressEvent.loaded / total) * 100)) : 0;
266
317
  onProgress?.(percent, total);
267
318
  },
268
- params: { launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id },
319
+ params: { launchId, ...(__classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId ? { jobRunId: __classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId } : {}) },
269
320
  headers: formData.getHeaders(),
270
321
  });
271
322
  }
@@ -276,10 +327,13 @@ export class TestOpsClient {
276
327
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
277
328
  throw new Error("Launch isn't created! Call createLaunch first");
278
329
  }
330
+ const launchId = __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id;
331
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
279
332
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/error/bulk", {
280
333
  body: {
281
- launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
282
- items: errors,
334
+ launchId,
335
+ ...(__classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId ? { jobRunId: __classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId } : {}),
336
+ items: errors.map(({ message, trace }) => ({ message: message?.trim() || UNKNOWN_ERROR_MESSAGE, trace })),
283
337
  },
284
338
  onUploadProgress(progressEvent) {
285
339
  const total = progressEvent.total ?? 100;
@@ -292,7 +346,7 @@ export class TestOpsClient {
292
346
  if (!__classPrivateFieldGet(this, _TestOpsClient_session, "f")) {
293
347
  throw new Error("Session isn't created! Call createSession first");
294
348
  }
295
- const { trs, environments, attachmentsResolver, fixturesResolver, onProgress } = params;
349
+ const { trs, environments, attachmentsResolver, fixturesResolver, onProgress, onRetry, onChunkUploaded } = params;
296
350
  const projectedTrs = trs.map((tr) => ({
297
351
  ...tr,
298
352
  ...(tr.steps ? { steps: normalizeTestStepsResults(tr.steps) } : {}),
@@ -301,39 +355,31 @@ export class TestOpsClient {
301
355
  const uploadLimitFn = pLimit(__classPrivateFieldGet(this, _TestOpsClient_uploadLimit, "f"));
302
356
  const uploadedTrs = [];
303
357
  const envNamesById = new Map(environments.map(({ id, name }) => [id, name]));
304
- try {
305
- for (const trsChunk of trsChunks) {
306
- const chunkEnvs = new Map();
307
- for (const tr of trsChunk) {
308
- const environmentId = tr.environment;
309
- if (environmentId && !__classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").has(environmentId)) {
310
- chunkEnvs.set(environmentId, {
311
- id: environmentId,
312
- name: envNamesById.get(environmentId) ?? environmentId,
313
- });
314
- }
358
+ for (const trsChunk of trsChunks) {
359
+ const chunkEnvs = new Map();
360
+ for (const tr of trsChunk) {
361
+ const environmentId = tr.environment;
362
+ if (environmentId && !__classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").has(environmentId)) {
363
+ chunkEnvs.set(environmentId, {
364
+ id: environmentId,
365
+ name: envNamesById.get(environmentId) ?? environmentId,
366
+ });
315
367
  }
316
- if (chunkEnvs.size > 0) {
317
- await this.createNamedEnvs(Array.from(chunkEnvs.values()));
318
- }
319
- const reportIdsToTestOpsIds = await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_postTestResultsChunk).call(this, trsChunk);
320
- uploadedTrs.push(...trsChunk.filter((tr) => typeof reportIdsToTestOpsIds[tr.id] === "number"));
321
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadChunkAttachmentsAndFixtures).call(this, trsChunk, reportIdsToTestOpsIds, attachmentsResolver, fixturesResolver, uploadLimitFn, onProgress);
322
368
  }
323
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Test results upload completed");
324
- }
325
- catch (error) {
326
- if (this.isTestOpsClientError(error)) {
327
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload test results: ${error.response?.data.message}`);
328
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").debug(error.response.data);
329
- }
330
- else if (error instanceof Error) {
331
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload test results: ${error.message}`);
332
- }
333
- else {
334
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error("Failed to upload test results");
335
- }
336
- }
369
+ const reportIdsToTestOpsIds = await withUploadRetry(async () => {
370
+ const envsToCreate = Array.from(chunkEnvs.values()).filter((environment) => !__classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").has(environment.id));
371
+ if (envsToCreate.length > 0) {
372
+ await this.createNamedEnvs(envsToCreate);
373
+ }
374
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1, files: trsChunk.length });
375
+ return __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_postTestResultsChunk).call(this, trsChunk);
376
+ }, { onRetry });
377
+ const uploadedChunkTrs = trsChunk.filter((tr) => typeof reportIdsToTestOpsIds[tr.id] === "number");
378
+ uploadedTrs.push(...uploadedChunkTrs);
379
+ onChunkUploaded?.(uploadedChunkTrs);
380
+ await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadChunkAttachmentsAndFixtures).call(this, trsChunk, reportIdsToTestOpsIds, attachmentsResolver, fixturesResolver, uploadLimitFn, onProgress);
381
+ }
382
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Test results upload completed");
337
383
  return uploadedTrs;
338
384
  }
339
385
  async uploadQualityGateResults(results, onProgress) {
@@ -343,6 +389,7 @@ export class TestOpsClient {
343
389
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
344
390
  throw new Error("Launch isn't created! Call createLaunch first");
345
391
  }
392
+ const launchId = __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id;
346
393
  const items = results.map((result) => {
347
394
  const item = {
348
395
  name: result.rule,
@@ -354,9 +401,10 @@ export class TestOpsClient {
354
401
  }
355
402
  return item;
356
403
  });
357
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/quality-gate/bulk", {
404
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
405
+ await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/quality-gate/bulk", {
358
406
  body: {
359
- launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
407
+ launchId,
360
408
  items,
361
409
  },
362
410
  onUploadProgress(progressEvent) {
@@ -364,10 +412,22 @@ export class TestOpsClient {
364
412
  const percent = total > 0 ? Math.min(100, Math.max(0, (progressEvent.loaded / total) * 100)) : 0;
365
413
  onProgress?.(percent, total);
366
414
  },
367
- });
415
+ }));
368
416
  }
369
417
  }
370
- _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _TestOpsClient_accessToken = new WeakMap(), _TestOpsClient_projectId = new WeakMap(), _TestOpsClient_client = new WeakMap(), _TestOpsClient_launch = new WeakMap(), _TestOpsClient_session = new WeakMap(), _TestOpsClient_uploadInProgress = new WeakMap(), _TestOpsClient_uploadLimit = new WeakMap(), _TestOpsClient_namedEnvsIdsByEnv = new WeakMap(), _TestOpsClient_instances = new WeakSet(), _TestOpsClient_postTestResultsChunk = async function _TestOpsClient_postTestResultsChunk(trsChunk) {
418
+ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _TestOpsClient_accessToken = new WeakMap(), _TestOpsClient_projectId = new WeakMap(), _TestOpsClient_client = new WeakMap(), _TestOpsClient_launch = new WeakMap(), _TestOpsClient_jobRunId = new WeakMap(), _TestOpsClient_session = new WeakMap(), _TestOpsClient_uploadInProgress = new WeakMap(), _TestOpsClient_uploadLimit = new WeakMap(), _TestOpsClient_uploadPacer = new WeakMap(), _TestOpsClient_namedEnvsIdsByEnv = new WeakMap(), _TestOpsClient_instances = new WeakSet(), _TestOpsClient_getExistingJob = async function _TestOpsClient_getExistingJob(ci) {
419
+ try {
420
+ return await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").get("/api/rs/job", {
421
+ params: { projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"), externalId: ci.jobUid },
422
+ }));
423
+ }
424
+ catch (error) {
425
+ if (error instanceof KnownError && error.status === 404) {
426
+ return undefined;
427
+ }
428
+ throw error;
429
+ }
430
+ }, _TestOpsClient_postTestResultsChunk = async function _TestOpsClient_postTestResultsChunk(trsChunk) {
371
431
  const extendedChunk = trsChunk.map((testResult) => {
372
432
  const extendedTestResult = {
373
433
  ...testResult,
@@ -404,16 +464,23 @@ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _
404
464
  if (typeof testOpsId !== "number") {
405
465
  return;
406
466
  }
407
- const attachments = await attachmentsResolver(tr);
408
- const fixtures = (await fixturesResolver(tr))
409
- .filter((fixture) => validateExecutableName(fixture.name))
410
- .map((fixture) => ({
411
- ...fixture,
412
- ...(fixture.steps ? { steps: normalizeTestStepsResults(fixture.steps) } : {}),
413
- }));
414
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadAttachmentsForResult).call(this, testOpsId, attachments);
415
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadFixturesForResult).call(this, testOpsId, fixtures);
416
- onProgress?.();
467
+ try {
468
+ const attachments = await attachmentsResolver(tr);
469
+ const fixtures = (await fixturesResolver(tr))
470
+ .filter((fixture) => validateExecutableName(fixture.name))
471
+ .map((fixture) => ({
472
+ ...fixture,
473
+ ...(fixture.steps ? { steps: normalizeTestStepsResults(fixture.steps) } : {}),
474
+ }));
475
+ await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadAttachmentsForResult).call(this, testOpsId, attachments);
476
+ await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadFixturesForResult).call(this, testOpsId, fixtures);
477
+ }
478
+ catch (error) {
479
+ __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_logResultUploadFailure).call(this, "fixtures", testOpsId, error);
480
+ }
481
+ finally {
482
+ onProgress?.();
483
+ }
417
484
  })));
418
485
  }, _TestOpsClient_uploadAttachmentsForResult = async function _TestOpsClient_uploadAttachmentsForResult(testOpsResultId, attachments) {
419
486
  if (attachments.length === 0) {
@@ -422,36 +489,42 @@ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _
422
489
  const attachmentsChunks = chunk(attachments, 100);
423
490
  for (const attachmentsChunk of attachmentsChunks) {
424
491
  const formData = new FormData();
492
+ let chunkBytes = 0;
425
493
  for (const att of attachmentsChunk) {
494
+ chunkBytes += attachmentByteLength(att);
426
495
  formData.append("file", att.content, {
427
496
  filename: att.originalFileName,
428
497
  contentType: att.contentType,
429
498
  });
430
499
  }
431
500
  try {
501
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1, files: attachmentsChunk.length, bytes: chunkBytes });
432
502
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/upload/test-result/${testOpsResultId}/attachment`, {
433
503
  body: formData,
434
504
  headers: formData.getHeaders(),
435
505
  });
436
506
  }
437
507
  catch (error) {
438
- if (this.isTestOpsClientError(error)) {
439
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}: ${error.response?.data.message}`);
440
- }
441
- else if (error instanceof Error) {
442
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}: ${error.message}`);
443
- }
444
- else {
445
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}`);
446
- }
508
+ __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_logResultUploadFailure).call(this, "attachments", testOpsResultId, error);
447
509
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").inspect(formData);
448
510
  }
449
511
  }
512
+ }, _TestOpsClient_logResultUploadFailure = function _TestOpsClient_logResultUploadFailure(label, testOpsResultId, error) {
513
+ if (this.isTestOpsClientError(error)) {
514
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload ${label} for result ${testOpsResultId}: ${error.response?.data.message}`);
515
+ }
516
+ else if (error instanceof Error) {
517
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload ${label} for result ${testOpsResultId}: ${error.message}`);
518
+ }
519
+ else {
520
+ __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload ${label} for result ${testOpsResultId}`);
521
+ }
450
522
  }, _TestOpsClient_uploadFixturesForResult = async function _TestOpsClient_uploadFixturesForResult(testOpsResultId, fixtures) {
451
523
  if (fixtures.length === 0)
452
524
  return;
453
525
  const body = toUploadFixturesResultsDto(fixtures);
454
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/upload/test-result/${testOpsResultId}/test-fixture-result`, {
526
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
527
+ await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/upload/test-result/${testOpsResultId}/test-fixture-result`, {
455
528
  body,
456
- });
529
+ }));
457
530
  };
@@ -0,0 +1,23 @@
1
+ export declare enum ErrorKind {
2
+ None = "none",
3
+ ServiceTransient = "service_transient",
4
+ ResourceRecoverable = "resource_recoverable",
5
+ AuthTerminal = "auth_terminal",
6
+ NotFoundTerminal = "not_found_terminal",
7
+ PayloadTerminal = "payload_terminal",
8
+ ValidationTerminal = "validation_terminal",
9
+ ConflictTerminal = "conflict_terminal",
10
+ Unknown = "unknown"
11
+ }
12
+ export declare const isClosedLaunchError: (error: unknown) => boolean;
13
+ export declare const classifyError: (error: unknown) => ErrorKind;
14
+ export declare const shouldRetryUpload: (error: unknown) => boolean;
15
+ export declare const isPermanentUploadError: (error: unknown) => boolean;
16
+ export declare const isTerminalUploadError: (error: unknown, retries: number, maxRetries: number) => boolean;
17
+ export type RetryOptions = {
18
+ maxRetries?: number;
19
+ baseDelayMs?: number;
20
+ maxDelayMs?: number;
21
+ onRetry?: (error: unknown, attempt: number) => void | Promise<void>;
22
+ };
23
+ export declare const withUploadRetry: <T>(operation: () => Promise<T>, options?: RetryOptions) => Promise<T>;