qase-javascript-commons 2.6.3 → 2.6.4
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/dist/client/clientV1.d.ts +6 -83
- package/dist/client/clientV1.js +15 -549
- package/dist/client/clientV2.d.ts +6 -24
- package/dist/client/clientV2.js +8 -255
- package/dist/client/services/api-error-handler.d.ts +10 -0
- package/dist/client/services/api-error-handler.js +44 -0
- package/dist/client/services/attachment-service.d.ts +16 -0
- package/dist/client/services/attachment-service.js +209 -0
- package/dist/client/services/configuration-service.d.ts +12 -0
- package/dist/client/services/configuration-service.js +110 -0
- package/dist/client/services/result-transformer.d.ts +23 -0
- package/dist/client/services/result-transformer.js +188 -0
- package/dist/client/services/run-service.d.ts +17 -0
- package/dist/client/services/run-service.js +114 -0
- package/dist/client/transport/api-config-builder.d.ts +8 -0
- package/dist/client/transport/api-config-builder.js +96 -0
- package/package.json +1 -1
package/dist/client/clientV2.js
CHANGED
|
@@ -1,277 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.ClientV2 = void 0;
|
|
7
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
8
4
|
const qase_api_v2_client_1 = require("qase-api-v2-client");
|
|
9
|
-
const models_1 = require("../models");
|
|
10
5
|
const clientV1_1 = require("./clientV1");
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
BASE_URL: 'https://api-',
|
|
15
|
-
VERSION: '/v2'
|
|
16
|
-
};
|
|
6
|
+
const api_config_builder_1 = require("./transport/api-config-builder");
|
|
7
|
+
const result_transformer_1 = require("./services/result-transformer");
|
|
8
|
+
const api_error_handler_1 = require("./services/api-error-handler");
|
|
17
9
|
class ClientV2 extends clientV1_1.ClientV1 {
|
|
18
|
-
rootSuite;
|
|
19
|
-
static statusMap = {
|
|
20
|
-
[models_1.TestStatusEnum.passed]: 'passed',
|
|
21
|
-
[models_1.TestStatusEnum.failed]: 'failed',
|
|
22
|
-
[models_1.TestStatusEnum.skipped]: 'skipped',
|
|
23
|
-
[models_1.TestStatusEnum.disabled]: 'skipped',
|
|
24
|
-
[models_1.TestStatusEnum.blocked]: 'blocked',
|
|
25
|
-
[models_1.TestStatusEnum.invalid]: 'invalid',
|
|
26
|
-
};
|
|
27
|
-
static stepStatusMap = {
|
|
28
|
-
[models_1.StepStatusEnum.passed]: qase_api_v2_client_1.ResultStepStatus.PASSED,
|
|
29
|
-
[models_1.StepStatusEnum.failed]: qase_api_v2_client_1.ResultStepStatus.FAILED,
|
|
30
|
-
[models_1.StepStatusEnum.blocked]: qase_api_v2_client_1.ResultStepStatus.BLOCKED,
|
|
31
|
-
[models_1.StepStatusEnum.skipped]: qase_api_v2_client_1.ResultStepStatus.SKIPPED,
|
|
32
|
-
};
|
|
33
10
|
resultsClient;
|
|
11
|
+
resultTransformer;
|
|
34
12
|
constructor(logger, config, environment, rootSuite, hostData, reporterName, frameworkName) {
|
|
35
13
|
super(logger, config, environment);
|
|
36
|
-
|
|
37
|
-
const apiConfig = this.createApiConfigV2(hostData, reporterName, frameworkName);
|
|
14
|
+
const apiConfig = (0, api_config_builder_1.createApiConfigV2)(config, hostData, reporterName, frameworkName);
|
|
38
15
|
this.resultsClient = new qase_api_v2_client_1.ResultsApi(apiConfig);
|
|
39
|
-
|
|
40
|
-
createApiConfigV2(hostData, reporterName, frameworkName) {
|
|
41
|
-
const apiConfig = new qase_api_v2_client_1.Configuration({ apiKey: this.config.api.token, formDataCtor: form_data_1.default });
|
|
42
|
-
apiConfig.basePath = this.config.api.host && this.config.api.host != API_CONFIG.DEFAULT_HOST
|
|
43
|
-
? `${API_CONFIG.BASE_URL}${this.config.api.host}${API_CONFIG.VERSION}`
|
|
44
|
-
: `https://api.${API_CONFIG.DEFAULT_HOST}${API_CONFIG.VERSION}`;
|
|
45
|
-
// Set default headers for all requests
|
|
46
|
-
if (hostData) {
|
|
47
|
-
const headers = this.buildHeaders(hostData, reporterName, frameworkName);
|
|
48
|
-
const existingHeaders = apiConfig.baseOptions?.headers || {};
|
|
49
|
-
const baseOptionsWithHeaders = {
|
|
50
|
-
...(apiConfig.baseOptions || {}),
|
|
51
|
-
headers: {
|
|
52
|
-
...existingHeaders,
|
|
53
|
-
...headers,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
apiConfig.baseOptions = baseOptionsWithHeaders;
|
|
57
|
-
}
|
|
58
|
-
return apiConfig;
|
|
59
|
-
}
|
|
60
|
-
buildHeaders(hostData, reporterName, frameworkName) {
|
|
61
|
-
const headers = {};
|
|
62
|
-
// Build X-Client header
|
|
63
|
-
const clientParts = [];
|
|
64
|
-
if (reporterName && reporterName.trim()) {
|
|
65
|
-
clientParts.push(`reporter=${reporterName}`);
|
|
66
|
-
}
|
|
67
|
-
if (hostData.reporter && hostData.reporter.trim()) {
|
|
68
|
-
clientParts.push(`reporter_version=${hostData.reporter}`);
|
|
69
|
-
}
|
|
70
|
-
if (frameworkName && frameworkName.trim()) {
|
|
71
|
-
clientParts.push(`framework=${frameworkName}`);
|
|
72
|
-
}
|
|
73
|
-
if (hostData.framework && hostData.framework.trim()) {
|
|
74
|
-
clientParts.push(`framework_version=${hostData.framework}`);
|
|
75
|
-
}
|
|
76
|
-
if (hostData.apiClientV1 && hostData.apiClientV1.trim()) {
|
|
77
|
-
clientParts.push(`client_version_v1=${hostData.apiClientV1}`);
|
|
78
|
-
}
|
|
79
|
-
if (hostData.apiClientV2 && hostData.apiClientV2.trim()) {
|
|
80
|
-
clientParts.push(`client_version_v2=${hostData.apiClientV2}`);
|
|
81
|
-
}
|
|
82
|
-
if (hostData.commons && hostData.commons.trim()) {
|
|
83
|
-
clientParts.push(`core_version=${hostData.commons}`);
|
|
84
|
-
}
|
|
85
|
-
if (clientParts.length > 0) {
|
|
86
|
-
headers['X-Client'] = clientParts.join(';');
|
|
87
|
-
}
|
|
88
|
-
// Build X-Platform header
|
|
89
|
-
const platformParts = [];
|
|
90
|
-
if (hostData.system && hostData.system.trim()) {
|
|
91
|
-
platformParts.push(`os=${hostData.system}`);
|
|
92
|
-
}
|
|
93
|
-
if (hostData.arch && hostData.arch.trim()) {
|
|
94
|
-
platformParts.push(`arch=${hostData.arch}`);
|
|
95
|
-
}
|
|
96
|
-
if (hostData.language && hostData.language.trim()) {
|
|
97
|
-
platformParts.push(`node=${hostData.language}`);
|
|
98
|
-
}
|
|
99
|
-
if (hostData.packageManager && hostData.packageManager.trim()) {
|
|
100
|
-
platformParts.push(`npm=${hostData.packageManager}`);
|
|
101
|
-
}
|
|
102
|
-
if (platformParts.length > 0) {
|
|
103
|
-
headers['X-Platform'] = platformParts.join(';');
|
|
104
|
-
}
|
|
105
|
-
return headers;
|
|
16
|
+
this.resultTransformer = new result_transformer_1.ResultTransformer(logger, rootSuite);
|
|
106
17
|
}
|
|
107
18
|
async uploadResults(runId, results) {
|
|
108
19
|
try {
|
|
109
|
-
const models = await Promise.all(results.map(result => this.
|
|
20
|
+
const models = await Promise.all(results.map(result => this.resultTransformer.transformWithDefect(result, (a) => this.attachmentService.uploadAttachments(this.config.project, [a], this.config.uploadAttachments ?? true).then(hashes => hashes[0] ?? ''), this.config.defect ?? false)));
|
|
110
21
|
await this.resultsClient.createResultsV2(this.config.project, runId, {
|
|
111
22
|
results: models,
|
|
112
23
|
});
|
|
113
24
|
}
|
|
114
25
|
catch (error) {
|
|
115
|
-
throw
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
async transformTestResult(result) {
|
|
119
|
-
const attachments = await this.uploadAttachments(result.attachments);
|
|
120
|
-
if (result.preparedAttachments) {
|
|
121
|
-
attachments.push(...result.preparedAttachments);
|
|
122
|
-
}
|
|
123
|
-
const steps = await this.transformSteps(result.steps, result.title);
|
|
124
|
-
const params = this.transformParams(result.params);
|
|
125
|
-
const groupParams = this.transformGroupParams(result.group_params, params);
|
|
126
|
-
const relations = this.getRelation(result.relations);
|
|
127
|
-
const model = {
|
|
128
|
-
title: result.title,
|
|
129
|
-
execution: this.getExecution(result.execution),
|
|
130
|
-
testops_ids: Array.isArray(result.testops_id)
|
|
131
|
-
? result.testops_id
|
|
132
|
-
: result.testops_id !== null ? [result.testops_id] : null,
|
|
133
|
-
attachments: attachments,
|
|
134
|
-
steps: steps,
|
|
135
|
-
params: params,
|
|
136
|
-
param_groups: groupParams,
|
|
137
|
-
relations: relations,
|
|
138
|
-
message: result.message,
|
|
139
|
-
fields: result.fields,
|
|
140
|
-
defect: this.config.defect ?? false,
|
|
141
|
-
signature: result.signature,
|
|
142
|
-
};
|
|
143
|
-
if (result.tags.length > 0) {
|
|
144
|
-
model.fields = {
|
|
145
|
-
...model.fields,
|
|
146
|
-
tags: [...new Set(result.tags)].join(','),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
this.logger.logDebug(`Transformed result: ${JSON.stringify(model)}`);
|
|
150
|
-
return model;
|
|
151
|
-
}
|
|
152
|
-
transformParams(params) {
|
|
153
|
-
const transformedParams = {};
|
|
154
|
-
for (const [key, value] of Object.entries(params)) {
|
|
155
|
-
if (value != null) {
|
|
156
|
-
transformedParams[key] = String(value);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return transformedParams;
|
|
160
|
-
}
|
|
161
|
-
transformGroupParams(groupParams, params) {
|
|
162
|
-
const keys = Object.keys(groupParams);
|
|
163
|
-
if (keys.length === 0) {
|
|
164
|
-
return [];
|
|
165
|
-
}
|
|
166
|
-
for (const [key, value] of Object.entries(groupParams)) {
|
|
167
|
-
if (value) {
|
|
168
|
-
params[key] = value;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return [keys];
|
|
172
|
-
}
|
|
173
|
-
async transformSteps(steps, testTitle) {
|
|
174
|
-
return Promise.all(steps.map(step => this.transformStep(step, testTitle)));
|
|
175
|
-
}
|
|
176
|
-
async transformStep(step, testTitle) {
|
|
177
|
-
const attachmentHashes = await this.uploadAttachments(step.attachments);
|
|
178
|
-
const resultStep = this.createBaseResultStep(attachmentHashes, step.execution.status);
|
|
179
|
-
if (step.step_type === models_1.StepType.TEXT) {
|
|
180
|
-
this.processTextStep(step, resultStep, testTitle);
|
|
181
|
-
}
|
|
182
|
-
else if (step.step_type === models_1.StepType.GHERKIN) {
|
|
183
|
-
this.processGherkinStep(step, resultStep);
|
|
184
|
-
}
|
|
185
|
-
else if (step.step_type === models_1.StepType.REQUEST) {
|
|
186
|
-
this.processRequestStep(step, resultStep);
|
|
187
|
-
}
|
|
188
|
-
if (step.steps.length > 0) {
|
|
189
|
-
resultStep.steps = await this.transformSteps(step.steps, testTitle);
|
|
190
|
-
}
|
|
191
|
-
return resultStep;
|
|
192
|
-
}
|
|
193
|
-
createBaseResultStep(attachmentHashes, status) {
|
|
194
|
-
return {
|
|
195
|
-
data: {
|
|
196
|
-
action: '',
|
|
197
|
-
},
|
|
198
|
-
execution: {
|
|
199
|
-
status: ClientV2.stepStatusMap[status],
|
|
200
|
-
attachments: attachmentHashes,
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
processTextStep(step, resultStep, testTitle) {
|
|
205
|
-
if (!('action' in step.data) || !resultStep.data) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const stepData = step.data;
|
|
209
|
-
resultStep.data.action = stepData.action || 'Unnamed step';
|
|
210
|
-
if (stepData.action === '') {
|
|
211
|
-
this.logger.log((0, chalk_1.default) `{magenta Test '${testTitle}' has empty action in step. The reporter will mark this step as unnamed step.}`);
|
|
212
|
-
}
|
|
213
|
-
if (stepData.expected_result != null) {
|
|
214
|
-
resultStep.data.expected_result = stepData.expected_result;
|
|
215
|
-
}
|
|
216
|
-
if (stepData.data != null) {
|
|
217
|
-
resultStep.data.input_data = stepData.data;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
processGherkinStep(step, resultStep) {
|
|
221
|
-
if (!('keyword' in step.data) || !resultStep.data) {
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
const stepData = step.data;
|
|
225
|
-
resultStep.data.action = stepData.keyword;
|
|
226
|
-
}
|
|
227
|
-
processRequestStep(step, resultStep) {
|
|
228
|
-
if (!('request_method' in step.data) || !resultStep.data) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const stepData = step.data;
|
|
232
|
-
resultStep.data.action = `${stepData.request_method} ${stepData.request_url}`;
|
|
233
|
-
}
|
|
234
|
-
getExecution(exec) {
|
|
235
|
-
return {
|
|
236
|
-
status: ClientV2.statusMap[exec.status],
|
|
237
|
-
start_time: exec.start_time,
|
|
238
|
-
end_time: exec.end_time,
|
|
239
|
-
duration: exec.duration,
|
|
240
|
-
stacktrace: exec.stacktrace,
|
|
241
|
-
thread: exec.thread,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
getRelation(relation) {
|
|
245
|
-
if (!relation?.suite) {
|
|
246
|
-
return this.getDefaultSuiteRelation();
|
|
247
|
-
}
|
|
248
|
-
const suiteData = this.buildSuiteData(relation.suite.data);
|
|
249
|
-
return { suite: { data: suiteData } };
|
|
250
|
-
}
|
|
251
|
-
getDefaultSuiteRelation() {
|
|
252
|
-
if (!this.rootSuite)
|
|
253
|
-
return {};
|
|
254
|
-
return {
|
|
255
|
-
suite: {
|
|
256
|
-
data: [{
|
|
257
|
-
public_id: null,
|
|
258
|
-
title: this.rootSuite,
|
|
259
|
-
}],
|
|
260
|
-
},
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
buildSuiteData(suiteData) {
|
|
264
|
-
const result = [];
|
|
265
|
-
if (this.rootSuite) {
|
|
266
|
-
result.push({
|
|
267
|
-
public_id: null,
|
|
268
|
-
title: this.rootSuite,
|
|
269
|
-
});
|
|
26
|
+
throw (0, api_error_handler_1.processError)(error, 'Error on uploading results', results);
|
|
270
27
|
}
|
|
271
|
-
return result.concat(suiteData.map(data => ({
|
|
272
|
-
public_id: null,
|
|
273
|
-
title: data.title,
|
|
274
|
-
})));
|
|
275
28
|
}
|
|
276
29
|
}
|
|
277
30
|
exports.ClientV2 = ClientV2;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { QaseError } from '../../utils/qase-error';
|
|
2
|
+
export declare enum ApiErrorCode {
|
|
3
|
+
UNAUTHORIZED = 401,
|
|
4
|
+
FORBIDDEN = 403,
|
|
5
|
+
NOT_FOUND = 404,
|
|
6
|
+
BAD_REQUEST = 400,
|
|
7
|
+
UNPROCESSABLE_ENTITY = 422
|
|
8
|
+
}
|
|
9
|
+
export declare function processError(error: unknown, message: string, model?: object): QaseError;
|
|
10
|
+
export declare function getErrorMessage(error: unknown): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ApiErrorCode = void 0;
|
|
4
|
+
exports.processError = processError;
|
|
5
|
+
exports.getErrorMessage = getErrorMessage;
|
|
6
|
+
const is_axios_error_1 = require("../../utils/is-axios-error");
|
|
7
|
+
const qase_error_1 = require("../../utils/qase-error");
|
|
8
|
+
var ApiErrorCode;
|
|
9
|
+
(function (ApiErrorCode) {
|
|
10
|
+
ApiErrorCode[ApiErrorCode["UNAUTHORIZED"] = 401] = "UNAUTHORIZED";
|
|
11
|
+
ApiErrorCode[ApiErrorCode["FORBIDDEN"] = 403] = "FORBIDDEN";
|
|
12
|
+
ApiErrorCode[ApiErrorCode["NOT_FOUND"] = 404] = "NOT_FOUND";
|
|
13
|
+
ApiErrorCode[ApiErrorCode["BAD_REQUEST"] = 400] = "BAD_REQUEST";
|
|
14
|
+
ApiErrorCode[ApiErrorCode["UNPROCESSABLE_ENTITY"] = 422] = "UNPROCESSABLE_ENTITY";
|
|
15
|
+
})(ApiErrorCode || (exports.ApiErrorCode = ApiErrorCode = {}));
|
|
16
|
+
function processError(error, message, model) {
|
|
17
|
+
if (!(0, is_axios_error_1.isAxiosError)(error)) {
|
|
18
|
+
return new qase_error_1.QaseError(message, { cause: error });
|
|
19
|
+
}
|
|
20
|
+
const err = error;
|
|
21
|
+
const errorData = err.response?.data;
|
|
22
|
+
const status = err.response?.status;
|
|
23
|
+
switch (status) {
|
|
24
|
+
case ApiErrorCode.UNAUTHORIZED:
|
|
25
|
+
return new qase_error_1.QaseError(`${message}: Unauthorized. Please check your API token.`);
|
|
26
|
+
case ApiErrorCode.FORBIDDEN:
|
|
27
|
+
return new qase_error_1.QaseError(`${message}: ${errorData?.errorMessage ?? 'Forbidden'}`);
|
|
28
|
+
case ApiErrorCode.NOT_FOUND:
|
|
29
|
+
return new qase_error_1.QaseError(`${message}: Not found.`);
|
|
30
|
+
case ApiErrorCode.BAD_REQUEST:
|
|
31
|
+
case ApiErrorCode.UNPROCESSABLE_ENTITY:
|
|
32
|
+
return new qase_error_1.QaseError(`${message}: Bad request\n${JSON.stringify(errorData)}\nBody: ${JSON.stringify(model)}`);
|
|
33
|
+
default:
|
|
34
|
+
return new qase_error_1.QaseError(message, { cause: err });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function getErrorMessage(error) {
|
|
38
|
+
if ((0, is_axios_error_1.isAxiosError)(error)) {
|
|
39
|
+
const err = error;
|
|
40
|
+
const errorData = err.response?.data;
|
|
41
|
+
return errorData?.errorMessage ?? errorData?.error ?? errorData?.message ?? 'Unknown API error';
|
|
42
|
+
}
|
|
43
|
+
return error instanceof Error ? error.message : 'Unknown error';
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AttachmentsApi } from 'qase-api-client';
|
|
2
|
+
import { Attachment } from '../../models';
|
|
3
|
+
import { LoggerInterface } from '../../utils/logger';
|
|
4
|
+
export declare class AttachmentService {
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private readonly attachmentClient;
|
|
7
|
+
constructor(logger: LoggerInterface, attachmentClient: AttachmentsApi);
|
|
8
|
+
uploadAttachment(projectCode: string, attachment: Attachment): Promise<string>;
|
|
9
|
+
uploadAttachments(projectCode: string, attachments: Attachment[], uploadEnabled: boolean): Promise<string[]>;
|
|
10
|
+
private groupIntoBatches;
|
|
11
|
+
private uploadWithRetry;
|
|
12
|
+
private getRetryAfter;
|
|
13
|
+
private delay;
|
|
14
|
+
private ensureAttachmentSize;
|
|
15
|
+
private prepareAttachmentData;
|
|
16
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AttachmentService = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const is_axios_error_1 = require("../../utils/is-axios-error");
|
|
6
|
+
const api_error_handler_1 = require("./api-error-handler");
|
|
7
|
+
const MAX_FILE_SIZE = 32 * 1024 * 1024; // 32 MB per file
|
|
8
|
+
const MAX_REQUEST_SIZE = 128 * 1024 * 1024; // 128 MB per request
|
|
9
|
+
const MAX_FILES_PER_REQUEST = 20; // 20 files per request
|
|
10
|
+
class AttachmentService {
|
|
11
|
+
logger;
|
|
12
|
+
attachmentClient;
|
|
13
|
+
constructor(logger, attachmentClient) {
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.attachmentClient = attachmentClient;
|
|
16
|
+
}
|
|
17
|
+
async uploadAttachment(projectCode, attachment) {
|
|
18
|
+
try {
|
|
19
|
+
const data = this.prepareAttachmentData(attachment);
|
|
20
|
+
const response = await this.attachmentClient.uploadAttachment(projectCode, [data]);
|
|
21
|
+
return response.data.result?.[0]?.hash ?? '';
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw (0, api_error_handler_1.processError)(error, 'Error on uploading attachment');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async uploadAttachments(projectCode, attachments, uploadEnabled) {
|
|
28
|
+
if (!uploadEnabled) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const uploadedHashes = [];
|
|
32
|
+
const validAttachments = [];
|
|
33
|
+
for (const attachment of attachments) {
|
|
34
|
+
if (!attachment)
|
|
35
|
+
continue;
|
|
36
|
+
this.ensureAttachmentSize(attachment);
|
|
37
|
+
if (attachment.size === 0) {
|
|
38
|
+
this.logger.logError(`Cannot determine size for attachment "${attachment.file_path ?? attachment.file_name}". Skipping.`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (attachment.size > MAX_FILE_SIZE) {
|
|
42
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum file size (32 MB). ` +
|
|
43
|
+
`File size: ${(attachment.size / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
validAttachments.push(attachment);
|
|
47
|
+
}
|
|
48
|
+
if (validAttachments.length === 0) {
|
|
49
|
+
return uploadedHashes;
|
|
50
|
+
}
|
|
51
|
+
const initialJitter = Math.random() * 500;
|
|
52
|
+
await this.delay(initialJitter);
|
|
53
|
+
const batches = this.groupIntoBatches(validAttachments);
|
|
54
|
+
this.logger.logDebug(`Uploading ${validAttachments.length} attachments in ${batches.length} batch(es)`);
|
|
55
|
+
for (let i = 0; i < batches.length; i++) {
|
|
56
|
+
const batch = batches[i];
|
|
57
|
+
if (!batch || batch.length === 0)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const batchNames = batch.map(a => a.file_path ?? a.file_name).join(', ');
|
|
61
|
+
this.logger.logDebug(`Uploading batch ${i + 1}/${batches.length} with ${batch.length} file(s): ${batchNames}`);
|
|
62
|
+
const batchData = batch.map(a => this.prepareAttachmentData(a));
|
|
63
|
+
const response = await this.uploadWithRetry(projectCode, batchData, batchNames);
|
|
64
|
+
if (response.data.result) {
|
|
65
|
+
for (const result of response.data.result) {
|
|
66
|
+
if (result.hash) {
|
|
67
|
+
uploadedHashes.push(result.hash);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.logger.logError(`Cannot upload batch ${i + 1}:`, error);
|
|
74
|
+
}
|
|
75
|
+
if (i < batches.length - 1) {
|
|
76
|
+
const baseDelay = 1000;
|
|
77
|
+
const jitter = Math.random() * 300;
|
|
78
|
+
await this.delay(baseDelay + jitter);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return uploadedHashes;
|
|
82
|
+
}
|
|
83
|
+
groupIntoBatches(attachments) {
|
|
84
|
+
const batches = [];
|
|
85
|
+
let currentBatch = [];
|
|
86
|
+
let currentBatchSize = 0;
|
|
87
|
+
for (const attachment of attachments) {
|
|
88
|
+
const attachmentSize = attachment.size;
|
|
89
|
+
const wouldExceedFileLimit = currentBatch.length >= MAX_FILES_PER_REQUEST;
|
|
90
|
+
const wouldExceedSizeLimit = currentBatchSize + attachmentSize > MAX_REQUEST_SIZE;
|
|
91
|
+
if (wouldExceedFileLimit || wouldExceedSizeLimit) {
|
|
92
|
+
if (currentBatch.length > 0) {
|
|
93
|
+
batches.push(currentBatch);
|
|
94
|
+
currentBatch = [];
|
|
95
|
+
currentBatchSize = 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (attachmentSize > MAX_REQUEST_SIZE) {
|
|
99
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum request size (128 MB). ` +
|
|
100
|
+
`File size: ${(attachmentSize / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
currentBatch.push(attachment);
|
|
104
|
+
currentBatchSize += attachmentSize;
|
|
105
|
+
}
|
|
106
|
+
if (currentBatch.length > 0) {
|
|
107
|
+
batches.push(currentBatch);
|
|
108
|
+
}
|
|
109
|
+
return batches;
|
|
110
|
+
}
|
|
111
|
+
async uploadWithRetry(projectCode, data, attachmentNames, maxRetries = 5, initialDelay = 1000) {
|
|
112
|
+
let lastError;
|
|
113
|
+
let delay = initialDelay;
|
|
114
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
115
|
+
try {
|
|
116
|
+
return await this.attachmentClient.uploadAttachment(projectCode, data);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
lastError = error;
|
|
120
|
+
if ((0, is_axios_error_1.isAxiosError)(error)) {
|
|
121
|
+
if (error.response?.status === 429) {
|
|
122
|
+
if (attempt < maxRetries) {
|
|
123
|
+
const retryAfter = this.getRetryAfter(error);
|
|
124
|
+
const baseWaitTime = retryAfter ?? delay;
|
|
125
|
+
const jitterPercent = 0.1 + Math.random() * 0.2;
|
|
126
|
+
const jitter = baseWaitTime * jitterPercent;
|
|
127
|
+
const waitTime = Math.floor(baseWaitTime + jitter);
|
|
128
|
+
this.logger.logDebug(`Rate limit exceeded (429) for attachment(s) "${attachmentNames}". ` +
|
|
129
|
+
`Retrying in ${waitTime}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
130
|
+
await this.delay(waitTime);
|
|
131
|
+
delay = Math.min(delay * 2, 30000);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
this.logger.logError(`Failed to upload attachment(s) "${attachmentNames}" after ${maxRetries} retries due to rate limiting`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw lastError;
|
|
147
|
+
}
|
|
148
|
+
getRetryAfter(error) {
|
|
149
|
+
const headers = error.response?.headers;
|
|
150
|
+
if (!headers)
|
|
151
|
+
return null;
|
|
152
|
+
const retryAfterHeader = headers['retry-after'];
|
|
153
|
+
if (retryAfterHeader && typeof retryAfterHeader === 'string') {
|
|
154
|
+
const retryAfterSeconds = parseInt(retryAfterHeader, 10);
|
|
155
|
+
if (!isNaN(retryAfterSeconds)) {
|
|
156
|
+
return retryAfterSeconds * 1000;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
delay(ms) {
|
|
162
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
163
|
+
}
|
|
164
|
+
ensureAttachmentSize(attachment) {
|
|
165
|
+
if (attachment.size > 0)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
if (attachment.file_path) {
|
|
169
|
+
const stats = (0, fs_1.statSync)(attachment.file_path);
|
|
170
|
+
attachment.size = stats.size;
|
|
171
|
+
}
|
|
172
|
+
else if (attachment.content) {
|
|
173
|
+
if (typeof attachment.content === 'string') {
|
|
174
|
+
if (attachment.content.match(/^[A-Za-z0-9+/=]+$/)) {
|
|
175
|
+
attachment.size = Buffer.from(attachment.content, 'base64').length;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
attachment.size = Buffer.byteLength(attachment.content, 'utf8');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (Buffer.isBuffer(attachment.content)) {
|
|
182
|
+
attachment.size = attachment.content.length;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
attachment.size = Buffer.byteLength(String(attachment.content), 'utf8');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
this.logger.logDebug(`Could not determine size for attachment "${attachment.file_path ?? attachment.file_name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
191
|
+
attachment.size = 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
prepareAttachmentData(attachment) {
|
|
195
|
+
if (attachment.file_path) {
|
|
196
|
+
return {
|
|
197
|
+
name: attachment.file_name,
|
|
198
|
+
value: (0, fs_1.createReadStream)(attachment.file_path),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
name: attachment.file_name,
|
|
203
|
+
value: typeof attachment.content === 'string'
|
|
204
|
+
? Buffer.from(attachment.content, attachment.content.match(/^[A-Za-z0-9+/=]+$/) ? 'base64' : undefined)
|
|
205
|
+
: attachment.content,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
exports.AttachmentService = AttachmentService;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ConfigurationsApi } from 'qase-api-client';
|
|
2
|
+
import { TestOpsConfigurationType } from '../../models/config/TestOpsOptionsType';
|
|
3
|
+
import { LoggerInterface } from '../../utils/logger';
|
|
4
|
+
export declare class ConfigurationService {
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private readonly configurationClient;
|
|
7
|
+
constructor(logger: LoggerInterface, configurationClient: ConfigurationsApi);
|
|
8
|
+
handleConfigurations(projectCode: string, configurations: TestOpsConfigurationType): Promise<number[]>;
|
|
9
|
+
private getConfigurations;
|
|
10
|
+
private createConfigurationGroup;
|
|
11
|
+
private createConfiguration;
|
|
12
|
+
}
|