@testomatio/reporter 2.6.0 → 2.6.2
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 +10 -10
- package/lib/adapter/playwright.d.ts +2 -12
- package/lib/adapter/playwright.js +15 -72
- package/lib/adapter/utils/playwright.d.ts +25 -0
- package/lib/adapter/utils/playwright.js +123 -0
- package/lib/adapter/vitest.js +2 -1
- package/lib/bin/cli.js +1 -1
- package/lib/data-storage.d.ts +1 -1
- package/lib/data-storage.js +1 -0
- package/lib/pipe/debug.js +1 -1
- package/lib/pipe/html.d.ts +2 -3
- package/lib/pipe/html.js +745 -37
- package/lib/pipe/testomatio.js +13 -2
- package/lib/reporter-functions.d.ts +36 -11
- package/lib/reporter-functions.js +67 -25
- package/lib/reporter.d.ts +42 -86
- package/lib/services/artifacts.d.ts +1 -1
- package/lib/services/key-values.d.ts +1 -1
- package/lib/services/links.d.ts +1 -1
- package/lib/services/logger.d.ts +1 -1
- package/lib/template/testomatio-old.hbs +1421 -0
- package/lib/template/testomatio.hbs +3200 -1157
- package/lib/utils/log-formatter.d.ts +2 -1
- package/lib/utils/log-formatter.js +8 -4
- package/package.json +5 -2
- package/src/adapter/playwright.js +15 -79
- package/src/adapter/utils/playwright.js +121 -0
- package/src/adapter/vitest.js +2 -1
- package/src/bin/cli.js +1 -1
- package/src/data-storage.js +1 -0
- package/src/pipe/debug.js +1 -1
- package/src/pipe/html.js +844 -38
- package/src/pipe/testomatio.js +13 -2
- package/src/reporter-functions.js +68 -28
- package/src/template/testomatio-old.hbs +1421 -0
- package/src/template/testomatio.hbs +3200 -1157
- package/src/utils/log-formatter.js +9 -4
- package/types/types.d.ts +29 -5
- package/lib/services/labels.d.ts +0 -0
- package/lib/services/labels.js +0 -0
- package/src/services/labels.js +0 -1
package/lib/pipe/html.js
CHANGED
|
@@ -24,7 +24,6 @@ class HtmlPipe {
|
|
|
24
24
|
debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
|
|
25
25
|
this.isEnabled = false;
|
|
26
26
|
this.htmlOutputPath = '';
|
|
27
|
-
this.fullHtmlOutputPath = '';
|
|
28
27
|
this.filenameMsg = '';
|
|
29
28
|
this.tests = [];
|
|
30
29
|
if (this.isHtml) {
|
|
@@ -59,15 +58,25 @@ class HtmlPipe {
|
|
|
59
58
|
}
|
|
60
59
|
/**
|
|
61
60
|
* Add test data to the result array for saving. As a result of this function, we get a result object to save.
|
|
62
|
-
* @param {import('../../types/types.js').
|
|
61
|
+
* @param {import('../../types/types.js').HtmlTestData} test - object which includes each test entry.
|
|
63
62
|
*/
|
|
64
63
|
addTest(test) {
|
|
65
64
|
if (!this.isEnabled)
|
|
66
65
|
return;
|
|
67
|
-
if (
|
|
66
|
+
if (test?.stack && typeof test.stack === 'string') {
|
|
67
|
+
test.stack = test.stack.replace((0, utils_js_1.ansiRegExp)(), '');
|
|
68
|
+
}
|
|
69
|
+
const hasPayload = Boolean(test?.status) ||
|
|
70
|
+
(Array.isArray(test?.files) && test.files.length) ||
|
|
71
|
+
(Array.isArray(test?.artifacts) && test.artifacts.length) ||
|
|
72
|
+
(Array.isArray(test?.steps) && test.steps.length) ||
|
|
73
|
+
Boolean(test?.message) ||
|
|
74
|
+
Boolean(test?.logs) ||
|
|
75
|
+
(test?.meta &&
|
|
76
|
+
((Array.isArray(test.meta.attachments) && test.meta.attachments.length) || test.meta.traces !== undefined));
|
|
77
|
+
if (!hasPayload)
|
|
68
78
|
return;
|
|
69
79
|
const index = this.tests.findIndex(t => (0, utils_js_1.isSameTest)(t, test));
|
|
70
|
-
// update if they were already added
|
|
71
80
|
if (index >= 0) {
|
|
72
81
|
this.tests[index] = (0, lodash_merge_1.default)(this.tests[index], test);
|
|
73
82
|
return;
|
|
@@ -106,45 +115,89 @@ class HtmlPipe {
|
|
|
106
115
|
if (msg) {
|
|
107
116
|
console.log(picocolors_1.default.blue(msg));
|
|
108
117
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
test.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
const aggregatedTests = aggregateTestRetries(tests);
|
|
119
|
+
aggregatedTests.forEach(test => {
|
|
120
|
+
const logsRaw = test.logs || test.meta?.logs || test.meta?.console || test.meta?.stdout || test.meta?.stderr || '';
|
|
121
|
+
const stackRaw = test.stack || '';
|
|
122
|
+
const messageRaw = test.message || '';
|
|
123
|
+
const { steps: stepsFromMsg, restText: messageClean } = extractStepLines(messageRaw);
|
|
124
|
+
const { steps: stepsFromLogs, restText: logsClean } = extractStepLines(logsRaw);
|
|
125
|
+
const { steps: stepsFromStack, restText: stackClean } = extractStepLines(stackRaw);
|
|
126
|
+
let stepsTree = null;
|
|
127
|
+
if (Array.isArray(test.steps) && test.steps.length) {
|
|
128
|
+
const userSteps = filterUserStepsTree(test.steps);
|
|
129
|
+
stepsTree = userSteps.length ? userSteps : null;
|
|
130
|
+
}
|
|
131
|
+
if (!stepsTree && stepsFromLogs.length > 0) {
|
|
132
|
+
stepsTree = buildStepsTreeFromLogs(stepsFromLogs);
|
|
133
|
+
}
|
|
134
|
+
const allStepLines = [...stepsFromMsg, ...stepsFromLogs, ...stepsFromStack];
|
|
135
|
+
const fallbackStepsText = allStepLines.length ? allStepLines.map((s, i) => `${i + 1}. ${s}`).join('\n') : '';
|
|
136
|
+
test.message = messageClean;
|
|
137
|
+
test.stack = stackClean;
|
|
138
|
+
parseRetryInfo(test);
|
|
139
|
+
if (test.meta?.traces !== undefined) {
|
|
140
|
+
test.traces =
|
|
141
|
+
typeof test.meta.traces === 'string' ? test.meta.traces : JSON.stringify(test.meta.traces, null, 2);
|
|
142
|
+
delete test.meta.traces;
|
|
143
|
+
}
|
|
144
|
+
loadTracesFromFiles(test);
|
|
145
|
+
const status = String(test.status || '').toLowerCase();
|
|
146
|
+
if ((status === 'skipped' || status === 'pending') && test.meta?.todo) {
|
|
147
|
+
test.status = 'todo';
|
|
119
148
|
}
|
|
149
|
+
prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText);
|
|
150
|
+
delete test._stepsFromMessage;
|
|
151
|
+
test.steps = toHtmlSafe(test.steps || '');
|
|
152
|
+
const rawFields = stripFailureBlock(toPlainText(logsClean));
|
|
153
|
+
const rawStack = extractLogsFromStack(test.stack);
|
|
154
|
+
const logsFromFields = normalizeLogs(rawFields);
|
|
155
|
+
const logsFromStack = normalizeLogs(stripStepMarkedLinesRaw(rawStack));
|
|
156
|
+
const logsMerged = (logsFromFields || logsFromStack).trim();
|
|
157
|
+
const messageProcessed = decodeHtmlEntities(toPlainText(test.message)).trim();
|
|
158
|
+
const messageNoInlineLogs = stripInlineLogsBlock(messageProcessed);
|
|
159
|
+
const messageFinal = cleanNoiseBlock(messageNoInlineLogs).trim();
|
|
160
|
+
const logsFinal = cleanNoiseBlock(logsMerged).trim();
|
|
161
|
+
const hasStack = hasMeaningfulText(test.stack);
|
|
162
|
+
const finalText = buildMessageForReport({
|
|
163
|
+
status,
|
|
164
|
+
messageRaw: messageFinal,
|
|
165
|
+
logsText: logsFinal,
|
|
166
|
+
hasStack,
|
|
167
|
+
});
|
|
168
|
+
test.message = toHtmlSafe(finalText);
|
|
120
169
|
if (!test.suite_title?.trim()) {
|
|
121
170
|
test.suite_title = 'Unknown suite';
|
|
122
171
|
}
|
|
123
172
|
if (!test.title?.trim()) {
|
|
124
173
|
test.title = 'Unknown test title';
|
|
125
174
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
test.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
175
|
+
test.artifacts = normalizeArtifacts(test);
|
|
176
|
+
const allPossibleArtifacts = [
|
|
177
|
+
...(test.artifacts || []),
|
|
178
|
+
...(test.manuallyAttachedArtifacts || []),
|
|
179
|
+
...(test.files || []),
|
|
180
|
+
...(test.meta?.attachments || []),
|
|
181
|
+
];
|
|
182
|
+
test.artifactsUploaded = allPossibleArtifacts.some(artifact => {
|
|
183
|
+
const link = artifact?.link || artifact?.path;
|
|
184
|
+
return link && (link.startsWith('http://') || link.startsWith('https://')) && !link.startsWith('file://');
|
|
185
|
+
});
|
|
186
|
+
normalizeRetries(test);
|
|
187
|
+
if (test.traces) {
|
|
188
|
+
test.traces = typeof test.traces === 'string' ? test.traces : JSON.stringify(test.traces, null, 2);
|
|
134
189
|
}
|
|
135
|
-
// TODO: future-proof: currently there is no need to display Artifacts and Metadata in HTML
|
|
136
|
-
test.artifacts = test.artifacts || [];
|
|
137
|
-
test.meta = test.meta || {};
|
|
138
|
-
// TODO: u can added an additional test values to this checks in the future
|
|
139
190
|
});
|
|
140
191
|
const data = {
|
|
192
|
+
title: this.title || 'Test Results',
|
|
141
193
|
runId: this.store.runId || '',
|
|
142
194
|
status: runParams.status || 'No status info',
|
|
143
195
|
parallel: runParams.isParallel || 'No parallel info',
|
|
144
196
|
runUrl: this.store.runUrl || '',
|
|
145
|
-
executionTime: testExecutionSumTime(
|
|
197
|
+
executionTime: testExecutionSumTime(aggregatedTests),
|
|
146
198
|
executionDate: getCurrentDateTimeFormatted(),
|
|
147
|
-
tests,
|
|
199
|
+
tests: aggregatedTests,
|
|
200
|
+
envVars: collectEnvironmentVariables(),
|
|
148
201
|
};
|
|
149
202
|
// generate output HTML based on the template
|
|
150
203
|
const html = this.#generateHTMLReport(data, templatePath);
|
|
@@ -188,6 +241,23 @@ class HtmlPipe {
|
|
|
188
241
|
}
|
|
189
242
|
#loadReportHelpers() {
|
|
190
243
|
handlebars_1.default.registerHelper('getTestsByStatus', (tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length);
|
|
244
|
+
handlebars_1.default.registerHelper('formatDuration', milliseconds => {
|
|
245
|
+
if (!milliseconds || milliseconds === 0)
|
|
246
|
+
return '0ms';
|
|
247
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
248
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
249
|
+
const seconds = totalSeconds % 60;
|
|
250
|
+
const ms = milliseconds % 1000;
|
|
251
|
+
if (minutes > 0) {
|
|
252
|
+
return `${minutes}m ${seconds}s ${ms}ms`;
|
|
253
|
+
}
|
|
254
|
+
else if (seconds > 0) {
|
|
255
|
+
return `${seconds}s ${ms}ms`;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
return `${ms}ms`;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
191
261
|
handlebars_1.default.registerHelper('selectComponent', () => new handlebars_1.default.SafeString(`<select style="width: 70px;height: 38px;" class="form-select" aria-label="Tests counter on page">
|
|
192
262
|
<option value="0">10</option>
|
|
193
263
|
<option value="1">25</option>
|
|
@@ -216,12 +286,13 @@ class HtmlPipe {
|
|
|
216
286
|
1: 25,
|
|
217
287
|
2: 50,
|
|
218
288
|
};
|
|
219
|
-
const statuses = ['all', 'passed', 'failed', 'skipped'];
|
|
289
|
+
const statuses = ['all', 'passed', 'failed', 'skipped', 'todo'];
|
|
220
290
|
const pageItemGroups = {
|
|
221
291
|
all: {},
|
|
222
292
|
passed: {},
|
|
223
293
|
failed: {},
|
|
224
294
|
skipped: {},
|
|
295
|
+
todo: {},
|
|
225
296
|
totalTests,
|
|
226
297
|
};
|
|
227
298
|
function paginateItems(items, pageSize) {
|
|
@@ -237,7 +308,7 @@ class HtmlPipe {
|
|
|
237
308
|
const pageSize = paginationOptions[option];
|
|
238
309
|
let filteredItems = totalTests;
|
|
239
310
|
if (status !== 'all') {
|
|
240
|
-
filteredItems = totalTests.filter(item => item.status === status);
|
|
311
|
+
filteredItems = totalTests.filter(item => String(item.status).toLowerCase() === status);
|
|
241
312
|
}
|
|
242
313
|
pageItemGroups[status][option] = paginateItems(filteredItems, pageSize);
|
|
243
314
|
}
|
|
@@ -246,6 +317,9 @@ class HtmlPipe {
|
|
|
246
317
|
pageItemGroups.totalTests = totalTests;
|
|
247
318
|
return JSON.stringify(pageItemGroups);
|
|
248
319
|
});
|
|
320
|
+
handlebars_1.default.registerHelper('ObjectLength', obj => {
|
|
321
|
+
return Object.keys(obj).length;
|
|
322
|
+
});
|
|
249
323
|
}
|
|
250
324
|
toString() {
|
|
251
325
|
return 'HTML Reporter';
|
|
@@ -266,15 +340,202 @@ function testExecutionSumTime(tests) {
|
|
|
266
340
|
}, 0);
|
|
267
341
|
return formatDuration(totalMilliseconds);
|
|
268
342
|
}
|
|
343
|
+
function parseRetryInfo(test) {
|
|
344
|
+
test.retries = test.retries || { retryCount: 0, attempts: [] };
|
|
345
|
+
if (test.meta && test.meta.retryCount !== undefined) {
|
|
346
|
+
const n = Number(test.meta.retryCount);
|
|
347
|
+
if (!Number.isNaN(n))
|
|
348
|
+
test.retries.retryCount = n;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function escapeHtml(str = '') {
|
|
352
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Converts value to plain text. Handles arrays by joining with newlines.
|
|
356
|
+
* @param {string|string[]} value - String or array of strings
|
|
357
|
+
* @returns {string} Plain text representation
|
|
358
|
+
*/
|
|
359
|
+
function toPlainText(value) {
|
|
360
|
+
if (!value)
|
|
361
|
+
return '';
|
|
362
|
+
if (Array.isArray(value))
|
|
363
|
+
return value.map(v => String(v)).join('\n');
|
|
364
|
+
return String(value);
|
|
365
|
+
}
|
|
366
|
+
function stripFailureBlock(text = '') {
|
|
367
|
+
const t = String(text);
|
|
368
|
+
const idx = t.indexOf('################[ Failure ]');
|
|
369
|
+
if (idx !== -1)
|
|
370
|
+
return t.slice(0, idx).trim();
|
|
371
|
+
const idx2 = t.indexOf('[ Failure ]');
|
|
372
|
+
if (idx2 !== -1)
|
|
373
|
+
return t.slice(0, idx2).trim();
|
|
374
|
+
return t.trim();
|
|
375
|
+
}
|
|
269
376
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* @
|
|
377
|
+
* Normalizes log text by removing ANSI codes and trimming whitespace.
|
|
378
|
+
* Removes empty lines and trims trailing spaces from each line.
|
|
379
|
+
* @param {string} text - Raw log text
|
|
380
|
+
* @returns {string} Cleaned log text
|
|
273
381
|
*/
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
382
|
+
function normalizeLogs(text = '') {
|
|
383
|
+
return String(text)
|
|
384
|
+
.replace((0, utils_js_1.ansiRegExp)(), '')
|
|
385
|
+
.split('\n')
|
|
386
|
+
.map(l => l.trimEnd())
|
|
387
|
+
.filter(l => l.trim())
|
|
388
|
+
.join('\n')
|
|
389
|
+
.trim();
|
|
390
|
+
}
|
|
391
|
+
function toHtmlSafe(value) {
|
|
392
|
+
const noAnsi = toPlainText(value).replace((0, utils_js_1.ansiRegExp)(), '');
|
|
393
|
+
return escapeHtml(noAnsi).replace(/\n/g, '<br>');
|
|
394
|
+
}
|
|
395
|
+
function hasMeaningfulText(value) {
|
|
396
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
397
|
+
}
|
|
398
|
+
function buildMessageForReport({ status, messageRaw, logsText, hasStack }) {
|
|
399
|
+
const hasMsg = hasMeaningfulText(messageRaw);
|
|
400
|
+
const hasLogs = hasMeaningfulText(logsText);
|
|
401
|
+
if (status === 'failed') {
|
|
402
|
+
if (hasStack) {
|
|
403
|
+
return '';
|
|
404
|
+
}
|
|
405
|
+
const parts = [];
|
|
406
|
+
if (hasMsg)
|
|
407
|
+
parts.push(messageRaw);
|
|
408
|
+
if (hasLogs)
|
|
409
|
+
parts.push(`--- Logs ---\n${logsText}`);
|
|
410
|
+
return parts.length ? parts.join('\n\n') : 'No message';
|
|
411
|
+
}
|
|
412
|
+
if (hasMsg)
|
|
413
|
+
return messageRaw;
|
|
414
|
+
if (hasLogs)
|
|
415
|
+
return logsText;
|
|
416
|
+
return 'No logs';
|
|
417
|
+
}
|
|
418
|
+
function stripInlineLogsBlock(text = '') {
|
|
419
|
+
const t = String(text || '');
|
|
420
|
+
const markers = ['--- Logs ---', '[ Logs ]', 'Logs:'];
|
|
421
|
+
let cut = -1;
|
|
422
|
+
for (const m of markers) {
|
|
423
|
+
const i = t.indexOf(m);
|
|
424
|
+
if (i !== -1)
|
|
425
|
+
cut = cut === -1 ? i : Math.min(cut, i);
|
|
426
|
+
}
|
|
427
|
+
return (cut === -1 ? t : t.slice(0, cut)).trim();
|
|
428
|
+
}
|
|
429
|
+
function extractLogsFromStack(stack) {
|
|
430
|
+
if (!stack)
|
|
431
|
+
return '';
|
|
432
|
+
const clean = String(stack).replace((0, utils_js_1.ansiRegExp)(), '');
|
|
433
|
+
const lines = clean.split('\n');
|
|
434
|
+
const startIdx = lines.findIndex(l => l.includes('[ Logs ]'));
|
|
435
|
+
if (startIdx === -1)
|
|
436
|
+
return '';
|
|
437
|
+
let endIdx = lines.findIndex((l, i) => i > startIdx && (l.includes('[ Failure ]') || l.includes('################[ Failure ]')));
|
|
438
|
+
if (endIdx === -1)
|
|
439
|
+
endIdx = lines.length;
|
|
440
|
+
const slice = lines.slice(startIdx + 1, endIdx);
|
|
441
|
+
return slice
|
|
442
|
+
.map(l => l.trimEnd())
|
|
443
|
+
.filter(l => l.trim())
|
|
444
|
+
.filter(l => !l.includes('[ Logs ]') && !l.includes('Logs:'))
|
|
445
|
+
.join('\n')
|
|
446
|
+
.trim();
|
|
447
|
+
}
|
|
448
|
+
function decodeHtmlEntities(value = '') {
|
|
449
|
+
return String(value || '')
|
|
450
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
451
|
+
.replace(/>/gi, '>')
|
|
452
|
+
.replace(/</gi, '<')
|
|
453
|
+
.replace(/&/gi, '&')
|
|
454
|
+
.replace(/"/gi, '"')
|
|
455
|
+
.replace(/'/gi, "'")
|
|
456
|
+
.replace(/\r\n/g, '\n');
|
|
457
|
+
}
|
|
458
|
+
function stripStepMarkedLinesRaw(text = '') {
|
|
459
|
+
const t = decodeHtmlEntities(text);
|
|
460
|
+
return t
|
|
461
|
+
.split('\n')
|
|
462
|
+
.filter(line => {
|
|
463
|
+
const ln = line.replace((0, utils_js_1.ansiRegExp)(), '');
|
|
464
|
+
return !/^\s*(?:>|>|[⏩►])\s/i.test(ln);
|
|
465
|
+
})
|
|
466
|
+
.join('\n')
|
|
467
|
+
.trim();
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Converts 'pending' status to 'todo' for Testomat.io display
|
|
471
|
+
* @param {string} value - Status value
|
|
472
|
+
* @returns {string} Status with 'pending' converted to 'todo'
|
|
473
|
+
*/
|
|
474
|
+
function normalizeStatus(value) {
|
|
475
|
+
const s = String(value || '').toLowerCase();
|
|
476
|
+
if (s === 'pending')
|
|
477
|
+
return 'todo';
|
|
478
|
+
return s || 'unknown';
|
|
479
|
+
}
|
|
480
|
+
function pickAttemptStatus(a) {
|
|
481
|
+
if (!a)
|
|
482
|
+
return 'unknown';
|
|
483
|
+
if (a.passed === true)
|
|
484
|
+
return 'passed';
|
|
485
|
+
if (a.passed === false)
|
|
486
|
+
return 'failed';
|
|
487
|
+
return normalizeStatus(a.status ?? a.state ?? a.outcome ?? a.result ?? a.verdict ?? a.ok ?? 'unknown');
|
|
488
|
+
}
|
|
489
|
+
function pickAttemptDuration(a) {
|
|
490
|
+
const n = a?.duration ?? a?.durationMs ?? a?.run_time ?? a?.time ?? a?.elapsed ?? null;
|
|
491
|
+
return typeof n === 'number' && !Number.isNaN(n) ? n : null;
|
|
492
|
+
}
|
|
493
|
+
function buildAttemptsFromCount(retryCount, finalStatus) {
|
|
494
|
+
const total = Math.max(1, Number(retryCount || 0) + 1);
|
|
495
|
+
const arr = [];
|
|
496
|
+
for (let i = 0; i < total; i++) {
|
|
497
|
+
const status = i === total - 1 ? normalizeStatus(finalStatus) : 'unknown';
|
|
498
|
+
arr.push({ status, duration: null });
|
|
499
|
+
}
|
|
500
|
+
return arr;
|
|
501
|
+
}
|
|
502
|
+
function normalizeRetries(test) {
|
|
503
|
+
test.meta = test.meta || {};
|
|
504
|
+
parseRetryInfo(test);
|
|
505
|
+
const finalStatus = normalizeStatus(test.status);
|
|
506
|
+
const attemptsRaw = (Array.isArray(test.attempts) && test.attempts) ||
|
|
507
|
+
(Array.isArray(test.retries?.attempts) && test.retries.attempts) ||
|
|
508
|
+
(Array.isArray(test.meta?.attempts) && test.meta.attempts) ||
|
|
509
|
+
(Array.isArray(test.meta?.retries) && test.meta.retries) ||
|
|
510
|
+
[];
|
|
511
|
+
const retryCountFromMeta = typeof test.meta.retryCount === 'number' ? test.meta.retryCount : undefined;
|
|
512
|
+
const retryCountFromRetries = typeof test.retries?.retryCount === 'number' ? test.retries.retryCount : undefined;
|
|
513
|
+
let attemptsNormalized = [];
|
|
514
|
+
if (attemptsRaw.length > 0) {
|
|
515
|
+
attemptsNormalized = attemptsRaw.map(a => ({
|
|
516
|
+
status: pickAttemptStatus(a),
|
|
517
|
+
duration: pickAttemptDuration(a),
|
|
518
|
+
}));
|
|
519
|
+
const lastIdx = attemptsNormalized.length - 1;
|
|
520
|
+
if (lastIdx >= 0)
|
|
521
|
+
attemptsNormalized[lastIdx].status = finalStatus;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
const retryCount = retryCountFromMeta ?? retryCountFromRetries ?? 0;
|
|
525
|
+
attemptsNormalized = buildAttemptsFromCount(retryCount, finalStatus);
|
|
526
|
+
}
|
|
527
|
+
const retryCountFinal = Math.max(0, attemptsNormalized.length - 1);
|
|
528
|
+
const hadFailures = attemptsNormalized.slice(0, -1).some(a => a.status === 'failed');
|
|
529
|
+
const passedAfterRetries = finalStatus === 'passed' && hadFailures;
|
|
530
|
+
test.retries = {
|
|
531
|
+
retryCount: retryCountFinal,
|
|
532
|
+
attempts: attemptsNormalized,
|
|
533
|
+
hadFailures,
|
|
534
|
+
passedAfterRetries,
|
|
535
|
+
finalStatus,
|
|
536
|
+
};
|
|
537
|
+
const metaFlaky = test.meta?.flaky === true || test.meta?.isFlaky === true;
|
|
538
|
+
test.flaky = Boolean(metaFlaky || passedAfterRetries);
|
|
278
539
|
}
|
|
279
540
|
/**
|
|
280
541
|
* Formats duration in milliseconds into a human-readable string representation.
|
|
@@ -304,4 +565,451 @@ function getCurrentDateTimeFormatted() {
|
|
|
304
565
|
const seconds = currentDate.getSeconds().toString().padStart(2, '0');
|
|
305
566
|
return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
|
|
306
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* Aggregates duplicate test records (from retries) into a single entry
|
|
570
|
+
* @param {Array} tests - Array of all tests
|
|
571
|
+
* @returns {Array} - Aggregated array of tests
|
|
572
|
+
*/
|
|
573
|
+
function aggregateTestRetries(tests) {
|
|
574
|
+
if (!Array.isArray(tests) || tests.length === 0)
|
|
575
|
+
return tests;
|
|
576
|
+
const grouped = new Map();
|
|
577
|
+
for (const t of tests) {
|
|
578
|
+
const rid = t?.rid || t?.meta?.rid || t?.meta?.RID || t?.meta?.runRid || t?.meta?.testRid;
|
|
579
|
+
const key = rid ? `rid:${rid}` : `ft:${t?.file || ''}|${t?.title || ''}`;
|
|
580
|
+
if (!grouped.has(key))
|
|
581
|
+
grouped.set(key, []);
|
|
582
|
+
grouped.get(key).push(t);
|
|
583
|
+
}
|
|
584
|
+
const aggregated = [];
|
|
585
|
+
grouped.forEach(group => {
|
|
586
|
+
if (group.length === 1) {
|
|
587
|
+
aggregated.push(group[0]);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const attemptsOnly = group.filter(x => x && x.status);
|
|
591
|
+
const base = attemptsOnly.length ? attemptsOnly[attemptsOnly.length - 1] : group[group.length - 1];
|
|
592
|
+
const allFiles = [];
|
|
593
|
+
const allArtifacts = [];
|
|
594
|
+
const allMetaAttachments = [];
|
|
595
|
+
const allManual = [];
|
|
596
|
+
for (const x of group) {
|
|
597
|
+
if (Array.isArray(x?.files))
|
|
598
|
+
allFiles.push(...x.files);
|
|
599
|
+
if (Array.isArray(x?.artifacts))
|
|
600
|
+
allArtifacts.push(...x.artifacts);
|
|
601
|
+
if (Array.isArray(x?.meta?.attachments))
|
|
602
|
+
allMetaAttachments.push(...x.meta.attachments);
|
|
603
|
+
if (Array.isArray(x?.manuallyAttachedArtifacts))
|
|
604
|
+
allManual.push(...x.manuallyAttachedArtifacts);
|
|
605
|
+
if (Array.isArray(x?.meta?.manuallyAttachedArtifacts))
|
|
606
|
+
allManual.push(...x.meta.manuallyAttachedArtifacts);
|
|
607
|
+
}
|
|
608
|
+
const attempts = attemptsOnly.map(a => ({
|
|
609
|
+
status: normalizeStatus(a.status),
|
|
610
|
+
duration: a.run_time || a.time || 0,
|
|
611
|
+
}));
|
|
612
|
+
const retryCount = Math.max(0, attempts.length - 1);
|
|
613
|
+
const hadFailures = attempts.slice(0, -1).some(a => a.status === 'failed');
|
|
614
|
+
const finalStatus = normalizeStatus(base.status);
|
|
615
|
+
const passedAfterRetries = finalStatus === 'passed' && hadFailures;
|
|
616
|
+
const merged = (0, lodash_merge_1.default)({}, base);
|
|
617
|
+
if (allFiles.length)
|
|
618
|
+
merged.files = allFiles;
|
|
619
|
+
if (allArtifacts.length)
|
|
620
|
+
merged.artifacts = allArtifacts;
|
|
621
|
+
merged.meta = merged.meta || {};
|
|
622
|
+
if (allMetaAttachments.length) {
|
|
623
|
+
merged.meta.attachments = [...(merged.meta.attachments || []), ...allMetaAttachments];
|
|
624
|
+
}
|
|
625
|
+
if (allManual.length) {
|
|
626
|
+
merged.manuallyAttachedArtifacts = [...(merged.manuallyAttachedArtifacts || []), ...allManual];
|
|
627
|
+
}
|
|
628
|
+
merged.retries = {
|
|
629
|
+
retryCount,
|
|
630
|
+
attempts,
|
|
631
|
+
hadFailures,
|
|
632
|
+
passedAfterRetries,
|
|
633
|
+
finalStatus,
|
|
634
|
+
};
|
|
635
|
+
merged.flaky = Boolean(passedAfterRetries || merged.meta?.flaky || merged.meta?.isFlaky);
|
|
636
|
+
aggregated.push(merged);
|
|
637
|
+
});
|
|
638
|
+
return aggregated;
|
|
639
|
+
}
|
|
640
|
+
function extractStepLines(raw = '') {
|
|
641
|
+
const text = decodeHtmlEntities(toPlainText(raw || ''));
|
|
642
|
+
if (!text.trim())
|
|
643
|
+
return { steps: [], restText: '' };
|
|
644
|
+
const lines = text.split('\n');
|
|
645
|
+
const steps = [];
|
|
646
|
+
const rest = [];
|
|
647
|
+
for (const line of lines) {
|
|
648
|
+
const cleanedLine = line.replace((0, utils_js_1.ansiRegExp)(), '');
|
|
649
|
+
const stepMatch = cleanedLine.match(/^\s*(?:>|>|[⏩►])\s*(.+?)\s*$/i);
|
|
650
|
+
if (stepMatch) {
|
|
651
|
+
steps.push(stepMatch[1].trim());
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const stepWithLabel = cleanedLine.match(/^\s*(?:>|>|[⏩►]\s*)?\s*Step:\s*(.+)\s*$/i);
|
|
655
|
+
if (stepWithLabel) {
|
|
656
|
+
steps.push(stepWithLabel[1].trim());
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (/^\s*Step\s*\d+\s*$/i.test(cleanedLine))
|
|
660
|
+
continue;
|
|
661
|
+
rest.push(line);
|
|
662
|
+
}
|
|
663
|
+
return { steps, restText: rest.join('\n').trim() };
|
|
664
|
+
}
|
|
665
|
+
function filterUserStepsTree(steps) {
|
|
666
|
+
if (!Array.isArray(steps))
|
|
667
|
+
return [];
|
|
668
|
+
const isUserStep = s => String(s?.category || '').toLowerCase() === 'user';
|
|
669
|
+
const walk = arr => {
|
|
670
|
+
const out = [];
|
|
671
|
+
for (const s of arr) {
|
|
672
|
+
if (!s)
|
|
673
|
+
continue;
|
|
674
|
+
const children = walk(s.steps || []);
|
|
675
|
+
if (isUserStep(s)) {
|
|
676
|
+
const copy = { ...s };
|
|
677
|
+
if (children.length)
|
|
678
|
+
copy.steps = children;
|
|
679
|
+
else
|
|
680
|
+
delete copy.steps;
|
|
681
|
+
out.push(copy);
|
|
682
|
+
}
|
|
683
|
+
else if (children.length) {
|
|
684
|
+
out.push(...children);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
};
|
|
689
|
+
return walk(steps);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Builds a tree structure from a flat array of step names
|
|
693
|
+
* This is used when steps are stored as logs (like in Playwright)
|
|
694
|
+
* @param {string[]} stepLines - Array of step names
|
|
695
|
+
* @returns {Array} - Tree structure of steps
|
|
696
|
+
*/
|
|
697
|
+
function buildStepsTreeFromLogs(stepLines) {
|
|
698
|
+
if (!Array.isArray(stepLines) || stepLines.length === 0)
|
|
699
|
+
return [];
|
|
700
|
+
const result = [];
|
|
701
|
+
const stack = [];
|
|
702
|
+
const indentStack = [];
|
|
703
|
+
stepLines.forEach(line => {
|
|
704
|
+
if (!line || !line.trim())
|
|
705
|
+
return;
|
|
706
|
+
const text = line.trim();
|
|
707
|
+
const indent = line.search(/\S/);
|
|
708
|
+
while (indentStack.length > 0 && indentStack[indentStack.length - 1] >= indent) {
|
|
709
|
+
stack.pop();
|
|
710
|
+
indentStack.pop();
|
|
711
|
+
}
|
|
712
|
+
const stepObj = {
|
|
713
|
+
category: 'user',
|
|
714
|
+
title: text,
|
|
715
|
+
duration: 0,
|
|
716
|
+
};
|
|
717
|
+
if (stack.length === 0) {
|
|
718
|
+
result.push(stepObj);
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
const parent = stack[stack.length - 1];
|
|
722
|
+
if (!parent.steps)
|
|
723
|
+
parent.steps = [];
|
|
724
|
+
parent.steps.push(stepObj);
|
|
725
|
+
}
|
|
726
|
+
stack.push(stepObj);
|
|
727
|
+
indentStack.push(indent);
|
|
728
|
+
});
|
|
729
|
+
return result;
|
|
730
|
+
}
|
|
731
|
+
function cleanNoiseBlock(text = '') {
|
|
732
|
+
const t = decodeHtmlEntities(String(text || '')).replace((0, utils_js_1.ansiRegExp)(), '');
|
|
733
|
+
let lines = t
|
|
734
|
+
.split('\n')
|
|
735
|
+
.map(l => l.trim())
|
|
736
|
+
.filter(Boolean);
|
|
737
|
+
lines = dropISayEcho(lines);
|
|
738
|
+
return lines.join('\n').trim();
|
|
739
|
+
}
|
|
740
|
+
function dropISayEcho(lines) {
|
|
741
|
+
const out = [];
|
|
742
|
+
for (let i = 0; i < lines.length; i++) {
|
|
743
|
+
const cur = lines[i];
|
|
744
|
+
const prev = out[out.length - 1];
|
|
745
|
+
const m = prev && prev.match(/^I say\s+"([\s\S]*)"$/);
|
|
746
|
+
if (m) {
|
|
747
|
+
const said = m[1];
|
|
748
|
+
if (cur === said)
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
out.push(cur);
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Collects all Testomatio and S3 environment variables
|
|
757
|
+
* Uses hardcoded list to avoid file system dependencies for end users
|
|
758
|
+
* @returns {Object} Object with TESTOMATIO_ and S3_ variables grouped
|
|
759
|
+
*/
|
|
760
|
+
function collectEnvironmentVariables() {
|
|
761
|
+
return getHardcodedEnvVars();
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Process environment variables configuration and collect their values
|
|
765
|
+
* @param {Object} varConfigs - Object with variable configurations { [key]: { description } }
|
|
766
|
+
* @param {Set} sensitiveVars - Set of sensitive variable names
|
|
767
|
+
* @returns {Object} Processed environment variables with metadata
|
|
768
|
+
*/
|
|
769
|
+
function processEnvironmentVariables(varConfigs, sensitiveVars) {
|
|
770
|
+
const result = {};
|
|
771
|
+
for (const [key, config] of Object.entries(varConfigs)) {
|
|
772
|
+
const value = process.env[key];
|
|
773
|
+
const isSensitive = sensitiveVars.has(key);
|
|
774
|
+
if (isSensitive) {
|
|
775
|
+
if (value !== undefined) {
|
|
776
|
+
result[key] = { value: '***', description: config.description, isSet: true, isSensitive: true };
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
result[key] = { value: '', description: config.description, isSet: false, isSensitive: true };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
if (value !== undefined) {
|
|
784
|
+
result[key] = { value, description: config.description, isSet: true };
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
result[key] = { value: '', description: config.description, isSet: false };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return result;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Hardcoded environment variables stored in code
|
|
795
|
+
* This is the main source of truth for env vars to avoid file system dependencies
|
|
796
|
+
* @returns {Object} Object with TESTOMATIO_ and S3_ variables
|
|
797
|
+
*/
|
|
798
|
+
function getHardcodedEnvVars() {
|
|
799
|
+
const allVars = {
|
|
800
|
+
testomatio: {
|
|
801
|
+
TESTOMATIO: { description: 'API Key for Testomat.io' },
|
|
802
|
+
TESTOMATIO_API_KEY: { description: 'API Key (alias for TESTOMATIO)' },
|
|
803
|
+
TESTOMATIO_CREATE: { description: 'Create new tests in Testomat.io' },
|
|
804
|
+
TESTOMATIO_DEBUG: { description: 'Enable debug mode' },
|
|
805
|
+
TESTOMATIO_DISABLE_BATCH_UPLOAD: { description: 'Disable batch upload' },
|
|
806
|
+
TESTOMATIO_ENV: { description: 'Environment label (e.g., "Windows, Chrome")' },
|
|
807
|
+
TESTOMATIO_EXCLUDE_FILES_FROM_REPORT_GLOB_PATTERN: { description: 'Glob pattern to exclude files' },
|
|
808
|
+
TESTOMATIO_EXCLUDE_SKIPPED: { description: 'Exclude skipped tests from report' },
|
|
809
|
+
TESTOMATIO_FILENAME: { description: 'HTML report filename' },
|
|
810
|
+
TESTOMATIO_HTML_FILENAME: { description: 'HTML report filename' },
|
|
811
|
+
TESTOMATIO_HTML_REPORT_FOLDER: { description: 'Folder for HTML report' },
|
|
812
|
+
TESTOMATIO_HTML_REPORT_SAVE: { description: 'Save HTML report' },
|
|
813
|
+
TESTOMATIO_INTERCEPT_CONSOLE_LOGS: { description: 'Intercept console logs' },
|
|
814
|
+
TESTOMATIO_MARK_DETACHED: { description: 'Mark tests as detached' },
|
|
815
|
+
TESTOMATIO_MAX_REQUEST_FAILURES: { description: 'Max request failures' },
|
|
816
|
+
TESTOMATIO_MAX_REQUEST_FAILURES_COUNT: { description: 'Max request failures count' },
|
|
817
|
+
TESTOMATIO_MAX_REQUEST_RETRIES_WITHIN_TIME_SECONDS: { description: 'Max retries within time period' },
|
|
818
|
+
TESTOMATIO_NO_STEPS: { description: 'Disable steps reporting' },
|
|
819
|
+
TESTOMATIO_NO_TIMESTAMP: { description: 'Remove timestamps from logs' },
|
|
820
|
+
TESTOMATIO_PROCEED: { description: 'Proceed even if tests fail' },
|
|
821
|
+
TESTOMATIO_PUBLISH: { description: 'Publish results to Testomat.io' },
|
|
822
|
+
TESTOMATIO_REQUEST_TIMEOUT: { description: 'Request timeout in milliseconds' },
|
|
823
|
+
TESTOMATIO_RUN: { description: 'Run ID to report tests to' },
|
|
824
|
+
TESTOMATIO_RUNGROUP_TITLE: { description: 'Title for run group' },
|
|
825
|
+
TESTOMATIO_SHARED_RUN: { description: 'Share run for parallel execution' },
|
|
826
|
+
TESTOMATIO_SHARED_RUN_TIMEOUT: { description: 'Timeout for shared run (in seconds)' },
|
|
827
|
+
TESTOMATIO_STACK_ARTIFACTS: { description: 'Stack artifacts in report' },
|
|
828
|
+
TESTOMATIO_STACK_FILTER: { description: 'Filter stack traces' },
|
|
829
|
+
TESTOMATIO_STACK_PASSED: { description: 'Report stack for passed tests' },
|
|
830
|
+
TESTOMATIO_STEPS_PASSED: { description: 'Report steps for passed tests' },
|
|
831
|
+
TESTOMATIO_SUITE: { description: 'Suite ID for new tests' },
|
|
832
|
+
TESTOMATIO_TOKEN: { description: 'API Token (alias for TESTOMATIO)' },
|
|
833
|
+
TESTOMATIO_TITLE: { description: 'Title for the test run' },
|
|
834
|
+
TESTOMATIO_URL: { description: 'Testomat.io URL (custom instance)' },
|
|
835
|
+
TESTOMATIO_WORKDIR: { description: 'Working directory for relative paths' },
|
|
836
|
+
},
|
|
837
|
+
s3: {
|
|
838
|
+
S3_ACCESS_KEY_ID: { description: 'S3 access key ID' },
|
|
839
|
+
S3_BUCKET: { description: 'S3 bucket name' },
|
|
840
|
+
S3_ENDPOINT: { description: 'S3 endpoint URL' },
|
|
841
|
+
S3_FORCE_PATH_STYLE: { description: 'S3 force path style' },
|
|
842
|
+
S3_KEY: { description: 'S3 access key' },
|
|
843
|
+
S3_PREFIX: { description: 'S3 key prefix' },
|
|
844
|
+
S3_REGION: { description: 'S3 region' },
|
|
845
|
+
S3_SECRET: { description: 'S3 secret key' },
|
|
846
|
+
S3_SECRET_ACCESS_KEY: { description: 'S3 secret access key' },
|
|
847
|
+
S3_SESSION_TOKEN: { description: 'S3 session token' },
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
const sensitiveVars = new Set([
|
|
851
|
+
'TESTOMATIO',
|
|
852
|
+
'TESTOMATIO_TOKEN',
|
|
853
|
+
'TESTOMATIO_API_KEY',
|
|
854
|
+
'S3_KEY',
|
|
855
|
+
'S3_SECRET',
|
|
856
|
+
'S3_ACCESS_KEY_ID',
|
|
857
|
+
'S3_SECRET_ACCESS_KEY',
|
|
858
|
+
'S3_SESSION_TOKEN',
|
|
859
|
+
]);
|
|
860
|
+
const envVars = {
|
|
861
|
+
testomatio: processEnvironmentVariables(allVars.testomatio, sensitiveVars),
|
|
862
|
+
s3: processEnvironmentVariables(allVars.s3, sensitiveVars),
|
|
863
|
+
};
|
|
864
|
+
return envVars;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Prepares test steps for HTML report display
|
|
868
|
+
* @param {object} test - Test object
|
|
869
|
+
* @param {Array} stepsTree - Steps tree from logs
|
|
870
|
+
* @param {Array} allStepLines - All step lines from message/logs/stack
|
|
871
|
+
* @param {string} fallbackStepsText - Fallback steps text
|
|
872
|
+
*/
|
|
873
|
+
function prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText) {
|
|
874
|
+
if (Array.isArray(test.steps) && test.steps.length) {
|
|
875
|
+
const userSteps = filterUserStepsTree(test.steps);
|
|
876
|
+
test.stepsArray = userSteps;
|
|
877
|
+
if (userSteps.length) {
|
|
878
|
+
test.steps = userSteps
|
|
879
|
+
.map(s => (0, utils_js_1.formatStep)(s))
|
|
880
|
+
.flat()
|
|
881
|
+
.join('\n');
|
|
882
|
+
}
|
|
883
|
+
else if (stepsTree) {
|
|
884
|
+
test.stepsArray = stepsTree;
|
|
885
|
+
test.steps = stepsTree.map(s => (0, utils_js_1.formatStep)(s)).flat().join('\n');
|
|
886
|
+
}
|
|
887
|
+
else if (fallbackStepsText) {
|
|
888
|
+
test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 }));
|
|
889
|
+
test.steps = fallbackStepsText;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
test.steps = '';
|
|
893
|
+
test.stepsArray = [];
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
else if (stepsTree) {
|
|
897
|
+
test.stepsArray = stepsTree;
|
|
898
|
+
test.steps = stepsTree.map(s => (0, utils_js_1.formatStep)(s)).flat().join('\n');
|
|
899
|
+
}
|
|
900
|
+
else if (fallbackStepsText) {
|
|
901
|
+
test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 }));
|
|
902
|
+
test.steps = fallbackStepsText;
|
|
903
|
+
}
|
|
904
|
+
else if (typeof test.steps === 'string' && test.steps.trim()) {
|
|
905
|
+
test.stepsArray = [];
|
|
906
|
+
test.steps = String(test.steps).replace((0, utils_js_1.ansiRegExp)(), '').trim();
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
test.steps = '';
|
|
910
|
+
test.stepsArray = [];
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Normalizes artifacts from different sources into a unified format
|
|
915
|
+
* @param {object} test - Test object with artifacts
|
|
916
|
+
* @returns {Array} - Normalized artifacts array with trace files filtered out
|
|
917
|
+
*/
|
|
918
|
+
function normalizeArtifacts(test) {
|
|
919
|
+
test.artifacts = test.artifacts || [];
|
|
920
|
+
test.meta = test.meta || {};
|
|
921
|
+
const allArtifacts = [
|
|
922
|
+
...(test.artifacts || []),
|
|
923
|
+
...(test.meta?.attachments || []),
|
|
924
|
+
...(test.manuallyAttachedArtifacts || []),
|
|
925
|
+
...(test.files || []),
|
|
926
|
+
...(test.meta?.manuallyAttachedArtifacts || []),
|
|
927
|
+
];
|
|
928
|
+
return allArtifacts
|
|
929
|
+
.map(artifact => {
|
|
930
|
+
if (typeof artifact === 'string') {
|
|
931
|
+
const abs = path_1.default.isAbsolute(artifact) ? artifact : path_1.default.resolve(process.cwd(), artifact);
|
|
932
|
+
const href = artifact.startsWith('file://') ? artifact : (0, file_url_1.default)(abs, { resolve: true });
|
|
933
|
+
const base = path_1.default.basename(abs);
|
|
934
|
+
return {
|
|
935
|
+
name: base,
|
|
936
|
+
title: base,
|
|
937
|
+
path: href,
|
|
938
|
+
fsPath: abs,
|
|
939
|
+
relativePath: artifact,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (artifact?.path) {
|
|
943
|
+
const raw = String(artifact.path);
|
|
944
|
+
const isFileUrl = raw.startsWith('file://');
|
|
945
|
+
const abs = isFileUrl ? null : path_1.default.isAbsolute(raw) ? raw : path_1.default.resolve(process.cwd(), raw);
|
|
946
|
+
const href = isFileUrl ? raw : (0, file_url_1.default)(abs, { resolve: true });
|
|
947
|
+
const base = abs ? path_1.default.basename(abs) : artifact.name || artifact.title || 'attachment';
|
|
948
|
+
return {
|
|
949
|
+
...artifact,
|
|
950
|
+
name: artifact.name || artifact.title || base,
|
|
951
|
+
title: artifact.title || artifact.name || base,
|
|
952
|
+
path: href,
|
|
953
|
+
fsPath: abs || artifact.fsPath || null,
|
|
954
|
+
relativePath: artifact.relativePath || raw,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
return artifact;
|
|
958
|
+
})
|
|
959
|
+
.filter(Boolean)
|
|
960
|
+
.filter(artifact => {
|
|
961
|
+
const isTrace = (artifact.title === 'trace' || artifact.name === 'trace') &&
|
|
962
|
+
(artifact.type === 'application/zip' ||
|
|
963
|
+
artifact.path?.endsWith('.zip') ||
|
|
964
|
+
artifact.relativePath?.endsWith('.zip'));
|
|
965
|
+
return !isTrace;
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Loads trace files from test.files and converts them to base64 data URLs
|
|
970
|
+
* @param {object} test - Test object with files array
|
|
971
|
+
*/
|
|
972
|
+
function loadTracesFromFiles(test) {
|
|
973
|
+
if (!test.traces && test.files && Array.isArray(test.files) && test.files.length > 0) {
|
|
974
|
+
const traceFiles = test.files.filter(f => f.path &&
|
|
975
|
+
f.path.trim().length > 0 &&
|
|
976
|
+
(f.title === 'trace' || f.name === 'trace') &&
|
|
977
|
+
(f.type === 'application/zip' || f.path.endsWith('.zip')));
|
|
978
|
+
if (traceFiles.length > 0) {
|
|
979
|
+
const traceDataList = [];
|
|
980
|
+
traceFiles.forEach(f => {
|
|
981
|
+
if (!fs_1.default.existsSync(f.path)) {
|
|
982
|
+
console.warn(`Trace file not found: ${f.path}`);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
const fileBuffer = fs_1.default.readFileSync(f.path, null);
|
|
987
|
+
if (!fileBuffer || fileBuffer.length === 0) {
|
|
988
|
+
console.warn(`Empty trace file: ${f.path}`);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const base64 = fileBuffer.toString('base64');
|
|
992
|
+
let filename = 'trace.zip';
|
|
993
|
+
try {
|
|
994
|
+
filename = path_1.default.basename(f.path);
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
console.warn(`Could not extract filename from ${f.path}, using default`);
|
|
998
|
+
}
|
|
999
|
+
const dataUrl = `data:application/zip;base64,${base64}`;
|
|
1000
|
+
traceDataList.push({
|
|
1001
|
+
dataUrl,
|
|
1002
|
+
name: filename
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
catch (e) {
|
|
1006
|
+
console.error(`Failed to convert trace to base64: ${f.path}`, e.message);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
if (traceDataList.length > 0) {
|
|
1010
|
+
test.traces = traceDataList;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
307
1015
|
module.exports = HtmlPipe;
|