@tiden/reporter-commons 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +201 -0
- package/README.md +34 -0
- package/dist/client/interface.d.ts +8 -0
- package/dist/client/interface.js +2 -0
- package/dist/client/reporter-api-models.d.ts +96 -0
- package/dist/client/reporter-api-models.js +2 -0
- package/dist/client/reporter-api.d.ts +14 -0
- package/dist/client/reporter-api.js +25 -0
- 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 +29 -0
- package/dist/client/services/attachment-service.js +268 -0
- package/dist/client/services/result-transformer.d.ts +21 -0
- package/dist/client/services/result-transformer.js +180 -0
- package/dist/client/services/run-service.d.ts +20 -0
- package/dist/client/services/run-service.js +68 -0
- package/dist/client/tiden-client.d.ts +34 -0
- package/dist/client/tiden-client.js +99 -0
- package/dist/client/tiden-http.d.ts +11 -0
- package/dist/client/tiden-http.js +23 -0
- package/dist/config/config-loader-interface.d.ts +4 -0
- package/dist/config/config-loader-interface.js +2 -0
- package/dist/config/config-loader.d.ts +10 -0
- package/dist/config/config-loader.js +60 -0
- package/dist/config/config-type.d.ts +2 -0
- package/dist/config/config-type.js +2 -0
- package/dist/config/config-validation-schema.d.ts +212 -0
- package/dist/config/config-validation-schema.js +213 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +7 -0
- package/dist/core/options-resolver.d.ts +19 -0
- package/dist/core/options-resolver.js +47 -0
- package/dist/core/reporter-factory.d.ts +18 -0
- package/dist/core/reporter-factory.js +63 -0
- package/dist/core/status-processor.d.ts +17 -0
- package/dist/core/status-processor.js +48 -0
- package/dist/env/env-enum.d.ts +64 -0
- package/dist/env/env-enum.js +75 -0
- package/dist/env/env-to-config.d.ts +7 -0
- package/dist/env/env-to-config.js +60 -0
- package/dist/env/env-type.d.ts +29 -0
- package/dist/env/env-type.js +3 -0
- package/dist/env/env-validation-schema.d.ts +6 -0
- package/dist/env/env-validation-schema.js +113 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +13 -0
- package/dist/formatter/formatter-interface.d.ts +3 -0
- package/dist/formatter/formatter-interface.js +2 -0
- package/dist/formatter/index.d.ts +4 -0
- package/dist/formatter/index.js +9 -0
- package/dist/formatter/json-formatter.d.ts +13 -0
- package/dist/formatter/json-formatter.js +28 -0
- package/dist/formatter/jsonp-formatter.d.ts +13 -0
- package/dist/formatter/jsonp-formatter.js +29 -0
- package/dist/formatter/report-serializer.d.ts +20 -0
- package/dist/formatter/report-serializer.js +92 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -0
- package/dist/internal/filter-positive-ids.d.ts +17 -0
- package/dist/internal/filter-positive-ids.js +38 -0
- package/dist/internal/ids-parser.d.ts +9 -0
- package/dist/internal/ids-parser.js +21 -0
- package/dist/internal/index.d.ts +8 -0
- package/dist/internal/index.js +15 -0
- package/dist/internal/step-parser.d.ts +11 -0
- package/dist/internal/step-parser.js +28 -0
- package/dist/internal/suite-file.d.ts +13 -0
- package/dist/internal/suite-file.js +16 -0
- package/dist/internal/suite-normalizer.d.ts +6 -0
- package/dist/internal/suite-normalizer.js +11 -0
- package/dist/internal/title.d.ts +5 -0
- package/dist/internal/title.js +15 -0
- package/dist/models/attachment.d.ts +8 -0
- package/dist/models/attachment.js +2 -0
- package/dist/models/config/TidenOptionsType.d.ts +33 -0
- package/dist/models/config/TidenOptionsType.js +2 -0
- package/dist/models/configuration.d.ts +12 -0
- package/dist/models/configuration.js +2 -0
- package/dist/models/error.d.ts +7 -0
- package/dist/models/error.js +24 -0
- package/dist/models/execution-sum.d.ts +6 -0
- package/dist/models/execution-sum.js +2 -0
- package/dist/models/host-data.d.ts +12 -0
- package/dist/models/host-data.js +2 -0
- package/dist/models/index.d.ts +11 -0
- package/dist/models/index.js +15 -0
- package/dist/models/report.d.ts +14 -0
- package/dist/models/report.js +2 -0
- package/dist/models/short-result.d.ts +7 -0
- package/dist/models/short-result.js +2 -0
- package/dist/models/stats.d.ts +9 -0
- package/dist/models/stats.js +2 -0
- package/dist/models/step-data.d.ts +19 -0
- package/dist/models/step-data.js +2 -0
- package/dist/models/step-execution.d.ts +13 -0
- package/dist/models/step-execution.js +23 -0
- package/dist/models/test-execution.d.ts +20 -0
- package/dist/models/test-execution.js +32 -0
- package/dist/models/test-result.d.ts +44 -0
- package/dist/models/test-result.js +50 -0
- package/dist/models/test-step.d.ts +18 -0
- package/dist/models/test-step.js +53 -0
- package/dist/options/composeOptions.d.ts +8 -0
- package/dist/options/composeOptions.js +12 -0
- package/dist/options/index.d.ts +3 -0
- package/dist/options/index.js +7 -0
- package/dist/options/mode-enum.d.ts +8 -0
- package/dist/options/mode-enum.js +12 -0
- package/dist/options/options-type.d.ts +38 -0
- package/dist/options/options-type.js +3 -0
- package/dist/profilers/abstract-profiler.d.ts +5 -0
- package/dist/profilers/abstract-profiler.js +6 -0
- package/dist/profilers/fetch-interceptor.d.ts +60 -0
- package/dist/profilers/fetch-interceptor.js +277 -0
- package/dist/profilers/http-interceptor.d.ts +76 -0
- package/dist/profilers/http-interceptor.js +252 -0
- package/dist/profilers/index.d.ts +5 -0
- package/dist/profilers/index.js +11 -0
- package/dist/profilers/network-profiler.d.ts +61 -0
- package/dist/profilers/network-profiler.js +94 -0
- package/dist/reporters/abstract-reporter.d.ts +68 -0
- package/dist/reporters/abstract-reporter.js +78 -0
- package/dist/reporters/index.d.ts +3 -0
- package/dist/reporters/index.js +9 -0
- package/dist/reporters/report-reporter.d.ts +50 -0
- package/dist/reporters/report-reporter.js +167 -0
- package/dist/reporters/run-reporter.d.ts +65 -0
- package/dist/reporters/run-reporter.js +155 -0
- package/dist/reporters/shared/fallback-coordinator.d.ts +47 -0
- package/dist/reporters/shared/fallback-coordinator.js +119 -0
- package/dist/reporters/shared/run-constants.d.ts +11 -0
- package/dist/reporters/shared/run-constants.js +14 -0
- package/dist/state/state.d.ts +16 -0
- package/dist/state/state.js +70 -0
- package/dist/steps/step.d.ts +16 -0
- package/dist/steps/step.js +86 -0
- package/dist/tiden.d.ts +48 -0
- package/dist/tiden.js +223 -0
- package/dist/utils/disabled-exception.d.ts +6 -0
- package/dist/utils/disabled-exception.js +10 -0
- package/dist/utils/get-package-version.d.ts +5 -0
- package/dist/utils/get-package-version.js +25 -0
- package/dist/utils/hostData.d.ts +8 -0
- package/dist/utils/hostData.js +140 -0
- package/dist/utils/is-axios-error.d.ts +6 -0
- package/dist/utils/is-axios-error.js +11 -0
- package/dist/utils/logger.d.ts +33 -0
- package/dist/utils/logger.js +141 -0
- package/dist/utils/mimeTypes.d.ts +5 -0
- package/dist/utils/mimeTypes.js +50 -0
- package/dist/utils/project-mapping-utils.d.ts +17 -0
- package/dist/utils/project-mapping-utils.js +29 -0
- package/dist/utils/signature.d.ts +11 -0
- package/dist/utils/signature.js +34 -0
- package/dist/utils/status-mapping-utils.d.ts +14 -0
- package/dist/utils/status-mapping-utils.js +45 -0
- package/dist/utils/test-status-utils.d.ts +8 -0
- package/dist/utils/test-status-utils.js +152 -0
- package/dist/utils/tiden-error.d.ts +18 -0
- package/dist/utils/tiden-error.js +19 -0
- package/dist/utils/token-masker.d.ts +11 -0
- package/dist/utils/token-masker.js +26 -0
- package/dist/utils/validate-json.d.ts +20 -0
- package/dist/utils/validate-json.js +41 -0
- package/dist/writer/driver-enum.d.ts +13 -0
- package/dist/writer/driver-enum.js +18 -0
- package/dist/writer/fs-writer.d.ts +40 -0
- package/dist/writer/fs-writer.js +139 -0
- package/dist/writer/index.d.ts +3 -0
- package/dist/writer/index.js +8 -0
- package/dist/writer/writer-interface.d.ts +7 -0
- package/dist/writer/writer-interface.js +2 -0
- package/package.json +83 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AttachmentService = void 0;
|
|
7
|
+
const form_data_1 = __importDefault(require("form-data"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const is_axios_error_1 = require("../../utils/is-axios-error");
|
|
10
|
+
const api_error_handler_1 = require("./api-error-handler");
|
|
11
|
+
const MAX_FILE_SIZE = 32 * 1024 * 1024; // 32 MB per file
|
|
12
|
+
const MAX_REQUEST_SIZE = 128 * 1024 * 1024; // 128 MB per request
|
|
13
|
+
const MAX_FILES_PER_REQUEST = 20; // 20 files per request
|
|
14
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/=]+$/;
|
|
15
|
+
// The round-trip check only catches malformed/misplaced padding: ANY
|
|
16
|
+
// complete, unpadded base64-alphabet string round-trips regardless of
|
|
17
|
+
// length, so a plain word like "abcd" is indistinguishable from genuine
|
|
18
|
+
// base64 by round-trip alone. This length floor is therefore the sole
|
|
19
|
+
// heuristic separating short plain words from base64; 8 (two full groups)
|
|
20
|
+
// keeps common words as utf8 at the cost of misreading genuinely
|
|
21
|
+
// base64-encoded content shorter than 6 bytes as literal text — an
|
|
22
|
+
// acceptable trade-off since real attachments are far larger.
|
|
23
|
+
const MIN_BASE64_LENGTH = 8;
|
|
24
|
+
/**
|
|
25
|
+
* Heuristically decide whether a string is base64-encoded content rather
|
|
26
|
+
* than plain utf8 text. Requires ALL of: base64-alphabet-only characters,
|
|
27
|
+
* a length that's a multiple of 4, a minimum length (see MIN_BASE64_LENGTH),
|
|
28
|
+
* and a decode/re-encode round-trip match (catches non-canonical padding).
|
|
29
|
+
*/
|
|
30
|
+
function looksLikeBase64(value) {
|
|
31
|
+
if (!BASE64_PATTERN.test(value))
|
|
32
|
+
return false;
|
|
33
|
+
if (value.length % 4 !== 0)
|
|
34
|
+
return false;
|
|
35
|
+
if (value.length < MIN_BASE64_LENGTH)
|
|
36
|
+
return false;
|
|
37
|
+
const normalize = (s) => s.replace(/=+$/, '');
|
|
38
|
+
const reencoded = Buffer.from(value, 'base64').toString('base64');
|
|
39
|
+
return normalize(reencoded) === normalize(value);
|
|
40
|
+
}
|
|
41
|
+
class AttachmentService {
|
|
42
|
+
logger;
|
|
43
|
+
http;
|
|
44
|
+
constructor(logger, http) {
|
|
45
|
+
this.logger = logger;
|
|
46
|
+
this.http = http;
|
|
47
|
+
}
|
|
48
|
+
async uploadAttachment(projectCode, attachment) {
|
|
49
|
+
try {
|
|
50
|
+
const data = this.prepareAttachmentData(attachment);
|
|
51
|
+
const response = await this.postAttachmentBatch(projectCode, [data]);
|
|
52
|
+
return response.data.result?.[0]?.hash ?? '';
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw (0, api_error_handler_1.processError)(error, 'Error on uploading attachment');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async uploadAttachments(projectCode, attachments, uploadEnabled) {
|
|
59
|
+
if (!uploadEnabled) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const uploadedHashes = [];
|
|
63
|
+
const validAttachments = [];
|
|
64
|
+
for (const attachment of attachments) {
|
|
65
|
+
if (!attachment)
|
|
66
|
+
continue;
|
|
67
|
+
this.ensureAttachmentSize(attachment);
|
|
68
|
+
if (attachment.size === 0) {
|
|
69
|
+
this.logger.logError(`Cannot determine size for attachment "${attachment.file_path ?? attachment.file_name}". Skipping.`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (attachment.size > MAX_FILE_SIZE) {
|
|
73
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum file size (32 MB). ` +
|
|
74
|
+
`File size: ${(attachment.size / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
validAttachments.push(attachment);
|
|
78
|
+
}
|
|
79
|
+
if (validAttachments.length === 0) {
|
|
80
|
+
return uploadedHashes;
|
|
81
|
+
}
|
|
82
|
+
const initialJitter = Math.random() * 500;
|
|
83
|
+
await this.delay(initialJitter);
|
|
84
|
+
const batches = this.groupIntoBatches(validAttachments);
|
|
85
|
+
this.logger.logDebug(`Uploading ${validAttachments.length} attachments in ${batches.length} batch(es)`);
|
|
86
|
+
for (let i = 0; i < batches.length; i++) {
|
|
87
|
+
const batch = batches[i];
|
|
88
|
+
if (!batch || batch.length === 0)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const batchNames = batch.map(a => a.file_path ?? a.file_name).join(', ');
|
|
92
|
+
this.logger.logDebug(`Uploading batch ${i + 1}/${batches.length} with ${batch.length} file(s): ${batchNames}`);
|
|
93
|
+
const batchData = batch.map(a => this.prepareAttachmentData(a));
|
|
94
|
+
const response = await this.uploadWithRetry(projectCode, batchData, batchNames);
|
|
95
|
+
if (response.data.result) {
|
|
96
|
+
for (const result of response.data.result) {
|
|
97
|
+
if (result.hash) {
|
|
98
|
+
uploadedHashes.push(result.hash);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.logger.logError(`Cannot upload batch ${i + 1}:`, error);
|
|
105
|
+
}
|
|
106
|
+
if (i < batches.length - 1) {
|
|
107
|
+
const baseDelay = 1000;
|
|
108
|
+
const jitter = Math.random() * 300;
|
|
109
|
+
await this.delay(baseDelay + jitter);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return uploadedHashes;
|
|
113
|
+
}
|
|
114
|
+
groupIntoBatches(attachments) {
|
|
115
|
+
const batches = [];
|
|
116
|
+
let currentBatch = [];
|
|
117
|
+
let currentBatchSize = 0;
|
|
118
|
+
for (const attachment of attachments) {
|
|
119
|
+
const attachmentSize = attachment.size;
|
|
120
|
+
const wouldExceedFileLimit = currentBatch.length >= MAX_FILES_PER_REQUEST;
|
|
121
|
+
const wouldExceedSizeLimit = currentBatchSize + attachmentSize > MAX_REQUEST_SIZE;
|
|
122
|
+
if (wouldExceedFileLimit || wouldExceedSizeLimit) {
|
|
123
|
+
if (currentBatch.length > 0) {
|
|
124
|
+
batches.push(currentBatch);
|
|
125
|
+
currentBatch = [];
|
|
126
|
+
currentBatchSize = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (attachmentSize > MAX_REQUEST_SIZE) {
|
|
130
|
+
this.logger.logError(`Attachment "${attachment.file_path ?? attachment.file_name}" exceeds maximum request size (128 MB). ` +
|
|
131
|
+
`File size: ${(attachmentSize / (1024 * 1024)).toFixed(2)} MB. Skipping.`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
currentBatch.push(attachment);
|
|
135
|
+
currentBatchSize += attachmentSize;
|
|
136
|
+
}
|
|
137
|
+
if (currentBatch.length > 0) {
|
|
138
|
+
batches.push(currentBatch);
|
|
139
|
+
}
|
|
140
|
+
return batches;
|
|
141
|
+
}
|
|
142
|
+
/** POST one multipart batch to Tiden. Returns the upstream-shaped
|
|
143
|
+
* `{data: {result: [{hash}]}}` envelope so callers stay unchanged
|
|
144
|
+
* (Tiden's response body is `{status, result: [{hash, ...}]}`).
|
|
145
|
+
*
|
|
146
|
+
* Hand-written on purpose: `POST /v1/products/{product}/attachments:upload`
|
|
147
|
+
* is a multipart route that is NOT in the OpenAPI spec, so
|
|
148
|
+
* The generated OpenAPI snapshot has no operation for it (the path appears
|
|
149
|
+
* there only inside a doc comment on testRunServiceGetRunAttachment). It
|
|
150
|
+
* still goes over the same axios instance as the reporter JSON calls, so
|
|
151
|
+
* baseURL/auth/timeout stay shared; only the request building is local.
|
|
152
|
+
* If the route is ever added to the spec, replace this with the generated
|
|
153
|
+
* operation. */
|
|
154
|
+
async postAttachmentBatch(projectCode, data) {
|
|
155
|
+
const form = new form_data_1.default();
|
|
156
|
+
for (const item of data) {
|
|
157
|
+
form.append('file[]', item.value, {
|
|
158
|
+
filename: item.name,
|
|
159
|
+
...(item.contentType ? { contentType: item.contentType } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const response = await this.http.post(`/v1/products/${projectCode}/attachments:upload`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
|
|
163
|
+
return { data: response.data };
|
|
164
|
+
}
|
|
165
|
+
async uploadWithRetry(projectCode, data, attachmentNames, maxRetries = 5, initialDelay = 1000) {
|
|
166
|
+
let lastError;
|
|
167
|
+
let delay = initialDelay;
|
|
168
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
return await this.postAttachmentBatch(projectCode, data);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
lastError = error;
|
|
174
|
+
if ((0, is_axios_error_1.isAxiosError)(error)) {
|
|
175
|
+
if (error.response?.status === 429) {
|
|
176
|
+
if (attempt < maxRetries) {
|
|
177
|
+
const retryAfter = this.getRetryAfter(error);
|
|
178
|
+
const baseWaitTime = retryAfter ?? delay;
|
|
179
|
+
const jitterPercent = 0.1 + Math.random() * 0.2;
|
|
180
|
+
const jitter = baseWaitTime * jitterPercent;
|
|
181
|
+
const waitTime = Math.floor(baseWaitTime + jitter);
|
|
182
|
+
this.logger.logDebug(`Rate limit exceeded (429) for attachment(s) "${attachmentNames}". ` +
|
|
183
|
+
`Retrying in ${waitTime}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
184
|
+
await this.delay(waitTime);
|
|
185
|
+
delay = Math.min(delay * 2, 30000);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
this.logger.logError(`Failed to upload attachment(s) "${attachmentNames}" after ${maxRetries} retries due to rate limiting`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw lastError;
|
|
201
|
+
}
|
|
202
|
+
getRetryAfter(error) {
|
|
203
|
+
const headers = error.response?.headers;
|
|
204
|
+
if (!headers)
|
|
205
|
+
return null;
|
|
206
|
+
const retryAfterHeader = headers['retry-after'];
|
|
207
|
+
if (retryAfterHeader && typeof retryAfterHeader === 'string') {
|
|
208
|
+
const retryAfterSeconds = parseInt(retryAfterHeader, 10);
|
|
209
|
+
if (!isNaN(retryAfterSeconds)) {
|
|
210
|
+
return retryAfterSeconds * 1000;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
delay(ms) {
|
|
216
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
217
|
+
}
|
|
218
|
+
ensureAttachmentSize(attachment) {
|
|
219
|
+
if (attachment.size > 0)
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
if (attachment.file_path) {
|
|
223
|
+
const stats = (0, fs_1.statSync)(attachment.file_path);
|
|
224
|
+
attachment.size = stats.size;
|
|
225
|
+
}
|
|
226
|
+
else if (attachment.content) {
|
|
227
|
+
if (typeof attachment.content === 'string') {
|
|
228
|
+
if (looksLikeBase64(attachment.content)) {
|
|
229
|
+
attachment.size = Buffer.from(attachment.content, 'base64').length;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
attachment.size = Buffer.byteLength(attachment.content, 'utf8');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (Buffer.isBuffer(attachment.content)) {
|
|
236
|
+
attachment.size = attachment.content.length;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
attachment.size = Buffer.byteLength(String(attachment.content), 'utf8');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
this.logger.logDebug(`Could not determine size for attachment "${attachment.file_path ?? attachment.file_name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
245
|
+
attachment.size = 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
prepareAttachmentData(attachment) {
|
|
249
|
+
const contentType = typeof attachment.mime_type === 'string' && attachment.mime_type.length > 0
|
|
250
|
+
? attachment.mime_type
|
|
251
|
+
: undefined;
|
|
252
|
+
if (attachment.file_path) {
|
|
253
|
+
return {
|
|
254
|
+
name: attachment.file_name,
|
|
255
|
+
value: (0, fs_1.createReadStream)(attachment.file_path),
|
|
256
|
+
...(contentType ? { contentType } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
name: attachment.file_name,
|
|
261
|
+
value: typeof attachment.content === 'string'
|
|
262
|
+
? Buffer.from(attachment.content, looksLikeBase64(attachment.content) ? 'base64' : 'utf8')
|
|
263
|
+
: attachment.content,
|
|
264
|
+
...(contentType ? { contentType } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
exports.AttachmentService = AttachmentService;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ResultCreate } from '../reporter-api-models';
|
|
2
|
+
import { Attachment, TestResultType } from '../../models';
|
|
3
|
+
import { LoggerInterface } from '../../utils/logger';
|
|
4
|
+
export declare class ResultTransformer {
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private readonly rootSuite;
|
|
7
|
+
constructor(logger: LoggerInterface, rootSuite: string | undefined);
|
|
8
|
+
transform(result: TestResultType, attachmentUploader: (attachment: Attachment) => Promise<string>): Promise<ResultCreate>;
|
|
9
|
+
transformWithDefect(result: TestResultType, attachmentUploader: (attachment: Attachment) => Promise<string>, defect: boolean): Promise<ResultCreate>;
|
|
10
|
+
private uploadAttachments;
|
|
11
|
+
private transformSteps;
|
|
12
|
+
private transformStep;
|
|
13
|
+
private createBaseResultStep;
|
|
14
|
+
private processTextStep;
|
|
15
|
+
private processGherkinStep;
|
|
16
|
+
private processRequestStep;
|
|
17
|
+
private getExecution;
|
|
18
|
+
private transformParams;
|
|
19
|
+
private transformGroupParams;
|
|
20
|
+
private getSuitePath;
|
|
21
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ResultTransformer = void 0;
|
|
7
|
+
const models_1 = require("../../models");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const statusMap = {
|
|
10
|
+
[models_1.TestStatusEnum.passed]: 'passed',
|
|
11
|
+
[models_1.TestStatusEnum.failed]: 'failed',
|
|
12
|
+
[models_1.TestStatusEnum.skipped]: 'skipped',
|
|
13
|
+
[models_1.TestStatusEnum.disabled]: 'skipped',
|
|
14
|
+
[models_1.TestStatusEnum.blocked]: 'blocked',
|
|
15
|
+
[models_1.TestStatusEnum.invalid]: 'invalid',
|
|
16
|
+
};
|
|
17
|
+
const stepStatusMap = {
|
|
18
|
+
[models_1.StepStatusEnum.passed]: 'passed',
|
|
19
|
+
[models_1.StepStatusEnum.failed]: 'failed',
|
|
20
|
+
[models_1.StepStatusEnum.blocked]: 'blocked',
|
|
21
|
+
[models_1.StepStatusEnum.skipped]: 'skipped',
|
|
22
|
+
};
|
|
23
|
+
class ResultTransformer {
|
|
24
|
+
logger;
|
|
25
|
+
rootSuite;
|
|
26
|
+
constructor(logger, rootSuite) {
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
this.rootSuite = rootSuite;
|
|
29
|
+
}
|
|
30
|
+
async transform(result, attachmentUploader) {
|
|
31
|
+
const attachments = await this.uploadAttachments(result.attachments, attachmentUploader);
|
|
32
|
+
if (result.preparedAttachments) {
|
|
33
|
+
attachments.push(...result.preparedAttachments);
|
|
34
|
+
}
|
|
35
|
+
const steps = await this.transformSteps(result.steps, result.title, attachmentUploader);
|
|
36
|
+
const params = this.transformParams(result.params);
|
|
37
|
+
const groupParams = this.transformGroupParams(result.group_params, params);
|
|
38
|
+
// No linked case ids → omit the field (previously an explicit null; both
|
|
39
|
+
// decode to an empty repeated field server-side).
|
|
40
|
+
const caseIds = Array.isArray(result.case_id)
|
|
41
|
+
? (result.case_id.length > 0 ? result.case_id : null)
|
|
42
|
+
: result.case_id !== null ? [result.case_id] : null;
|
|
43
|
+
// Field names/types follow the generated `ResultCreate` contract and are
|
|
44
|
+
// checked against it at test time: JSON is lowerCamelCase and int64s are
|
|
45
|
+
// strings. Nullable internal values are
|
|
46
|
+
// omitted rather than sent as `null` — protojson maps both to the field's
|
|
47
|
+
// zero value, and omitting keeps the generated optional types honest.
|
|
48
|
+
const model = {
|
|
49
|
+
id: result.id,
|
|
50
|
+
title: result.title,
|
|
51
|
+
execution: this.getExecution(result.execution),
|
|
52
|
+
...(caseIds ? { testopsIds: caseIds } : {}),
|
|
53
|
+
attachments: attachments,
|
|
54
|
+
steps: steps,
|
|
55
|
+
params: params,
|
|
56
|
+
paramGroups: groupParams,
|
|
57
|
+
suitePath: this.getSuitePath(result.relations),
|
|
58
|
+
...(result.message != null ? { message: result.message } : {}),
|
|
59
|
+
fields: result.fields,
|
|
60
|
+
defect: false,
|
|
61
|
+
...(result.signature != null ? { signature: result.signature } : {}),
|
|
62
|
+
};
|
|
63
|
+
if (result.tags && result.tags.length > 0) {
|
|
64
|
+
model.fields = {
|
|
65
|
+
...model.fields,
|
|
66
|
+
tags: [...new Set(result.tags)].join(','),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
this.logger.logDebug(`Transformed result: ${JSON.stringify(model)}`);
|
|
70
|
+
return model;
|
|
71
|
+
}
|
|
72
|
+
async transformWithDefect(result, attachmentUploader, defect) {
|
|
73
|
+
const model = await this.transform(result, attachmentUploader);
|
|
74
|
+
model.defect = defect;
|
|
75
|
+
return model;
|
|
76
|
+
}
|
|
77
|
+
async uploadAttachments(attachments, uploader) {
|
|
78
|
+
const hashes = [];
|
|
79
|
+
for (const attachment of attachments) {
|
|
80
|
+
const hash = await uploader(attachment);
|
|
81
|
+
if (hash)
|
|
82
|
+
hashes.push(hash);
|
|
83
|
+
}
|
|
84
|
+
return hashes;
|
|
85
|
+
}
|
|
86
|
+
async transformSteps(steps, testTitle, attachmentUploader) {
|
|
87
|
+
return Promise.all(steps.map(step => this.transformStep(step, testTitle, attachmentUploader)));
|
|
88
|
+
}
|
|
89
|
+
async transformStep(step, testTitle, attachmentUploader) {
|
|
90
|
+
const attachmentHashes = await this.uploadAttachments(step.attachments, attachmentUploader);
|
|
91
|
+
const resultStep = this.createBaseResultStep(attachmentHashes, step.execution.status);
|
|
92
|
+
if (step.step_type === models_1.StepType.TEXT) {
|
|
93
|
+
this.processTextStep(step, resultStep, testTitle);
|
|
94
|
+
}
|
|
95
|
+
else if (step.step_type === models_1.StepType.GHERKIN) {
|
|
96
|
+
this.processGherkinStep(step, resultStep);
|
|
97
|
+
}
|
|
98
|
+
else if (step.step_type === models_1.StepType.REQUEST) {
|
|
99
|
+
this.processRequestStep(step, resultStep);
|
|
100
|
+
}
|
|
101
|
+
if (step.steps.length > 0) {
|
|
102
|
+
resultStep.steps = await this.transformSteps(step.steps, testTitle, attachmentUploader);
|
|
103
|
+
}
|
|
104
|
+
return resultStep;
|
|
105
|
+
}
|
|
106
|
+
createBaseResultStep(attachmentHashes, status) {
|
|
107
|
+
return {
|
|
108
|
+
data: { action: '' },
|
|
109
|
+
execution: {
|
|
110
|
+
status: stepStatusMap[status],
|
|
111
|
+
attachments: attachmentHashes,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
processTextStep(step, resultStep, testTitle) {
|
|
116
|
+
if (!('action' in step.data) || !resultStep.data)
|
|
117
|
+
return;
|
|
118
|
+
const stepData = step.data;
|
|
119
|
+
resultStep.data.action = stepData.action || 'Unnamed step';
|
|
120
|
+
if (stepData.action === '') {
|
|
121
|
+
this.logger.log((0, chalk_1.default) `{magenta Test '${testTitle}' has empty action in step. The reporter will mark this step as unnamed step.}`);
|
|
122
|
+
}
|
|
123
|
+
if (stepData.expected_result != null) {
|
|
124
|
+
resultStep.data.expectedResult = stepData.expected_result;
|
|
125
|
+
}
|
|
126
|
+
if (stepData.data != null) {
|
|
127
|
+
resultStep.data.inputData = stepData.data;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
processGherkinStep(step, resultStep) {
|
|
131
|
+
if (!('keyword' in step.data) || !resultStep.data)
|
|
132
|
+
return;
|
|
133
|
+
resultStep.data.action = step.data.keyword;
|
|
134
|
+
}
|
|
135
|
+
processRequestStep(step, resultStep) {
|
|
136
|
+
if (!('request_method' in step.data) || !resultStep.data)
|
|
137
|
+
return;
|
|
138
|
+
const stepData = step.data;
|
|
139
|
+
resultStep.data.action = `${stepData.request_method} ${stepData.request_url}`;
|
|
140
|
+
}
|
|
141
|
+
getExecution(exec) {
|
|
142
|
+
// `duration` is an int64 in the proto, so the generated type carries it as
|
|
143
|
+
// a string (protojson's int64 convention); the timestamps stay numbers
|
|
144
|
+
// (Unix seconds, fractional ms).
|
|
145
|
+
return {
|
|
146
|
+
status: statusMap[exec.status],
|
|
147
|
+
...(exec.start_time != null ? { startTime: exec.start_time } : {}),
|
|
148
|
+
...(exec.end_time != null ? { endTime: exec.end_time } : {}),
|
|
149
|
+
...(exec.duration != null ? { duration: String(exec.duration) } : {}),
|
|
150
|
+
...(exec.stacktrace != null ? { stacktrace: exec.stacktrace } : {}),
|
|
151
|
+
...(exec.thread != null ? { thread: exec.thread } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
transformParams(params) {
|
|
155
|
+
const transformedParams = {};
|
|
156
|
+
for (const [key, value] of Object.entries(params)) {
|
|
157
|
+
if (value != null) {
|
|
158
|
+
transformedParams[key] = String(value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return transformedParams;
|
|
162
|
+
}
|
|
163
|
+
transformGroupParams(groupParams, params) {
|
|
164
|
+
const keys = Object.keys(groupParams);
|
|
165
|
+
if (keys.length === 0)
|
|
166
|
+
return [];
|
|
167
|
+
for (const [key, value] of Object.entries(groupParams)) {
|
|
168
|
+
if (value) {
|
|
169
|
+
params[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [{ names: keys }];
|
|
173
|
+
}
|
|
174
|
+
getSuitePath(relation) {
|
|
175
|
+
const titles = relation?.suite?.data.map(d => d.title) ?? [];
|
|
176
|
+
const path = this.rootSuite ? [this.rootSuite, ...titles] : titles;
|
|
177
|
+
return path.map(title => ({ title }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
exports.ResultTransformer = ResultTransformer;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ReporterApi } from '../reporter-api';
|
|
2
|
+
import { LoggerInterface } from '../../utils/logger';
|
|
3
|
+
import { TidenOptionsType } from '../../models/config/TidenOptionsType';
|
|
4
|
+
export declare class RunService {
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private readonly api;
|
|
7
|
+
constructor(logger: LoggerInterface, api: ReporterApi);
|
|
8
|
+
/**
|
|
9
|
+
* Creates a Tiden run and returns its per-product seq number (int32).
|
|
10
|
+
* `environment` is a slug: the server resolves it and auto-creates
|
|
11
|
+
* unknown slugs. An empty title gets the server-side default.
|
|
12
|
+
*/
|
|
13
|
+
createRun(config: TidenOptionsType, environment?: string): Promise<number>;
|
|
14
|
+
/**
|
|
15
|
+
* Completes the run. Server-idempotent for already-completed runs, so the
|
|
16
|
+
* upstream pre-flight GET is gone. Completes by default; only skips when
|
|
17
|
+
* run.complete is explicitly false (sharded CI: the orchestrator owns completion).
|
|
18
|
+
*/
|
|
19
|
+
completeRun(runId: number, config: TidenOptionsType): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RunService = void 0;
|
|
4
|
+
const tiden_error_1 = require("../../utils/tiden-error");
|
|
5
|
+
const api_error_handler_1 = require("./api-error-handler");
|
|
6
|
+
class RunService {
|
|
7
|
+
logger;
|
|
8
|
+
api;
|
|
9
|
+
constructor(logger, api) {
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.api = api;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates a Tiden run and returns its per-product seq number (int32).
|
|
15
|
+
* `environment` is a slug: the server resolves it and auto-creates
|
|
16
|
+
* unknown slugs. An empty title gets the server-side default.
|
|
17
|
+
*/
|
|
18
|
+
async createRun(config, environment) {
|
|
19
|
+
if (config.run.id) {
|
|
20
|
+
return config.run.id; // sharded CI: pre-created run
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
// Body shape follows the OpenAPI contract (lowerCamelCase JSON, hence
|
|
24
|
+
// `clientMeta`) and is checked against its generated model in tests.
|
|
25
|
+
const body = {
|
|
26
|
+
title: config.run.title ?? '',
|
|
27
|
+
description: config.run.description ?? '',
|
|
28
|
+
environment: environment ?? '',
|
|
29
|
+
branch: config.run.branch ?? '',
|
|
30
|
+
configurations: config.configurations
|
|
31
|
+
? Object.fromEntries(config.configurations.values.map((v) => [v.name, v.value]))
|
|
32
|
+
: {},
|
|
33
|
+
clientMeta: config.clientMeta ?? {},
|
|
34
|
+
};
|
|
35
|
+
this.logger.logDebug(`Creating test run: ${JSON.stringify(body)}`);
|
|
36
|
+
const { data } = await this.api.createTestRun(config.product, body);
|
|
37
|
+
const seqNum = data.run?.seqNum;
|
|
38
|
+
if (!seqNum) {
|
|
39
|
+
throw new tiden_error_1.TidenError('Failed to create test run');
|
|
40
|
+
}
|
|
41
|
+
this.logger.logDebug(`Run create response: seqNum=${seqNum}`);
|
|
42
|
+
return seqNum;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw (0, api_error_handler_1.processError)(error, 'Error creating test run');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Completes the run. Server-idempotent for already-completed runs, so the
|
|
50
|
+
* upstream pre-flight GET is gone. Completes by default; only skips when
|
|
51
|
+
* run.complete is explicitly false (sharded CI: the orchestrator owns completion).
|
|
52
|
+
*/
|
|
53
|
+
async completeRun(runId, config) {
|
|
54
|
+
// Complete by default; only an explicit `complete: false` (sharded CI:
|
|
55
|
+
// the orchestrator owns completion) skips it.
|
|
56
|
+
if (config.run.complete === false) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await this.api.completeTestRun(config.product, runId);
|
|
61
|
+
this.logger.log(`Test run #${runId} completed`);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw (0, api_error_handler_1.processError)(error, 'Error on completing run');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.RunService = RunService;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { IClient } from './interface';
|
|
2
|
+
import { LoggerInterface } from '../utils/logger';
|
|
3
|
+
import { TidenOptionsType } from '../models/config/TidenOptionsType';
|
|
4
|
+
import { Attachment, TestResultType } from '../models';
|
|
5
|
+
/**
|
|
6
|
+
* Facade over the Tiden REST API.
|
|
7
|
+
*
|
|
8
|
+
* The JSON endpoints (create/report/complete) go through the narrow reporter
|
|
9
|
+
* API. Its types are checked against the generated OpenAPI snapshot at test
|
|
10
|
+
* time without making that full client a runtime dependency. Attachment
|
|
11
|
+
* upload stays hand-written because it is multipart. Both share one axios
|
|
12
|
+
* instance.
|
|
13
|
+
*/
|
|
14
|
+
export declare class TidenApiClient implements IClient {
|
|
15
|
+
private readonly logger;
|
|
16
|
+
private readonly config;
|
|
17
|
+
private readonly environment;
|
|
18
|
+
private readonly http;
|
|
19
|
+
private readonly api;
|
|
20
|
+
private readonly runService;
|
|
21
|
+
private readonly attachmentService;
|
|
22
|
+
private readonly resultTransformer;
|
|
23
|
+
constructor(logger: LoggerInterface, config: TidenOptionsType, environment: string | undefined, rootSuite: string | undefined);
|
|
24
|
+
createRun(): Promise<number>;
|
|
25
|
+
completeRun(runId: number): Promise<void>;
|
|
26
|
+
uploadAttachment(attachment: Attachment): Promise<string>;
|
|
27
|
+
/** Public run report links have no Tiden equivalent in v1. */
|
|
28
|
+
enablePublicReport(_runId: number): Promise<void>;
|
|
29
|
+
uploadResults(runId: number, results: TestResultType[]): Promise<void>;
|
|
30
|
+
/** 429-only retry (Retry-After honored, exp backoff, 30s cap); 400 is
|
|
31
|
+
* terminal — resending an identical batch fails identically, so log
|
|
32
|
+
* every per-entry ReportError from details[] and re-throw. */
|
|
33
|
+
private postResultsWithRetry;
|
|
34
|
+
}
|