@testomatio/reporter 1.5.1-beta → 1.6.0-beta-1-artifacts
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 -1
- package/lib/adapter/codecept.js +8 -7
- package/lib/adapter/cucumber/current.js +2 -4
- package/lib/adapter/playwright.js +30 -22
- package/lib/bin/cli.js +220 -0
- package/lib/bin/startTest.js +2 -2
- package/lib/bin/uploadArtifacts.js +91 -0
- package/lib/client.js +50 -35
- package/lib/pipe/bitbucket.js +254 -0
- package/lib/pipe/index.js +2 -0
- package/lib/pipe/testomatio.js +10 -4
- package/lib/uploader.js +308 -0
- package/lib/utils/pipe_utils.js +1 -1
- package/lib/xmlReader.js +6 -4
- package/package.json +7 -3
- package/lib/fileUploader.js +0 -306
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const debug = require('debug')('@testomatio/reporter:pipe:bitbucket');
|
|
2
|
+
const { default: axios } = require('axios');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const humanizeDuration = require('humanize-duration');
|
|
5
|
+
const merge = require('lodash.merge');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { APP_PREFIX, testomatLogoURL } = require('../constants');
|
|
8
|
+
const { ansiRegExp, isSameTest } = require('../utils/utils');
|
|
9
|
+
const { statusEmoji, fullName } = require('../utils/pipe_utils');
|
|
10
|
+
|
|
11
|
+
//! BITBUCKET_ACCESS_TOKEN environment variable is required for this functionality to work
|
|
12
|
+
//! and your pipeline trigger should be a pull request
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @class BitbucketPipe
|
|
16
|
+
* @typedef {import('../../types').Pipe} Pipe
|
|
17
|
+
* @typedef {import('../../types').TestData} TestData
|
|
18
|
+
*/
|
|
19
|
+
class BitbucketPipe {
|
|
20
|
+
constructor(params, store = {}) {
|
|
21
|
+
this.isEnabled = false;
|
|
22
|
+
this.ENV = process.env;
|
|
23
|
+
this.store = store;
|
|
24
|
+
this.tests = [];
|
|
25
|
+
// Bitbucket PAT looks like bbpat-*****
|
|
26
|
+
this.token = params.BITBUCKET_ACCESS_TOKEN || process.env.BITBUCKET_ACCESS_TOKEN || this.ENV.BITBUCKET_ACCESS_TOKEN;
|
|
27
|
+
this.hiddenCommentData = `Testomat.io report: ${process.env.BITBUCKET_BRANCH || ''}`;
|
|
28
|
+
|
|
29
|
+
debug(
|
|
30
|
+
chalk.yellow('Bitbucket Pipe:'),
|
|
31
|
+
this.token ? 'TOKEN passed' : '*no token*',
|
|
32
|
+
`Project key: ${this.ENV.BITBUCKET_PROJECT_KEY}, Pull request ID: ${this.ENV.BITBUCKET_PR_ID}`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (!this.token) {
|
|
36
|
+
debug(`Hint: Bitbucket CI variables are unavailable for unprotected branches by default.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.isEnabled = true;
|
|
41
|
+
|
|
42
|
+
debug('Bitbucket Pipe: Enabled');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async cleanLog(log) {
|
|
46
|
+
const stripAnsi = (await import('strip-ansi')).default;
|
|
47
|
+
return stripAnsi(log);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Prepare the run (if needed)
|
|
51
|
+
async prepareRun() {}
|
|
52
|
+
|
|
53
|
+
// Create a new run (if needed)
|
|
54
|
+
async createRun() {}
|
|
55
|
+
|
|
56
|
+
addTest(test) {
|
|
57
|
+
if (!this.isEnabled) return;
|
|
58
|
+
|
|
59
|
+
const index = this.tests.findIndex(t => isSameTest(t, test));
|
|
60
|
+
// Update if they were already added
|
|
61
|
+
if (index >= 0) {
|
|
62
|
+
this.tests[index] = merge(this.tests[index], test);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.tests.push(test);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async finishRun(runParams) {
|
|
70
|
+
if (!this.isEnabled) return;
|
|
71
|
+
|
|
72
|
+
if (runParams.tests) runParams.tests.forEach(t => this.addTest(t));
|
|
73
|
+
|
|
74
|
+
// Clean up the logs from ANSI codes
|
|
75
|
+
for (let i = 0; i < this.tests.length; i++) {
|
|
76
|
+
this.tests[i].message = await this.cleanLog(this.tests[i].message || '');
|
|
77
|
+
this.tests[i].stack = await this.cleanLog(this.tests[i].stack || '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create a comment on Bitbucket
|
|
81
|
+
const passedCount = this.tests.filter(t => t.status === 'passed').length;
|
|
82
|
+
const failedCount = this.tests.filter(t => t.status === 'failed').length;
|
|
83
|
+
const skippedCount = this.tests.filter(t => t.status === 'skipped').length;
|
|
84
|
+
|
|
85
|
+
// Constructing the table
|
|
86
|
+
let summary = `${this.hiddenCommentData}
|
|
87
|
+
|
|
88
|
+
|  | ${statusEmoji(
|
|
89
|
+
runParams.status,
|
|
90
|
+
)} ${runParams.status.toUpperCase()} ${statusEmoji(runParams.status)} |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| **Tests** | ✔️ **${this.tests.length}** tests run |
|
|
93
|
+
| **Summary** | ${statusEmoji('failed')} **${failedCount}** failed; ${statusEmoji(
|
|
94
|
+
'passed',
|
|
95
|
+
)} **${passedCount}** passed; **${statusEmoji('skipped')}** ${skippedCount} skipped |
|
|
96
|
+
| **Duration** | 🕐 **${humanizeDuration(
|
|
97
|
+
parseInt(
|
|
98
|
+
this.tests.reduce((a, t) => a + (t.run_time || 0), 0),
|
|
99
|
+
10,
|
|
100
|
+
),
|
|
101
|
+
{
|
|
102
|
+
maxDecimalPoints: 0,
|
|
103
|
+
},
|
|
104
|
+
)}** |
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
if (this.ENV.BITBUCKET_BRANCH && this.ENV.BITBUCKET_COMMIT) {
|
|
108
|
+
// eslint-disable-next-line max-len
|
|
109
|
+
summary += `| **Job** | 👷 [#${this.ENV.BITBUCKET_BUILD_NUMBER}](https://bitbucket.org/${this.ENV.BITBUCKET_REPO_FULL_NAME}/pipelines/results/${this.ENV.BITBUCKET_BUILD_NUMBER}") by commit: **${this.ENV.BITBUCKET_COMMIT}** |`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const failures = this.tests
|
|
113
|
+
.filter(t => t.status === 'failed')
|
|
114
|
+
.slice(0, 20)
|
|
115
|
+
.map(t => {
|
|
116
|
+
let text = `${statusEmoji('failed')} ${fullName(t)}\n`;
|
|
117
|
+
if (t.message) {
|
|
118
|
+
text += `> ${t.message
|
|
119
|
+
.replace(/[^\x20-\x7E]/g, '')
|
|
120
|
+
.replace(ansiRegExp(), '')
|
|
121
|
+
.trim()}\n`;
|
|
122
|
+
}
|
|
123
|
+
if (t.stack) {
|
|
124
|
+
text += `\n\`\`\`diff\n${t.stack
|
|
125
|
+
.replace(ansiRegExp(), '')
|
|
126
|
+
.replace(
|
|
127
|
+
/^[\s\S]*################\[ Failure \]################/g,
|
|
128
|
+
'################[ Failure ]################',
|
|
129
|
+
)
|
|
130
|
+
.trim()}\n\`\`\`\n`;
|
|
131
|
+
}
|
|
132
|
+
if (t.artifacts && t.artifacts.length && !this.ENV.TESTOMATIO_PRIVATE_ARTIFACTS) {
|
|
133
|
+
t.artifacts
|
|
134
|
+
.filter(f => !!f)
|
|
135
|
+
.forEach(f => {
|
|
136
|
+
if (f.endsWith('.png')) {
|
|
137
|
+
text += `\n`;
|
|
138
|
+
} else {
|
|
139
|
+
text += `[📄 ${path.basename(f)}](${f})\n`;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
text += `\n---\n`;
|
|
144
|
+
return text;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
let body = summary;
|
|
148
|
+
|
|
149
|
+
if (failures.length) {
|
|
150
|
+
body += `\n🟥 **Failures (${failures.length})**\n\n* ${failures.join('\n* ')}\n`;
|
|
151
|
+
if (failures.length > 10) {
|
|
152
|
+
body += `\n> Notice: Only the first 10 failures are shown.`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.tests.length > 0) {
|
|
157
|
+
body += `\n\n**🐢 Slowest Tests**\n\n`;
|
|
158
|
+
body += this.tests
|
|
159
|
+
.sort((a, b) => b.run_time - a.run_time)
|
|
160
|
+
.slice(0, 5)
|
|
161
|
+
.map(t => `* **${fullName(t)}** (${humanizeDuration(parseFloat(t.run_time))})`)
|
|
162
|
+
.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Construct Bitbucket API URL for comments
|
|
166
|
+
// eslint-disable-next-line max-len
|
|
167
|
+
const commentsRequestURL = `https://api.bitbucket.org/2.0/repositories/${this.ENV.BITBUCKET_WORKSPACE}/${this.ENV.BITBUCKET_REPO_SLUG}/pullrequests/${this.ENV.BITBUCKET_PR_ID}/comments`;
|
|
168
|
+
|
|
169
|
+
// Delete previous report
|
|
170
|
+
await deletePreviousReport(axios, commentsRequestURL, this.hiddenCommentData, this.token);
|
|
171
|
+
|
|
172
|
+
// Add current report
|
|
173
|
+
debug(`Adding comment via URL: ${commentsRequestURL}`);
|
|
174
|
+
debug(`Final Bitbucket API call body: ${body}`);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const addCommentResponse = await axios.post(
|
|
178
|
+
commentsRequestURL,
|
|
179
|
+
{ content: { raw: body } },
|
|
180
|
+
{
|
|
181
|
+
headers: {
|
|
182
|
+
Authorization: `Bearer ${this.token}`,
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const commentID = addCommentResponse.data.id;
|
|
189
|
+
// eslint-disable-next-line max-len
|
|
190
|
+
const commentURL = `https://bitbucket.org/${this.ENV.BITBUCKET_WORKSPACE}/${this.ENV.BITBUCKET_REPO_SLUG}/pull-requests/${this.ENV.BITBUCKET_PR_ID}#comment-${commentID}`;
|
|
191
|
+
|
|
192
|
+
console.log(APP_PREFIX, chalk.yellow('Bitbucket'), `Report created: ${chalk.magenta(commentURL)}`);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(
|
|
195
|
+
APP_PREFIX,
|
|
196
|
+
chalk.yellow('Bitbucket'),
|
|
197
|
+
`Couldn't create Bitbucket report\n${err}.
|
|
198
|
+
Request URL: ${commentsRequestURL}
|
|
199
|
+
Request data: ${body}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
toString() {
|
|
205
|
+
return 'Bitbucket Reporter';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
updateRun() {}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function deletePreviousReport(axiosInstance, commentsRequestURL, hiddenCommentData, token) {
|
|
212
|
+
if (process.env.BITBUCKET_KEEP_OUTDATED_REPORTS) return;
|
|
213
|
+
|
|
214
|
+
// Get comments
|
|
215
|
+
let comments = [];
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const response = await axiosInstance.get(commentsRequestURL, {
|
|
219
|
+
headers: {
|
|
220
|
+
Authorization: `Bearer ${token}`,
|
|
221
|
+
'Content-Type': 'application/json',
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
comments = response.data.values;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error('Error while attempting to retrieve comments on Bitbucket Pull Request:\n', e);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!comments.length) return;
|
|
230
|
+
|
|
231
|
+
for (const comment of comments) {
|
|
232
|
+
// If comment was left by the same workflow
|
|
233
|
+
if (comment.content.raw.includes(hiddenCommentData)) {
|
|
234
|
+
try {
|
|
235
|
+
// Delete previous comment
|
|
236
|
+
const deleteCommentURL = `${commentsRequestURL}/${comment.id}`;
|
|
237
|
+
await axiosInstance.delete(deleteCommentURL, {
|
|
238
|
+
headers: {
|
|
239
|
+
Authorization: `Bearer ${token}`,
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.warn(`Can't delete previously added comment with testomat.io report. Ignored.`);
|
|
245
|
+
}
|
|
246
|
+
// Pass next env var if need to clear all previous reports;
|
|
247
|
+
// only the last one is removed by default
|
|
248
|
+
if (!process.env.BITBUCKET_REMOVE_ALL_OUTDATED_REPORTS) break;
|
|
249
|
+
// TODO: in case of many reports should implement pagination
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = BitbucketPipe;
|
package/lib/pipe/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const GitHubPipe = require('./github');
|
|
|
7
7
|
const GitLabPipe = require('./gitlab');
|
|
8
8
|
const CsvPipe = require('./csv');
|
|
9
9
|
const HtmlPipe = require('./html');
|
|
10
|
+
const BitbucketPipe = require('./bitbucket');
|
|
10
11
|
|
|
11
12
|
function PipeFactory(params, opts) {
|
|
12
13
|
const extraPipes = [];
|
|
@@ -45,6 +46,7 @@ function PipeFactory(params, opts) {
|
|
|
45
46
|
new GitLabPipe(params, opts),
|
|
46
47
|
new CsvPipe(params, opts),
|
|
47
48
|
new HtmlPipe(params, opts),
|
|
49
|
+
new BitbucketPipe(params, opts),
|
|
48
50
|
...extraPipes,
|
|
49
51
|
];
|
|
50
52
|
|
package/lib/pipe/testomatio.js
CHANGED
|
@@ -37,7 +37,7 @@ class TestomatioPipe {
|
|
|
37
37
|
this.isEnabled = false;
|
|
38
38
|
this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io';
|
|
39
39
|
this.apiKey = params.apiKey || config.TESTOMATIO;
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
if (!this.apiKey) {
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
@@ -50,6 +50,7 @@ class TestomatioPipe {
|
|
|
50
50
|
this.store = store || {};
|
|
51
51
|
this.title = params.title || process.env.TESTOMATIO_TITLE;
|
|
52
52
|
this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN;
|
|
53
|
+
this.sharedRunTimeout = !!process.env.TESTOMATIO_SHARED_RUN_TIMEOUT;
|
|
53
54
|
this.groupTitle = params.groupTitle || process.env.TESTOMATIO_RUNGROUP_TITLE;
|
|
54
55
|
this.env = process.env.TESTOMATIO_ENV;
|
|
55
56
|
this.label = process.env.TESTOMATIO_LABEL;
|
|
@@ -148,7 +149,6 @@ class TestomatioPipe {
|
|
|
148
149
|
*/
|
|
149
150
|
async createRun(params = {}) {
|
|
150
151
|
this.batch.isEnabled = params.isBatchEnabled ?? this.batch.isEnabled;
|
|
151
|
-
debug('Creating run...');
|
|
152
152
|
if (!this.isEnabled) return;
|
|
153
153
|
if (this.batch.isEnabled) this.batch.intervalFunction = setInterval(this.#batchUpload, this.batch.intervalTime);
|
|
154
154
|
|
|
@@ -184,17 +184,20 @@ class TestomatioPipe {
|
|
|
184
184
|
title: this.title,
|
|
185
185
|
label: this.label,
|
|
186
186
|
shared_run: this.sharedRun,
|
|
187
|
+
shared_run_timeout: this.sharedRunTimeout,
|
|
187
188
|
}).filter(([, value]) => !!value),
|
|
188
189
|
);
|
|
189
190
|
debug('Run params', JSON.stringify(runParams, null, 2));
|
|
190
191
|
|
|
191
192
|
if (this.runId) {
|
|
193
|
+
this.store.runId = this.runId;
|
|
192
194
|
debug(`Run with id ${this.runId} already created, updating...`);
|
|
193
195
|
const resp = await this.axios.put(`/api/reporter/${this.runId}`, runParams);
|
|
194
196
|
if (resp.data.artifacts) setS3Credentials(resp.data.artifacts);
|
|
195
197
|
return;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
debug('Creating run...');
|
|
198
201
|
try {
|
|
199
202
|
const resp = await this.axios.post(`/api/reporter`, runParams, {
|
|
200
203
|
maxContentLength: Infinity,
|
|
@@ -296,7 +299,7 @@ class TestomatioPipe {
|
|
|
296
299
|
}
|
|
297
300
|
});
|
|
298
301
|
};
|
|
299
|
-
|
|
302
|
+
|
|
300
303
|
|
|
301
304
|
/**
|
|
302
305
|
* Uploads tests as a batch (multiple tests at once). Intended to be used with a setInterval
|
|
@@ -310,6 +313,8 @@ class TestomatioPipe {
|
|
|
310
313
|
const testsToSend = this.batch.tests.splice(0);
|
|
311
314
|
debug('📨 Batch upload', testsToSend.length, 'tests');
|
|
312
315
|
|
|
316
|
+
testsToSend.forEach(debug);
|
|
317
|
+
|
|
313
318
|
return this.axios
|
|
314
319
|
.post(
|
|
315
320
|
`/api/reporter/${this.runId}/testrun`,
|
|
@@ -367,7 +372,7 @@ class TestomatioPipe {
|
|
|
367
372
|
*/
|
|
368
373
|
async finishRun(params) {
|
|
369
374
|
if (!this.isEnabled) return;
|
|
370
|
-
|
|
375
|
+
|
|
371
376
|
await this.#batchUpload();
|
|
372
377
|
if (this.batch.intervalFunction) clearInterval(this.batch.intervalFunction);
|
|
373
378
|
|
|
@@ -408,6 +413,7 @@ class TestomatioPipe {
|
|
|
408
413
|
console.log(APP_PREFIX, `📊 ${notFinishedMessage}. Report URL: ${chalk.magenta(this.runUrl)}`);
|
|
409
414
|
console.log(APP_PREFIX, `🛬 Run to finish it: TESTOMATIO_RUN=${this.runId} npx start-test-run --finish`);
|
|
410
415
|
}
|
|
416
|
+
|
|
411
417
|
if (this.hasUnmatchedTests) {
|
|
412
418
|
console.log('');
|
|
413
419
|
// eslint-disable-next-line max-len
|
package/lib/uploader.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const debug = require('debug')('@testomatio/reporter:uploader');
|
|
2
|
+
const { S3 } = require('@aws-sdk/client-s3');
|
|
3
|
+
const { Upload } = require('@aws-sdk/lib-storage');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const promiseRetry = require('promise-retry');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
const { APP_PREFIX } = require('./constants');
|
|
10
|
+
|
|
11
|
+
class S3Uploader {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.isEnabled = undefined;
|
|
14
|
+
this.storeEnabled = true;
|
|
15
|
+
this.config = undefined;
|
|
16
|
+
|
|
17
|
+
// counters
|
|
18
|
+
this.skippedUpload = 0;
|
|
19
|
+
this.failedUpload = 0;
|
|
20
|
+
this.totalUploaded = 0;
|
|
21
|
+
|
|
22
|
+
this.succesfulUploads = {};
|
|
23
|
+
|
|
24
|
+
this.configKeys = [
|
|
25
|
+
'S3_ENDPOINT',
|
|
26
|
+
'S3_REGION',
|
|
27
|
+
'S3_BUCKET',
|
|
28
|
+
'S3_ACCESS_KEY_ID',
|
|
29
|
+
'S3_SECRET_ACCESS_KEY',
|
|
30
|
+
'S3_SESSION_TOKEN',
|
|
31
|
+
'S3_FORCE_PATH_STYLE',
|
|
32
|
+
'TESTOMATIO_DISABLE_ARTIFACTS',
|
|
33
|
+
'TESTOMATIO_PRIVATE_ARTIFACTS',
|
|
34
|
+
'TESTOMATIO_ARTIFACTS_SIZE'
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resetConfig() {
|
|
39
|
+
this.config = undefined;
|
|
40
|
+
this.isEnabled = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getConfig() {
|
|
44
|
+
if (this.config) return this.config;
|
|
45
|
+
this.config = this.configKeys.reduce((acc, key) => {
|
|
46
|
+
acc[key] = process.env[key];
|
|
47
|
+
return acc;
|
|
48
|
+
}, {});
|
|
49
|
+
return this.config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getMaskedConfig() {
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(this.getConfig()).map(([key, value]) => [
|
|
55
|
+
key,
|
|
56
|
+
key === 'S3_SECRET_ACCESS_KEY' || key === 'S3_ACCESS_KEY_ID' ? '***' : value,
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
checkEnabled() {
|
|
62
|
+
if (this.isEnabled !== undefined) return this.isEnabled;
|
|
63
|
+
|
|
64
|
+
const { S3_BUCKET, TESTOMATIO_DISABLE_ARTIFACTS } = this.getConfig();
|
|
65
|
+
if (!S3_BUCKET) debug(`Upload is disabled because S3_BUCKET is not set`);
|
|
66
|
+
this.isEnabled = !!(S3_BUCKET && !TESTOMATIO_DISABLE_ARTIFACTS);
|
|
67
|
+
|
|
68
|
+
if (this.isEnabled) debug('S3 uploader is enabled');
|
|
69
|
+
debug(this.getMaskedConfig());
|
|
70
|
+
|
|
71
|
+
return this.isEnabled;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
enableLogStorage() {
|
|
75
|
+
this.storeEnabled = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
disbleLogStorage() {
|
|
79
|
+
this.storeEnabled = false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async uploadToS3(Body, Key) {
|
|
83
|
+
const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS } = this.getConfig();
|
|
84
|
+
const ACL = TESTOMATIO_PRIVATE_ARTIFACTS ? 'private' : 'public-read';
|
|
85
|
+
|
|
86
|
+
if (!S3_BUCKET || !Body) {
|
|
87
|
+
console.log(APP_PREFIX, chalk.bold.red(`Failed uploading '${Key}'. Please check S3 credentials`), this.getMaskedConfig());
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
debug('Uploading to S3:', Key);
|
|
92
|
+
|
|
93
|
+
const s3 = new S3(this.getS3Config());
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const upload = new Upload({
|
|
97
|
+
client: s3,
|
|
98
|
+
params: {
|
|
99
|
+
Bucket: S3_BUCKET,
|
|
100
|
+
Key,
|
|
101
|
+
Body,
|
|
102
|
+
ACL,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const link = await this.getS3LocationLink(upload);
|
|
107
|
+
this.totalUploaded++;
|
|
108
|
+
this.succesfulUploads[Key] = link;
|
|
109
|
+
return link;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
this.failedUpload++;
|
|
112
|
+
debug('S3 uploading error:', e);
|
|
113
|
+
console.log(APP_PREFIX, "Upload failed:", e.message, this.getMaskedConfig());
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
readUploadedFiles(runId) {
|
|
118
|
+
const tempFilePath = this.#getUploadFilePath(runId);
|
|
119
|
+
|
|
120
|
+
debug('Reading file', tempFilePath);
|
|
121
|
+
|
|
122
|
+
if (!fs.existsSync(tempFilePath)) {
|
|
123
|
+
debug('File not found:', tempFilePath);
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stats = fs.statSync(tempFilePath);
|
|
128
|
+
const diff = (+new Date()) - (+stats.mtime);
|
|
129
|
+
const diffHours = diff / 1000 / 60 / 60;
|
|
130
|
+
if (diffHours > 3) {
|
|
131
|
+
console.log(APP_PREFIX, 'Artifacts file is too old, can\'t process artifacts. Please re-run the tests.');
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const data = fs.readFileSync(tempFilePath, 'utf8');
|
|
136
|
+
const lines = data.split('\n').filter(Boolean);
|
|
137
|
+
return lines.map(line => JSON.parse(line));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#getUploadFilePath(runId, forceCreate = false) {
|
|
141
|
+
const tempFilePath = path.join(os.tmpdir(), `testomatio.run.${runId}.jsonl`);
|
|
142
|
+
if (!fs.existsSync(tempFilePath) || forceCreate) {
|
|
143
|
+
debug('Creating artifacts file:', tempFilePath);
|
|
144
|
+
fs.writeFileSync(tempFilePath, '');
|
|
145
|
+
// make symlink to 'testomatio.run.latest.jsonl' file
|
|
146
|
+
const latestFilePath = path.join(os.tmpdir(), 'testomatio.run.latest.jsonl');
|
|
147
|
+
if (fs.existsSync(latestFilePath)) {
|
|
148
|
+
fs.unlinkSync(latestFilePath);
|
|
149
|
+
}
|
|
150
|
+
fs.symlinkSync(tempFilePath, latestFilePath);
|
|
151
|
+
|
|
152
|
+
}
|
|
153
|
+
return tempFilePath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
storeUploadedFile(filePath, runId, rid, uploaded = false) {
|
|
157
|
+
if (!this.storeEnabled) return;
|
|
158
|
+
|
|
159
|
+
if (!filePath || !runId || !rid ) return;
|
|
160
|
+
|
|
161
|
+
const tempFilePath = this.#getUploadFilePath(runId);
|
|
162
|
+
|
|
163
|
+
const data = { rid, file: filePath, uploaded };
|
|
164
|
+
const jsonLine = JSON.stringify(data) + '\n';
|
|
165
|
+
|
|
166
|
+
fs.appendFileSync(tempFilePath, jsonLine);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getskippedUpload() {
|
|
170
|
+
return this.skippedUpload;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async uploadFileByPath(filePath, pathInS3) {
|
|
174
|
+
const [runId, rid] = pathInS3;
|
|
175
|
+
|
|
176
|
+
if (!this.isEnabled) {
|
|
177
|
+
this.storeUploadedFile(filePath, runId, rid, false);
|
|
178
|
+
this.skippedUpload++;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const {
|
|
183
|
+
S3_BUCKET,
|
|
184
|
+
TESTOMATIO_ARTIFACTS_SIZE,
|
|
185
|
+
} = this.getConfig();
|
|
186
|
+
|
|
187
|
+
debug('Started upload', filePath, 'to', S3_BUCKET);
|
|
188
|
+
|
|
189
|
+
const isFileExist = await this.checkFileExists(filePath, 20, 500);
|
|
190
|
+
|
|
191
|
+
if (!isFileExist) {
|
|
192
|
+
this.failedUpload++;
|
|
193
|
+
console.error(chalk.yellow(`Artifacts file ${filePath} does not exist. Skipping...`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fileSize = fs.statSync(filePath).size;
|
|
198
|
+
const fileSizeInMb = fileSize / (1024 * 1024);
|
|
199
|
+
|
|
200
|
+
if (TESTOMATIO_ARTIFACTS_SIZE && fileSizeInMb > parseInt(TESTOMATIO_ARTIFACTS_SIZE)) {
|
|
201
|
+
this.skippedUpload++;
|
|
202
|
+
console.error(chalk.yellow(`Artifacts file ${filePath} exceeds the maximum allowed size. Skipping...`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
debug('File:', filePath, 'exists, size:', fileSizeInMb.toFixed(2), 'MB');
|
|
206
|
+
|
|
207
|
+
const fileStream = fs.createReadStream(filePath);
|
|
208
|
+
const Key = pathInS3.join('/');
|
|
209
|
+
|
|
210
|
+
const link = await this.uploadToS3(fileStream, Key);
|
|
211
|
+
|
|
212
|
+
this.storeUploadedFile(filePath, runId, rid, !!link);
|
|
213
|
+
|
|
214
|
+
return link;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async uploadFileAsBuffer(buffer, pathInS3) {
|
|
218
|
+
if (!this.isEnabled) return;
|
|
219
|
+
|
|
220
|
+
let Key = pathInS3.join('/');
|
|
221
|
+
const ext = this.#getFileExtBase64(buffer);
|
|
222
|
+
|
|
223
|
+
if (ext) {
|
|
224
|
+
Key = `${Key}.${ext}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return this.uploadToS3(buffer, Key);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async checkFileExists(filePath, attempts = 5, intervalMs = 500) {
|
|
231
|
+
return promiseRetry(
|
|
232
|
+
async (retry, number) => {
|
|
233
|
+
try {
|
|
234
|
+
fs.accessSync(filePath);
|
|
235
|
+
return true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (number === attempts) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
debug(`File not found, retrying (attempt ${number}/${attempts})`);
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
242
|
+
retry(err);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
retries: attempts,
|
|
247
|
+
minTimeout: intervalMs,
|
|
248
|
+
maxTimeout: intervalMs,
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async getS3LocationLink(out) {
|
|
254
|
+
const response = await out.done();
|
|
255
|
+
|
|
256
|
+
let s3Location = response?.Location;
|
|
257
|
+
|
|
258
|
+
if (!s3Location) {
|
|
259
|
+
s3Location = out?.singleUploadResult?.Location;
|
|
260
|
+
debug('Uploaded singleUploadResult.Location', s3Location);
|
|
261
|
+
|
|
262
|
+
if (!s3Location) {
|
|
263
|
+
throw new Error("Problems getting the S3 artifact's link. Please check S3 permissions!");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return s3Location;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#getFileExtBase64(str) {
|
|
271
|
+
const type = str.charAt(0);
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
{
|
|
275
|
+
'/': 'jpg',
|
|
276
|
+
i: 'png',
|
|
277
|
+
R: 'gif',
|
|
278
|
+
U: 'webp',
|
|
279
|
+
}[type] || ''
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
getS3Config() {
|
|
284
|
+
const { S3_REGION, S3_SESSION_TOKEN, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE, S3_ENDPOINT } =
|
|
285
|
+
this.getConfig();
|
|
286
|
+
|
|
287
|
+
const cfg = {
|
|
288
|
+
region: S3_REGION,
|
|
289
|
+
credentials: {
|
|
290
|
+
accessKeyId: S3_ACCESS_KEY_ID,
|
|
291
|
+
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
292
|
+
s3ForcePathStyle: S3_FORCE_PATH_STYLE,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (S3_SESSION_TOKEN) {
|
|
297
|
+
cfg.credentials.sessionToken = S3_SESSION_TOKEN;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (S3_ENDPOINT) {
|
|
301
|
+
cfg.endpoint = S3_ENDPOINT;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return cfg;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = S3Uploader;
|
package/lib/utils/pipe_utils.js
CHANGED
package/lib/xmlReader.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
fetchIdFromCode,
|
|
14
14
|
humanize,
|
|
15
15
|
} = require('./utils/utils');
|
|
16
|
-
const
|
|
16
|
+
const S3Uploader = require('./uploader');
|
|
17
17
|
const pipesFactory = require('./pipe');
|
|
18
18
|
const adapterFactory = require('./junit-adapter');
|
|
19
19
|
const config = require('./config');
|
|
@@ -56,7 +56,7 @@ class XmlReader {
|
|
|
56
56
|
this.tests = [];
|
|
57
57
|
this.stats = {};
|
|
58
58
|
this.stats.language = opts.lang?.toLowerCase();
|
|
59
|
-
this.
|
|
59
|
+
this.uploader = new S3Uploader();
|
|
60
60
|
|
|
61
61
|
this.version = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json')).toString()).version;
|
|
62
62
|
console.log(APP_PREFIX, `Testomatio Reporter v${this.version}`);
|
|
@@ -382,7 +382,7 @@ class XmlReader {
|
|
|
382
382
|
if (!files.length) continue;
|
|
383
383
|
|
|
384
384
|
const runId = this.runId || this.store.runId || Date.now().toString();
|
|
385
|
-
test.artifacts = await Promise.all(files.map(f =>
|
|
385
|
+
test.artifacts = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId])));
|
|
386
386
|
console.log(APP_PREFIX, `🗄️ Uploaded ${chalk.bold(`${files.length} artifacts`)} for test ${test.title}`);
|
|
387
387
|
}
|
|
388
388
|
}
|
|
@@ -398,7 +398,9 @@ class XmlReader {
|
|
|
398
398
|
|
|
399
399
|
debug('Run', runParams);
|
|
400
400
|
|
|
401
|
-
|
|
401
|
+
await Promise.all(this.pipes.map(p => p.createRun(runParams)));
|
|
402
|
+
|
|
403
|
+
this.uploader.checkEnabled();
|
|
402
404
|
}
|
|
403
405
|
|
|
404
406
|
async uploadData() {
|