@allurereport/plugin-testops 3.16.0 → 3.17.0

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,65 +9,29 @@ 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
- const MAX_REQUEST_RETRIES = 3;
29
- const RETRY_BASE_DELAY_MS = 500;
30
- const getAxiosCause = (error) => {
31
- if (isAxiosError(error)) {
32
- return error;
33
- }
34
- return error instanceof Error && isAxiosError(error.cause) ? error.cause : undefined;
35
- };
36
- const isRetryableRequestError = (error) => {
37
- const cause = getAxiosCause(error);
38
- if (!cause || cause.code === "ERR_CANCELED" || cause.name === "CanceledError") {
39
- return false;
40
- }
41
- const status = cause.response?.status;
42
- return status === undefined || status === 408 || status === 429 || status >= 500;
43
- };
44
- const retryAfterMs = (error) => {
45
- const headers = getAxiosCause(error)?.response?.headers;
46
- const value = typeof headers?.get === "function" ? headers.get("retry-after") : headers?.["retry-after"];
47
- const normalized = Array.isArray(value) ? value[0] : value;
48
- if (typeof normalized !== "string" && typeof normalized !== "number") {
49
- return undefined;
50
- }
51
- const seconds = Number(normalized);
52
- if (Number.isFinite(seconds)) {
53
- return Math.max(0, seconds * 1000);
54
- }
55
- const date = Date.parse(String(normalized));
56
- return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
57
- };
58
- const retryRequest = async (request) => {
59
- for (let attempt = 0;; attempt += 1) {
60
- try {
61
- return await request();
62
- }
63
- catch (error) {
64
- if (attempt >= MAX_REQUEST_RETRIES || !isRetryableRequestError(error)) {
65
- throw error;
66
- }
67
- await new Promise((resolve) => setTimeout(resolve, retryAfterMs(error) ?? RETRY_BASE_DELAY_MS * 2 ** attempt));
68
- }
69
- }
70
- };
71
35
  export class TestOpsClient {
72
36
  constructor(params) {
73
37
  _TestOpsClient_instances.add(this);
@@ -77,9 +41,11 @@ export class TestOpsClient {
77
41
  _TestOpsClient_projectId.set(this, void 0);
78
42
  _TestOpsClient_client.set(this, void 0);
79
43
  _TestOpsClient_launch.set(this, void 0);
44
+ _TestOpsClient_jobRunId.set(this, void 0);
80
45
  _TestOpsClient_session.set(this, void 0);
81
46
  _TestOpsClient_uploadInProgress.set(this, false);
82
47
  _TestOpsClient_uploadLimit.set(this, 1);
48
+ _TestOpsClient_uploadPacer.set(this, void 0);
83
49
  _TestOpsClient_namedEnvsIdsByEnv.set(this, new Map());
84
50
  if (!params.accessToken) {
85
51
  throw new Error("accessToken is required");
@@ -102,6 +68,7 @@ export class TestOpsClient {
102
68
  if (params.limit) {
103
69
  __classPrivateFieldSet(this, _TestOpsClient_uploadLimit, params.limit, "f");
104
70
  }
71
+ __classPrivateFieldSet(this, _TestOpsClient_uploadPacer, new UploadPacer(params.uploadRateLimit), "f");
105
72
  }
106
73
  isTestOpsClientError(error) {
107
74
  return (isAxiosError(error) &&
@@ -122,6 +89,11 @@ export class TestOpsClient {
122
89
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/launch/${launchId}/close`);
123
90
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Launch closed");
124
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
+ }
125
97
  async createLaunchCategoriesBulk(launchId, items) {
126
98
  if (items.length === 0) {
127
99
  return [];
@@ -157,29 +129,52 @@ export class TestOpsClient {
157
129
  }
158
130
  return results;
159
131
  }
160
- async startUpload(ci) {
161
- if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
132
+ async startUpload(ci, jobRunId) {
133
+ if (!jobRunId && !__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
162
134
  throw new Error("Launch isn't created! Call createLaunch first");
163
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
+ };
164
158
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose(`Starting CI upload (${ci.type})…`);
165
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/start", {
159
+ const data = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/start", {
166
160
  body: {
167
161
  projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
168
162
  ci: {
169
- name: ci.type,
170
- },
171
- job: {
172
- name: ci.jobUid,
173
- uid: ci.jobUid,
174
- },
175
- jobRun: {
176
- uid: ci.jobRunUid,
177
- },
178
- launch: {
179
- id: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
163
+ type: TESTOPS_CI_TYPE[ci.type] ?? ci.type,
164
+ ...(endpoint ? { endpoint } : {}),
180
165
  },
166
+ job: jobPayload,
167
+ jobRun: jobRunPayload,
168
+ ...(__classPrivateFieldGet(this, _TestOpsClient_launch, "f") ? { launch: { id: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id } } : {}),
181
169
  },
182
- });
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
+ }
183
178
  __classPrivateFieldSet(this, _TestOpsClient_uploadInProgress, true, "f");
184
179
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("CI upload started");
185
180
  }
@@ -187,14 +182,16 @@ export class TestOpsClient {
187
182
  if (!__classPrivateFieldGet(this, _TestOpsClient_uploadInProgress, "f")) {
188
183
  throw new Error("Upload isn't started! Call startUpload first");
189
184
  }
190
- await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/stop", {
191
- body: {
192
- jobRunUid: ci.jobRunUid,
193
- jobUid: ci.jobUid,
194
- projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
195
- status: testStatusToLaunchStatus(status),
196
- },
197
- });
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
+ }
198
195
  __classPrivateFieldSet(this, _TestOpsClient_uploadInProgress, false, "f");
199
196
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose(`CI upload stopped (status: ${status})`);
200
197
  }
@@ -202,7 +199,7 @@ export class TestOpsClient {
202
199
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Creating launch…");
203
200
  const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch", {
204
201
  body: {
205
- name: launchName,
202
+ name: launchName.slice(0, MAX_LAUNCH_NAME_LENGTH),
206
203
  projectId: __classPrivateFieldGet(this, _TestOpsClient_projectId, "f"),
207
204
  autoclose: true,
208
205
  external: true,
@@ -213,6 +210,9 @@ export class TestOpsClient {
213
210
  __classPrivateFieldSet(this, _TestOpsClient_launch, data, "f");
214
211
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").debug(`Launch created: id=${bold(data.id.toString())}`);
215
212
  }
213
+ attachToLaunch(launchId) {
214
+ __classPrivateFieldSet(this, _TestOpsClient_launch, { id: launchId }, "f");
215
+ }
216
216
  async checkLaunchProgress() {
217
217
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
218
218
  throw new Error("Launch isn't created! Call createLaunch first");
@@ -221,34 +221,33 @@ export class TestOpsClient {
221
221
  const data = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").get(`/api/launch/${__classPrivateFieldGet(this, _TestOpsClient_launch, "f").id}/progress`));
222
222
  return data.ready;
223
223
  }
224
- async createSession(environment = {}) {
224
+ async createSession(environment = {}, reopenClosedLaunch = false) {
225
225
  if (!__classPrivateFieldGet(this, _TestOpsClient_launch, "f")) {
226
226
  throw new Error("Launch isn't created! Call createLaunch first");
227
227
  }
228
- const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/session", {
229
- body: {
230
- launchId: __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id,
231
- environment: Object.entries(environment).map(([key, value]) => ({
232
- key,
233
- value: String(value),
234
- })),
235
- },
236
- params: {
237
- manual: "true",
238
- },
239
- });
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
+ });
240
247
  __classPrivateFieldSet(this, _TestOpsClient_session, data, "f");
241
248
  }
242
- get namedEnvs() {
243
- return __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").values();
244
- }
245
249
  getNamedEnvFor(id) {
246
- for (const [, env] of __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f")) {
247
- if (env.externalId === id) {
248
- return env;
249
- }
250
- }
251
- return undefined;
250
+ return __classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").get(id);
252
251
  }
253
252
  async createNamedEnvs(environments, onProgress) {
254
253
  if (!__classPrivateFieldGet(this, _TestOpsClient_session, "f")) {
@@ -293,11 +292,13 @@ export class TestOpsClient {
293
292
  const launchId = __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id;
294
293
  const formData = new FormData();
295
294
  let hasResolvedAttachments = false;
295
+ let totalBytes = 0;
296
296
  for (const attachmentLink of attachments) {
297
297
  const attachment = await attachmentsResolver(attachmentLink);
298
298
  if (!attachment) {
299
299
  continue;
300
300
  }
301
+ totalBytes += attachmentByteLength(attachment);
301
302
  formData.append("file", attachment.content, {
302
303
  filename: attachment.originalFileName,
303
304
  contentType: attachment.contentType,
@@ -307,6 +308,7 @@ export class TestOpsClient {
307
308
  if (!hasResolvedAttachments) {
308
309
  return;
309
310
  }
311
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1, files: attachments.length, bytes: totalBytes });
310
312
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/attachment", {
311
313
  body: formData,
312
314
  onUploadProgress(progressEvent) {
@@ -314,7 +316,7 @@ export class TestOpsClient {
314
316
  const percent = total > 0 ? Math.min(100, Math.max(0, (progressEvent.loaded / total) * 100)) : 0;
315
317
  onProgress?.(percent, total);
316
318
  },
317
- params: { launchId },
319
+ params: { launchId, ...(__classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId ? { jobRunId: __classPrivateFieldGet(this, _TestOpsClient_session, "f").jobRunId } : {}) },
318
320
  headers: formData.getHeaders(),
319
321
  });
320
322
  }
@@ -326,10 +328,12 @@ export class TestOpsClient {
326
328
  throw new Error("Launch isn't created! Call createLaunch first");
327
329
  }
328
330
  const launchId = __classPrivateFieldGet(this, _TestOpsClient_launch, "f").id;
331
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
329
332
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/error/bulk", {
330
333
  body: {
331
334
  launchId,
332
- items: errors,
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 })),
333
337
  },
334
338
  onUploadProgress(progressEvent) {
335
339
  const total = progressEvent.total ?? 100;
@@ -342,7 +346,7 @@ export class TestOpsClient {
342
346
  if (!__classPrivateFieldGet(this, _TestOpsClient_session, "f")) {
343
347
  throw new Error("Session isn't created! Call createSession first");
344
348
  }
345
- const { trs, environments, attachmentsResolver, fixturesResolver, onProgress } = params;
349
+ const { trs, environments, attachmentsResolver, fixturesResolver, onProgress, onRetry, onChunkUploaded } = params;
346
350
  const projectedTrs = trs.map((tr) => ({
347
351
  ...tr,
348
352
  ...(tr.steps ? { steps: normalizeTestStepsResults(tr.steps) } : {}),
@@ -351,39 +355,31 @@ export class TestOpsClient {
351
355
  const uploadLimitFn = pLimit(__classPrivateFieldGet(this, _TestOpsClient_uploadLimit, "f"));
352
356
  const uploadedTrs = [];
353
357
  const envNamesById = new Map(environments.map(({ id, name }) => [id, name]));
354
- try {
355
- for (const trsChunk of trsChunks) {
356
- const chunkEnvs = new Map();
357
- for (const tr of trsChunk) {
358
- const environmentId = tr.environment;
359
- if (environmentId && !__classPrivateFieldGet(this, _TestOpsClient_namedEnvsIdsByEnv, "f").has(environmentId)) {
360
- chunkEnvs.set(environmentId, {
361
- id: environmentId,
362
- name: envNamesById.get(environmentId) ?? environmentId,
363
- });
364
- }
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
+ });
365
367
  }
366
- if (chunkEnvs.size > 0) {
367
- await this.createNamedEnvs(Array.from(chunkEnvs.values()));
368
- }
369
- const reportIdsToTestOpsIds = await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_postTestResultsChunk).call(this, trsChunk);
370
- uploadedTrs.push(...trsChunk.filter((tr) => typeof reportIdsToTestOpsIds[tr.id] === "number"));
371
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadChunkAttachmentsAndFixtures).call(this, trsChunk, reportIdsToTestOpsIds, attachmentsResolver, fixturesResolver, uploadLimitFn, onProgress);
372
- }
373
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").verbose("Test results upload completed");
374
- }
375
- catch (error) {
376
- if (this.isTestOpsClientError(error)) {
377
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload test results: ${error.response?.data.message}`);
378
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").debug(error.response.data);
379
- }
380
- else if (error instanceof Error) {
381
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload test results: ${error.message}`);
382
368
  }
383
- else {
384
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error("Failed to upload test results");
385
- }
386
- }
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");
387
383
  return uploadedTrs;
388
384
  }
389
385
  async uploadQualityGateResults(results, onProgress) {
@@ -405,6 +401,7 @@ export class TestOpsClient {
405
401
  }
406
402
  return item;
407
403
  });
404
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
408
405
  await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/launch/quality-gate/bulk", {
409
406
  body: {
410
407
  launchId,
@@ -418,7 +415,19 @@ export class TestOpsClient {
418
415
  }));
419
416
  }
420
417
  }
421
- _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) {
422
431
  const extendedChunk = trsChunk.map((testResult) => {
423
432
  const extendedTestResult = {
424
433
  ...testResult,
@@ -438,10 +447,10 @@ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _
438
447
  return extendedTestResult;
439
448
  });
440
449
  const body = toUploadResultsDto(__classPrivateFieldGet(this, _TestOpsClient_session, "f").id, extendedChunk);
441
- const data = await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/test-result", {
450
+ const data = await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post("/api/upload/test-result", {
442
451
  body,
443
452
  headers: { "Content-Type": "application/json" },
444
- }));
453
+ });
445
454
  const reportIdsToTestOpsIds = {};
446
455
  for (const { id, uuid } of data.results ?? []) {
447
456
  if (typeof uuid === "string" && typeof id === "number") {
@@ -455,16 +464,23 @@ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _
455
464
  if (typeof testOpsId !== "number") {
456
465
  return;
457
466
  }
458
- const attachments = await attachmentsResolver(tr);
459
- const fixtures = (await fixturesResolver(tr))
460
- .filter((fixture) => validateExecutableName(fixture.name))
461
- .map((fixture) => ({
462
- ...fixture,
463
- ...(fixture.steps ? { steps: normalizeTestStepsResults(fixture.steps) } : {}),
464
- }));
465
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadAttachmentsForResult).call(this, testOpsId, attachments);
466
- await __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_uploadFixturesForResult).call(this, testOpsId, fixtures);
467
- 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
+ }
468
484
  })));
469
485
  }, _TestOpsClient_uploadAttachmentsForResult = async function _TestOpsClient_uploadAttachmentsForResult(testOpsResultId, attachments) {
470
486
  if (attachments.length === 0) {
@@ -473,35 +489,41 @@ _TestOpsClient_baseUrl = new WeakMap(), _TestOpsClient_logger = new WeakMap(), _
473
489
  const attachmentsChunks = chunk(attachments, 100);
474
490
  for (const attachmentsChunk of attachmentsChunks) {
475
491
  const formData = new FormData();
492
+ let chunkBytes = 0;
476
493
  for (const att of attachmentsChunk) {
494
+ chunkBytes += attachmentByteLength(att);
477
495
  formData.append("file", att.content, {
478
496
  filename: att.originalFileName,
479
497
  contentType: att.contentType,
480
498
  });
481
499
  }
482
500
  try {
501
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1, files: attachmentsChunk.length, bytes: chunkBytes });
483
502
  await __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/upload/test-result/${testOpsResultId}/attachment`, {
484
503
  body: formData,
485
504
  headers: formData.getHeaders(),
486
505
  });
487
506
  }
488
507
  catch (error) {
489
- if (this.isTestOpsClientError(error)) {
490
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}: ${error.response?.data.message}`);
491
- }
492
- else if (error instanceof Error) {
493
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}: ${error.message}`);
494
- }
495
- else {
496
- __classPrivateFieldGet(this, _TestOpsClient_logger, "f").error(`Failed to upload attachments for result ${testOpsResultId}`);
497
- }
508
+ __classPrivateFieldGet(this, _TestOpsClient_instances, "m", _TestOpsClient_logResultUploadFailure).call(this, "attachments", testOpsResultId, error);
498
509
  __classPrivateFieldGet(this, _TestOpsClient_logger, "f").inspect(formData);
499
510
  }
500
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
+ }
501
522
  }, _TestOpsClient_uploadFixturesForResult = async function _TestOpsClient_uploadFixturesForResult(testOpsResultId, fixtures) {
502
523
  if (fixtures.length === 0)
503
524
  return;
504
525
  const body = toUploadFixturesResultsDto(fixtures);
526
+ await __classPrivateFieldGet(this, _TestOpsClient_uploadPacer, "f").wait({ requests: 1 });
505
527
  await retryRequest(() => __classPrivateFieldGet(this, _TestOpsClient_client, "f").post(`/api/upload/test-result/${testOpsResultId}/test-fixture-result`, {
506
528
  body,
507
529
  }));
@@ -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>;