@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 +2 -0
- package/dist/client.d.ts +7 -3
- package/dist/client.js +169 -147
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +134 -0
- package/dist/gitFlow/LaunchGitFlow.js +11 -3
- package/dist/gitFlow/options.d.ts +1 -0
- package/dist/gitFlow/options.js +1 -1
- package/dist/model.d.ts +26 -1
- package/dist/plugin.js +127 -141
- package/dist/uploadPacer.d.ts +12 -0
- package/dist/uploadPacer.js +76 -0
- package/dist/uploadQueue.d.ts +15 -0
- package/dist/uploadQueue.js +61 -0
- package/dist/utils/ciOverrides.d.ts +3 -0
- package/dist/utils/ciOverrides.js +129 -0
- package/dist/utils/httpRetry.d.ts +4 -0
- package/dist/utils/httpRetry.js +62 -0
- package/dist/utils/jobParameters.d.ts +15 -0
- package/dist/utils/jobParameters.js +39 -0
- package/dist/utils/launchCategories.d.ts +7 -0
- package/dist/utils/launchCategories.js +97 -0
- package/dist/utils/options.d.ts +1 -0
- package/dist/utils/options.js +14 -2
- package/dist/utils/resolvers.js +1 -0
- package/dist/utils/testResults.js +0 -1
- package/package.json +8 -8
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
384
|
-
__classPrivateFieldGet(this,
|
|
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(),
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
}));
|
package/dist/errors.d.ts
ADDED
|
@@ -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>;
|