testomatio-reporter-cli 2.8.4 → 2.8.5-beta.2-yarn
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 +3 -3
- package/bin/cli.js +6 -26
- package/package.json +39 -4
- package/src/adapter/codecept.js +626 -0
- package/src/adapter/cucumber/current.js +230 -0
- package/src/adapter/cucumber/legacy.js +158 -0
- package/src/adapter/cucumber.js +4 -0
- package/src/adapter/cypress-plugin/index.js +110 -0
- package/src/adapter/jasmine.js +60 -0
- package/src/adapter/jest.js +108 -0
- package/src/adapter/mocha.cjs +2 -0
- package/src/adapter/mocha.js +211 -0
- package/src/adapter/nightwatch.js +88 -0
- package/src/adapter/playwright.js +343 -0
- package/src/adapter/utils/playwright.js +121 -0
- package/src/adapter/utils/step-formatter.js +232 -0
- package/src/adapter/vitest.js +455 -0
- package/src/adapter/webdriver.js +201 -0
- package/src/bin/cli.js +507 -0
- package/src/bin/reportXml.js +79 -0
- package/src/bin/startTest.js +54 -0
- package/src/bin/uploadArtifacts.js +91 -0
- package/src/client.js +524 -0
- package/src/config.js +30 -0
- package/src/constants.js +72 -0
- package/src/data-storage.js +204 -0
- package/src/helpers.js +1 -0
- package/src/junit-adapter/adapter.js +23 -0
- package/src/junit-adapter/csharp.js +70 -0
- package/src/junit-adapter/index.js +28 -0
- package/src/junit-adapter/java.js +58 -0
- package/src/junit-adapter/javascript.js +31 -0
- package/src/junit-adapter/nunit-parser.js +474 -0
- package/src/junit-adapter/python.js +42 -0
- package/src/junit-adapter/ruby.js +10 -0
- package/src/output.js +57 -0
- package/src/pipe/bitbucket.js +285 -0
- package/src/pipe/coverage.js +500 -0
- package/src/pipe/csv.js +161 -0
- package/src/pipe/debug.js +143 -0
- package/src/pipe/github.js +256 -0
- package/src/pipe/gitlab.js +258 -0
- package/src/pipe/html.js +1153 -0
- package/src/pipe/index.js +73 -0
- package/src/pipe/markdown.js +753 -0
- package/src/pipe/testomatio.js +707 -0
- package/src/replay.js +274 -0
- package/src/reporter-functions.js +155 -0
- package/src/reporter.js +42 -0
- package/src/services/artifacts.js +59 -0
- package/src/services/index.js +15 -0
- package/src/services/key-values.js +59 -0
- package/src/services/links.js +69 -0
- package/src/services/logger.js +320 -0
- package/src/template/emptyData.svg +23 -0
- package/src/template/testomatio-old.hbs +1421 -0
- package/src/template/testomatio.hbs +3726 -0
- package/src/uploader.js +382 -0
- package/src/utils/constants.js +12 -0
- package/src/utils/debug.js +20 -0
- package/src/utils/log-formatter.js +118 -0
- package/src/utils/log.js +88 -0
- package/src/utils/pipe_utils.js +193 -0
- package/src/utils/utils.js +732 -0
- package/src/xmlReader.js +834 -0
- package/types/types.d.ts +425 -0
- package/types/vitest.types.d.ts +93 -0
package/src/uploader.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import createDebugMessages from 'debug';
|
|
2
|
+
import { S3 } from '@aws-sdk/client-s3';
|
|
3
|
+
import { Upload } from '@aws-sdk/lib-storage';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import promiseRetry from 'promise-retry';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { APP_PREFIX } from './constants.js';
|
|
10
|
+
import { filesize as prettyBytes } from 'filesize';
|
|
11
|
+
import { log } from './utils/log.js';
|
|
12
|
+
|
|
13
|
+
const debug = createDebugMessages('@testomatio/reporter:file-uploader');
|
|
14
|
+
|
|
15
|
+
export class S3Uploader {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.isEnabled = undefined;
|
|
18
|
+
this.storeEnabled = true;
|
|
19
|
+
this.config = undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @type {{path: string, size: number}[]}
|
|
23
|
+
*/
|
|
24
|
+
this.skippedUploads = [];
|
|
25
|
+
this.failedUploads = [];
|
|
26
|
+
/**
|
|
27
|
+
* @type {{path: string, size: number, link: string}[]}
|
|
28
|
+
*/
|
|
29
|
+
this.successfulUploads = [];
|
|
30
|
+
|
|
31
|
+
this.configKeys = [
|
|
32
|
+
'S3_ENDPOINT',
|
|
33
|
+
'S3_REGION',
|
|
34
|
+
'S3_BUCKET',
|
|
35
|
+
'S3_ACCESS_KEY_ID',
|
|
36
|
+
'S3_SECRET_ACCESS_KEY',
|
|
37
|
+
'S3_SESSION_TOKEN',
|
|
38
|
+
'S3_FORCE_PATH_STYLE',
|
|
39
|
+
'TESTOMATIO_DISABLE_ARTIFACTS',
|
|
40
|
+
'TESTOMATIO_PRIVATE_ARTIFACTS',
|
|
41
|
+
'TESTOMATIO_ARTIFACT_MAX_SIZE_MB',
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resetConfig() {
|
|
46
|
+
this.config = undefined;
|
|
47
|
+
this.isEnabled = undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
*
|
|
52
|
+
* @returns {Record<string, string>}
|
|
53
|
+
*/
|
|
54
|
+
getConfig() {
|
|
55
|
+
if (this.config) return this.config;
|
|
56
|
+
this.config = this.configKeys.reduce((acc, key) => {
|
|
57
|
+
acc[key] = process.env[key];
|
|
58
|
+
return acc;
|
|
59
|
+
}, {});
|
|
60
|
+
return this.config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getMaskedConfig() {
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(this.getConfig()).map(([key, value]) => [
|
|
66
|
+
key,
|
|
67
|
+
key === 'S3_SECRET_ACCESS_KEY' || key === 'S3_ACCESS_KEY_ID' ? '***' : value,
|
|
68
|
+
]),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
checkEnabled() {
|
|
73
|
+
if (this.isEnabled !== undefined) return this.isEnabled;
|
|
74
|
+
|
|
75
|
+
const { S3_BUCKET, TESTOMATIO_DISABLE_ARTIFACTS } = this.getConfig();
|
|
76
|
+
if (!S3_BUCKET) debug(`Artifacts uploading is disabled because S3_BUCKET is not set`);
|
|
77
|
+
this.isEnabled = !!(S3_BUCKET && !TESTOMATIO_DISABLE_ARTIFACTS);
|
|
78
|
+
|
|
79
|
+
if (this.isEnabled) debug('S3 uploader is enabled');
|
|
80
|
+
debug(this.getMaskedConfig());
|
|
81
|
+
|
|
82
|
+
return this.isEnabled;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
enableLogStorage() {
|
|
86
|
+
this.storeEnabled = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
disableLogStorage() {
|
|
90
|
+
this.storeEnabled = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
*
|
|
95
|
+
* @param {*} Body
|
|
96
|
+
* @param {*} Key
|
|
97
|
+
* @param {{path: string, size?: number}} file
|
|
98
|
+
* @returns
|
|
99
|
+
*/
|
|
100
|
+
async #uploadToS3(Body, Key, file) {
|
|
101
|
+
const { S3_BUCKET, TESTOMATIO_PRIVATE_ARTIFACTS } = this.getConfig();
|
|
102
|
+
const ACL = TESTOMATIO_PRIVATE_ARTIFACTS ? 'private' : 'public-read';
|
|
103
|
+
|
|
104
|
+
if (!S3_BUCKET || !Body) {
|
|
105
|
+
console.log(
|
|
106
|
+
APP_PREFIX,
|
|
107
|
+
pc.bold(pc.red(`Failed uploading '${Key}'. Please check S3 credentials`)),
|
|
108
|
+
this.getMaskedConfig(),
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
debug('Uploading to S3:', Key);
|
|
114
|
+
|
|
115
|
+
const s3Config = this.#getS3Config();
|
|
116
|
+
const s3 = new S3(s3Config);
|
|
117
|
+
const params = {
|
|
118
|
+
Bucket: S3_BUCKET,
|
|
119
|
+
Key,
|
|
120
|
+
Body,
|
|
121
|
+
};
|
|
122
|
+
// disable ACL for I AM roles
|
|
123
|
+
if (!s3Config.credentials.sessionToken) {
|
|
124
|
+
params.ACL = ACL;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const upload = new Upload({ client: s3, params });
|
|
129
|
+
|
|
130
|
+
const link = await this.getS3LocationLink(upload);
|
|
131
|
+
this.successfulUploads.push({ path: file.path, size: file.size, link });
|
|
132
|
+
debug(`📤 Uploaded artifact. File: ${file.path}, size: ${prettyBytes(file.size || 0)}, link: ${link}`);
|
|
133
|
+
return link;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
this.failedUploads.push({ path: file.path, size: file.size });
|
|
136
|
+
debug('S3 uploading error:', e);
|
|
137
|
+
log.info('Upload failed:', e.message, '\nConfig:\n', this.getMaskedConfig());
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns an array of uploaded files
|
|
143
|
+
*
|
|
144
|
+
* @returns {{rid: string, file: string, uploaded: boolean}[]}
|
|
145
|
+
*/
|
|
146
|
+
readUploadedFiles(runId) {
|
|
147
|
+
const tempFilePath = this.#getFilePathWithUploadsList(runId);
|
|
148
|
+
|
|
149
|
+
debug('Reading file', tempFilePath);
|
|
150
|
+
|
|
151
|
+
if (!fs.existsSync(tempFilePath)) {
|
|
152
|
+
debug('File not found:', tempFilePath);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const stats = fs.statSync(tempFilePath);
|
|
157
|
+
debug('Artifacts file stats:', +stats.mtime);
|
|
158
|
+
debug('Current time:', +new Date());
|
|
159
|
+
const diff = +new Date() - +stats.mtime;
|
|
160
|
+
debug('Diff:', diff);
|
|
161
|
+
const diffHours = diff / 1000 / 60 / 60;
|
|
162
|
+
debug('Diff hours:', diffHours);
|
|
163
|
+
if (diffHours > 3) {
|
|
164
|
+
log.info("Artifacts file is too old, can't process artifacts. Please re-run the tests.");
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const data = fs.readFileSync(tempFilePath, 'utf8');
|
|
169
|
+
debug('Artifacts file contents:', data);
|
|
170
|
+
const lines = data.split('\n').filter(Boolean);
|
|
171
|
+
return lines.map(line => JSON.parse(line));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#getFilePathWithUploadsList(runId) {
|
|
175
|
+
const tempFilePath = path.join(os.tmpdir(), `testomatio.run.${runId}.json`);
|
|
176
|
+
if (!fs.existsSync(tempFilePath)) {
|
|
177
|
+
debug('Creating artifacts file:', tempFilePath);
|
|
178
|
+
fs.writeFileSync(tempFilePath, '');
|
|
179
|
+
}
|
|
180
|
+
return tempFilePath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
storeUploadedFile(filePath, runId, rid, uploaded = false) {
|
|
184
|
+
if (!this.storeEnabled) return;
|
|
185
|
+
|
|
186
|
+
if (!filePath || !runId || !rid) return;
|
|
187
|
+
|
|
188
|
+
const tempFilePath = this.#getFilePathWithUploadsList(runId);
|
|
189
|
+
|
|
190
|
+
if (typeof filePath === 'object') {
|
|
191
|
+
filePath = filePath.path;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof filePath === 'string' && !path.isAbsolute(filePath)) {
|
|
195
|
+
filePath = path.join(process.cwd(), filePath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Normalize path separators for cross-platform compatibility
|
|
199
|
+
if (typeof filePath === 'string') {
|
|
200
|
+
filePath = filePath.replace(/\\/g, '/');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const data = { rid, file: filePath, uploaded };
|
|
204
|
+
const jsonLine = `${JSON.stringify(data)}\n`;
|
|
205
|
+
fs.appendFileSync(tempFilePath, jsonLine);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {*} filePath
|
|
210
|
+
* @param {*} pathInS3 contains runId, rid and filename
|
|
211
|
+
* @returns
|
|
212
|
+
*/
|
|
213
|
+
async uploadFileByPath(filePath, pathInS3) {
|
|
214
|
+
/* WDIO: some artifacts uploading started before createRun function completion
|
|
215
|
+
probably, the reason is that run is NOT created in adapter (but via cli) */
|
|
216
|
+
this.isEnabled = this.isEnabled ?? this.checkEnabled();
|
|
217
|
+
|
|
218
|
+
const [runId, rid] = pathInS3;
|
|
219
|
+
|
|
220
|
+
if (!filePath) return;
|
|
221
|
+
|
|
222
|
+
let fileSize = null;
|
|
223
|
+
let fileSizeInMb = null;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// file may not exist
|
|
227
|
+
fileSize = fs.statSync(filePath).size || 0;
|
|
228
|
+
fileSizeInMb = Number((fileSize / (1024 * 1024)).toFixed(2));
|
|
229
|
+
} catch (e) {
|
|
230
|
+
debug(`File ${filePath} does not exist`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!this.isEnabled) {
|
|
234
|
+
this.storeUploadedFile(filePath, runId, rid, false);
|
|
235
|
+
this.skippedUploads.push({ path: filePath, size: fileSize });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { S3_BUCKET, TESTOMATIO_ARTIFACT_MAX_SIZE_MB } = this.getConfig();
|
|
240
|
+
|
|
241
|
+
debug('Started upload', filePath, 'to', S3_BUCKET);
|
|
242
|
+
|
|
243
|
+
const isFileExist = await this.checkArtifactExistsInFileSystem(filePath, 20, 500);
|
|
244
|
+
|
|
245
|
+
if (!isFileExist) {
|
|
246
|
+
console.error(pc.yellow(`Artifacts file ${filePath} does not exist. Skipping...`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// skipping artifact only if: 1. storing to file is enabled, 2. max size is set and 3. file size exceeds the limit
|
|
251
|
+
if (
|
|
252
|
+
this.storeEnabled &&
|
|
253
|
+
TESTOMATIO_ARTIFACT_MAX_SIZE_MB &&
|
|
254
|
+
fileSizeInMb > parseFloat(TESTOMATIO_ARTIFACT_MAX_SIZE_MB)
|
|
255
|
+
) {
|
|
256
|
+
const skippedArtifact = { path: filePath, size: fileSize };
|
|
257
|
+
this.storeUploadedFile(filePath, runId, rid, false);
|
|
258
|
+
this.skippedUploads.push(skippedArtifact);
|
|
259
|
+
debug(pc.yellow(`Artifacts file ${JSON.stringify(skippedArtifact)} exceeds the maximum allowed size. Skipping.`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
debug('File:', filePath, 'exists, size:', prettyBytes(fileSize));
|
|
263
|
+
|
|
264
|
+
const fileStream = fs.createReadStream(filePath);
|
|
265
|
+
const Key = pathInS3.filter(p => !!p).join('/');
|
|
266
|
+
|
|
267
|
+
const link = await this.#uploadToS3(fileStream, Key, { path: filePath, size: fileSize });
|
|
268
|
+
|
|
269
|
+
this.storeUploadedFile(filePath, runId, rid, !!link);
|
|
270
|
+
|
|
271
|
+
return link;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {Buffer} buffer
|
|
276
|
+
* @param {string[]} pathInS3
|
|
277
|
+
* @returns
|
|
278
|
+
*/
|
|
279
|
+
async uploadFileAsBuffer(buffer, pathInS3) {
|
|
280
|
+
/* WDIO: some artifacts uploading started before createRun function completion
|
|
281
|
+
probably, the reason is that run is NOT created in adapter (but via cli) */
|
|
282
|
+
|
|
283
|
+
this.isEnabled = this.isEnabled ?? this.checkEnabled();
|
|
284
|
+
if (!this.isEnabled) return;
|
|
285
|
+
|
|
286
|
+
let Key = pathInS3.filter(p => !!p).join('/');
|
|
287
|
+
const ext = this.#getFileExtBase64(buffer.toString('base64'));
|
|
288
|
+
|
|
289
|
+
if (ext) {
|
|
290
|
+
Key = `${Key}.${ext}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return this.#uploadToS3(buffer, Key, { path: Key });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async checkArtifactExistsInFileSystem(filePath, attempts = 5, intervalMs = 500) {
|
|
297
|
+
return promiseRetry(
|
|
298
|
+
async (retry, number) => {
|
|
299
|
+
try {
|
|
300
|
+
fs.accessSync(filePath);
|
|
301
|
+
return true;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (number === attempts) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
debug(`File not found, retrying (attempt ${number}/${attempts})`);
|
|
307
|
+
await new Promise(resolve => {
|
|
308
|
+
setTimeout(resolve, intervalMs);
|
|
309
|
+
});
|
|
310
|
+
retry(err);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
retries: attempts,
|
|
315
|
+
minTimeout: intervalMs,
|
|
316
|
+
maxTimeout: intervalMs,
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async getS3LocationLink(out) {
|
|
322
|
+
const response = await out.done();
|
|
323
|
+
|
|
324
|
+
let s3Location = response?.Location?.trim();
|
|
325
|
+
|
|
326
|
+
if (!s3Location) {
|
|
327
|
+
s3Location = out?.singleUploadResult?.Location;
|
|
328
|
+
debug('Uploaded singleUploadResult.Location', s3Location);
|
|
329
|
+
|
|
330
|
+
if (!s3Location) {
|
|
331
|
+
throw new Error("Problems getting the S3 artifact's link. Please check S3 permissions!");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Normalize the URL
|
|
336
|
+
if (!s3Location.startsWith('http')) {
|
|
337
|
+
s3Location = `https://${s3Location}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return s3Location;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#getFileExtBase64(str) {
|
|
344
|
+
const type = str.charAt(0);
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
{
|
|
348
|
+
'/': 'jpg',
|
|
349
|
+
i: 'png',
|
|
350
|
+
R: 'gif',
|
|
351
|
+
U: 'webp',
|
|
352
|
+
}[type] || ''
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#getS3Config() {
|
|
357
|
+
const { S3_REGION, S3_SESSION_TOKEN, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE, S3_ENDPOINT } =
|
|
358
|
+
this.getConfig();
|
|
359
|
+
|
|
360
|
+
const cfg = {
|
|
361
|
+
region: S3_REGION,
|
|
362
|
+
credentials: {
|
|
363
|
+
accessKeyId: S3_ACCESS_KEY_ID,
|
|
364
|
+
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (S3_FORCE_PATH_STYLE) {
|
|
369
|
+
cfg.forcePathStyle = !['false', '0'].includes(String(S3_FORCE_PATH_STYLE || '').toLowerCase());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (S3_SESSION_TOKEN) {
|
|
373
|
+
cfg.credentials.sessionToken = S3_SESSION_TOKEN;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (S3_ENDPOINT) {
|
|
377
|
+
cfg.endpoint = S3_ENDPOINT;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return cfg;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const extensionMap = {
|
|
2
|
+
'application/json': 'json',
|
|
3
|
+
'text/plain': 'txt',
|
|
4
|
+
'image/jpeg': 'jpg',
|
|
5
|
+
'image/png': 'png',
|
|
6
|
+
'text/html': 'html',
|
|
7
|
+
'text/css': 'css',
|
|
8
|
+
'text/javascript': 'js',
|
|
9
|
+
'application/pdf': 'pdf',
|
|
10
|
+
'application/xml': 'xml',
|
|
11
|
+
'text/xml': 'xml',
|
|
12
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { DEBUG_FILE } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the debug file path(s).
|
|
7
|
+
*
|
|
8
|
+
* Always creates a timestamped file in tmp dir and a symlink in project root.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} [suffix] - Optional suffix appended to the base name (e.g. 'replay').
|
|
11
|
+
* @returns {{root: string, tmp: string}} root path (symlink), tmp path (actual file)
|
|
12
|
+
*/
|
|
13
|
+
export function getDebugFilePath(suffix = '') {
|
|
14
|
+
const dateTime = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
|
|
15
|
+
const baseName = suffix ? `${DEBUG_FILE}-${suffix}` : DEBUG_FILE;
|
|
16
|
+
return {
|
|
17
|
+
root: path.join(process.cwd(), `${baseName}.json`),
|
|
18
|
+
tmp: path.join(os.tmpdir(), `${baseName}.${dateTime}.json`),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import createCallsiteRecord from 'callsite-record';
|
|
2
|
+
import { minimatch } from 'minimatch';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { stripVTControlCharacters } from 'util';
|
|
5
|
+
import { sep } from 'path';
|
|
6
|
+
import { formatStep, truncate } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const stripColors = stripVTControlCharacters || (str => str?.replace(/\x1b\[[0-9;]*m/g, '') || '');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns the formatted stack including the stack trace, steps, and logs.
|
|
12
|
+
* @param {Object} params - Parameters for formatting logs
|
|
13
|
+
* @param {string} params.error - Error message
|
|
14
|
+
* @param {Array|any} [params.steps] - Test steps (array or other types)
|
|
15
|
+
* @param {string} params.logs - Test logs
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function formatLogs({ error, steps, logs }) {
|
|
19
|
+
error = error?.trim();
|
|
20
|
+
logs = logs
|
|
21
|
+
?.trim()
|
|
22
|
+
.split('\n')
|
|
23
|
+
.map(l => truncate(l))
|
|
24
|
+
.join('\n');
|
|
25
|
+
|
|
26
|
+
if (Array.isArray(steps)) {
|
|
27
|
+
steps = steps
|
|
28
|
+
.map(step => formatStep(step))
|
|
29
|
+
.flat()
|
|
30
|
+
.join('\n');
|
|
31
|
+
} else {
|
|
32
|
+
steps = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let testLogs = '';
|
|
36
|
+
if (steps) testLogs += `${pc.bold(pc.blue('################[ Steps ]################'))}\n${steps}\n\n`;
|
|
37
|
+
if (logs) testLogs += `${pc.bold(pc.gray('################[ Logs ]################'))}\n${logs}\n\n`;
|
|
38
|
+
if (error) testLogs += `${pc.bold(pc.red('################[ Failure ]################'))}\n${error}`;
|
|
39
|
+
return testLogs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Formats an error with stack trace and diff information
|
|
44
|
+
* @param {Error & {inspect?: () => string, operator?: string, diff?: string, actual?: any, expected?: any}} error
|
|
45
|
+
* The error object to format
|
|
46
|
+
* @param {string} [message] - Optional error message override
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function formatError(error, message) {
|
|
50
|
+
if (!message) message = error.message;
|
|
51
|
+
// @ts-ignore - inspect is a custom property added by some testing frameworks
|
|
52
|
+
if (error.inspect) message = error.inspect() || '';
|
|
53
|
+
|
|
54
|
+
let stack = '';
|
|
55
|
+
if (error.name) stack += `${pc.red(error.name)}`;
|
|
56
|
+
// @ts-ignore - operator is a custom property added by assertion libraries
|
|
57
|
+
if (error.operator) stack += ` (${pc.red(error.operator)})`;
|
|
58
|
+
// add new line if something was added to stack
|
|
59
|
+
if (stack) stack += ': ';
|
|
60
|
+
|
|
61
|
+
stack += `${message}\n`;
|
|
62
|
+
|
|
63
|
+
// @ts-ignore - diff is a custom property added by vitest
|
|
64
|
+
if (error.diff) {
|
|
65
|
+
// diff for vitest
|
|
66
|
+
stack += error.diff;
|
|
67
|
+
stack += '\n\n';
|
|
68
|
+
} else if (error.actual && error.expected && error.actual !== error.expected) {
|
|
69
|
+
// diffs for mocha, cypress, codeceptjs style
|
|
70
|
+
stack += `\n\n${pc.bold(pc.green('+ expected'))} ${pc.bold(pc.red('- actual'))}`;
|
|
71
|
+
stack += `\n${pc.green(`+ ${error.expected.toString().split('\n').join('\n+ ')}`)}`;
|
|
72
|
+
stack += `\n${pc.red(`- ${error.actual.toString().split('\n').join('\n- ')}`)}`;
|
|
73
|
+
stack += '\n\n';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const customFilter = process.env.TESTOMATIO_STACK_IGNORE;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
let hasFrame = false;
|
|
80
|
+
const record = createCallsiteRecord({
|
|
81
|
+
forError: error,
|
|
82
|
+
isCallsiteFrame: frame => {
|
|
83
|
+
if (customFilter && minimatch(frame.fileName, customFilter)) return false;
|
|
84
|
+
if (hasFrame) return false;
|
|
85
|
+
if (isNotInternalFrame(frame)) hasFrame = true;
|
|
86
|
+
return hasFrame;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
if (record && !record.filename.startsWith('http')) {
|
|
91
|
+
stack += record.renderSync({ stackFilter: isNotInternalFrame });
|
|
92
|
+
}
|
|
93
|
+
return stack;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.log(e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks if a stack frame is not an internal frame (node_modules or internal)
|
|
101
|
+
* @param {Object} frame - Stack frame object
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isNotInternalFrame(frame) {
|
|
105
|
+
const fileName = frame.getFileName();
|
|
106
|
+
if (!fileName) return false;
|
|
107
|
+
|
|
108
|
+
const isFileUrl = fileName.startsWith('file://');
|
|
109
|
+
const hasPathSeparator = fileName.includes(sep) || fileName.includes('/') || isFileUrl;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
hasPathSeparator &&
|
|
113
|
+
!fileName.includes('node_modules') &&
|
|
114
|
+
!fileName.includes('internal')
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { stripColors };
|
package/src/utils/log.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { APP_PREFIX } from '../constants.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log levels for the Testomat.io reporter.
|
|
5
|
+
* A message is logged if its level is <= the current log level.
|
|
6
|
+
* @example
|
|
7
|
+
* TESTOMATIO_LOG_LEVEL=ERROR npx codeceptjs run // Only errors
|
|
8
|
+
* TESTOMATIO_LOG_LEVEL=WARN npx codeceptjs run // Warnings and errors
|
|
9
|
+
* TESTOMATIO_LOG_LEVEL=INFO npx codeceptjs run // Info, warnings, errors (default)
|
|
10
|
+
*/
|
|
11
|
+
export const LOG_LEVELS = {
|
|
12
|
+
ERROR: 0,
|
|
13
|
+
WARN: 1,
|
|
14
|
+
INFO: 2,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the current log level from TESTOMATIO_LOG_LEVEL environment variable.
|
|
19
|
+
* Defaults to INFO (info, warn, and error messages).
|
|
20
|
+
* @returns {number} Numeric log level (0-2)
|
|
21
|
+
*/
|
|
22
|
+
export function getLogLevel() {
|
|
23
|
+
const envLevel = process.env.TESTOMATIO_LOG_LEVEL?.toUpperCase();
|
|
24
|
+
return LOG_LEVELS[envLevel] ?? LOG_LEVELS.INFO;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a message should be logged based on its level.
|
|
29
|
+
* A message is logged if its level is <= the current log level,
|
|
30
|
+
* or if TESTOMATIO_DEBUG is set (for debugging with the debug package).
|
|
31
|
+
* @param {number} messageLevel - Message level (LOG_LEVELS value)
|
|
32
|
+
* @returns {boolean} True if the message should be logged
|
|
33
|
+
*/
|
|
34
|
+
export function shouldLog(messageLevel) {
|
|
35
|
+
return messageLevel <= getLogLevel() || !!process.env.TESTOMATIO_DEBUG;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Log an info message with [TESTOMATIO] prefix.
|
|
40
|
+
* Only logs when TESTOMATIO_LOG_LEVEL is INFO.
|
|
41
|
+
* @param {...any} args - Arguments to log
|
|
42
|
+
*/
|
|
43
|
+
export function info(...args) {
|
|
44
|
+
if (shouldLog(LOG_LEVELS.INFO)) {
|
|
45
|
+
const fn = process.env.TESTOMATIO_LOG_STDERR === '1' ? console.error : console.log;
|
|
46
|
+
fn(APP_PREFIX, ...args);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Log a warning message with [TESTOMATIO] prefix.
|
|
52
|
+
* Only logs when TESTOMATIO_LOG_LEVEL is WARN or INFO.
|
|
53
|
+
* @param {...any} args - Arguments to log
|
|
54
|
+
*/
|
|
55
|
+
export function warn(...args) {
|
|
56
|
+
if (shouldLog(LOG_LEVELS.WARN)) {
|
|
57
|
+
console.warn(APP_PREFIX, ...args);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Log an error message with [TESTOMATIO] prefix.
|
|
63
|
+
* Logs for all TESTOMATIO_LOG_LEVEL values.
|
|
64
|
+
* @param {...any} args - Arguments to log
|
|
65
|
+
*/
|
|
66
|
+
export function error(...args) {
|
|
67
|
+
if (shouldLog(LOG_LEVELS.ERROR)) {
|
|
68
|
+
console.error(APP_PREFIX, ...args);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Logging utility for Testomat.io reporter.
|
|
74
|
+
* All messages are prefixed with [TESTOMATIO] and respect TESTOMATIO_LOG_LEVEL.
|
|
75
|
+
* @example
|
|
76
|
+
* import { log } from './utils/log.js';
|
|
77
|
+
* log.info('Test started');
|
|
78
|
+
* log.warn('This is a warning');
|
|
79
|
+
* log.error('Something went wrong');
|
|
80
|
+
*/
|
|
81
|
+
export const log = {
|
|
82
|
+
info,
|
|
83
|
+
warn,
|
|
84
|
+
error,
|
|
85
|
+
getLogLevel,
|
|
86
|
+
shouldLog,
|
|
87
|
+
LOG_LEVELS,
|
|
88
|
+
};
|