@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/src/pipe/html.js
CHANGED
|
@@ -25,7 +25,6 @@ class HtmlPipe {
|
|
|
25
25
|
|
|
26
26
|
this.isEnabled = false;
|
|
27
27
|
this.htmlOutputPath = '';
|
|
28
|
-
this.fullHtmlOutputPath = '';
|
|
29
28
|
this.filenameMsg = '';
|
|
30
29
|
this.tests = [];
|
|
31
30
|
|
|
@@ -74,15 +73,28 @@ class HtmlPipe {
|
|
|
74
73
|
|
|
75
74
|
/**
|
|
76
75
|
* Add test data to the result array for saving. As a result of this function, we get a result object to save.
|
|
77
|
-
* @param {import('../../types/types.js').
|
|
76
|
+
* @param {import('../../types/types.js').HtmlTestData} test - object which includes each test entry.
|
|
78
77
|
*/
|
|
79
78
|
addTest(test) {
|
|
80
79
|
if (!this.isEnabled) return;
|
|
81
80
|
|
|
82
|
-
if (
|
|
81
|
+
if (test?.stack && typeof test.stack === 'string') {
|
|
82
|
+
test.stack = test.stack.replace(ansiRegExp(), '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasPayload =
|
|
86
|
+
Boolean(test?.status) ||
|
|
87
|
+
(Array.isArray(test?.files) && test.files.length) ||
|
|
88
|
+
(Array.isArray(test?.artifacts) && test.artifacts.length) ||
|
|
89
|
+
(Array.isArray(test?.steps) && test.steps.length) ||
|
|
90
|
+
Boolean(test?.message) ||
|
|
91
|
+
Boolean(test?.logs) ||
|
|
92
|
+
(test?.meta &&
|
|
93
|
+
((Array.isArray(test.meta.attachments) && test.meta.attachments.length) || test.meta.traces !== undefined));
|
|
94
|
+
|
|
95
|
+
if (!hasPayload) return;
|
|
83
96
|
|
|
84
97
|
const index = this.tests.findIndex(t => isSameTest(t, test));
|
|
85
|
-
// update if they were already added
|
|
86
98
|
if (index >= 0) {
|
|
87
99
|
this.tests[index] = merge(this.tests[index], test);
|
|
88
100
|
return;
|
|
@@ -129,19 +141,77 @@ class HtmlPipe {
|
|
|
129
141
|
console.log(pc.blue(msg));
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
const aggregatedTests = aggregateTestRetries(tests);
|
|
145
|
+
|
|
146
|
+
aggregatedTests.forEach(test => {
|
|
147
|
+
const logsRaw =
|
|
148
|
+
test.logs || test.meta?.logs || test.meta?.console || test.meta?.stdout || test.meta?.stderr || '';
|
|
149
|
+
const stackRaw = test.stack || '';
|
|
150
|
+
const messageRaw = test.message || '';
|
|
151
|
+
|
|
152
|
+
const { steps: stepsFromMsg, restText: messageClean } = extractStepLines(messageRaw);
|
|
153
|
+
const { steps: stepsFromLogs, restText: logsClean } = extractStepLines(logsRaw);
|
|
154
|
+
const { steps: stepsFromStack, restText: stackClean } = extractStepLines(stackRaw);
|
|
155
|
+
|
|
156
|
+
let stepsTree = null;
|
|
157
|
+
if (Array.isArray(test.steps) && test.steps.length) {
|
|
158
|
+
const userSteps = filterUserStepsTree(test.steps);
|
|
159
|
+
stepsTree = userSteps.length ? userSteps : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!stepsTree && stepsFromLogs.length > 0) {
|
|
163
|
+
stepsTree = buildStepsTreeFromLogs(stepsFromLogs);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const allStepLines = [...stepsFromMsg, ...stepsFromLogs, ...stepsFromStack];
|
|
167
|
+
const fallbackStepsText = allStepLines.length ? allStepLines.map((s, i) => `${i + 1}. ${s}`).join('\n') : '';
|
|
168
|
+
|
|
169
|
+
test.message = messageClean;
|
|
170
|
+
test.stack = stackClean;
|
|
171
|
+
|
|
172
|
+
parseRetryInfo(test);
|
|
173
|
+
|
|
174
|
+
if (test.meta?.traces !== undefined) {
|
|
175
|
+
test.traces =
|
|
176
|
+
typeof test.meta.traces === 'string' ? test.meta.traces : JSON.stringify(test.meta.traces, null, 2);
|
|
177
|
+
delete test.meta.traces;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
loadTracesFromFiles(test);
|
|
181
|
+
|
|
182
|
+
const status = String(test.status || '').toLowerCase();
|
|
183
|
+
if ((status === 'skipped' || status === 'pending') && test.meta?.todo) {
|
|
184
|
+
test.status = 'todo';
|
|
143
185
|
}
|
|
144
186
|
|
|
187
|
+
prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText);
|
|
188
|
+
|
|
189
|
+
delete test._stepsFromMessage;
|
|
190
|
+
test.steps = toHtmlSafe(test.steps || '');
|
|
191
|
+
|
|
192
|
+
const rawFields = stripFailureBlock(toPlainText(logsClean));
|
|
193
|
+
const rawStack = extractLogsFromStack(test.stack);
|
|
194
|
+
|
|
195
|
+
const logsFromFields = normalizeLogs(rawFields);
|
|
196
|
+
const logsFromStack = normalizeLogs(stripStepMarkedLinesRaw(rawStack));
|
|
197
|
+
const logsMerged = (logsFromFields || logsFromStack).trim();
|
|
198
|
+
|
|
199
|
+
const messageProcessed = decodeHtmlEntities(toPlainText(test.message)).trim();
|
|
200
|
+
const messageNoInlineLogs = stripInlineLogsBlock(messageProcessed);
|
|
201
|
+
const messageFinal = cleanNoiseBlock(messageNoInlineLogs).trim();
|
|
202
|
+
const logsFinal = cleanNoiseBlock(logsMerged).trim();
|
|
203
|
+
|
|
204
|
+
const hasStack = hasMeaningfulText(test.stack);
|
|
205
|
+
|
|
206
|
+
const finalText = buildMessageForReport({
|
|
207
|
+
status,
|
|
208
|
+
messageRaw: messageFinal,
|
|
209
|
+
logsText: logsFinal,
|
|
210
|
+
hasStack,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test.message = toHtmlSafe(finalText);
|
|
214
|
+
|
|
145
215
|
if (!test.suite_title?.trim()) {
|
|
146
216
|
test.suite_title = 'Unknown suite';
|
|
147
217
|
}
|
|
@@ -150,30 +220,36 @@ class HtmlPipe {
|
|
|
150
220
|
test.title = 'Unknown test title';
|
|
151
221
|
}
|
|
152
222
|
|
|
153
|
-
|
|
154
|
-
test.files = 'This test has no files';
|
|
155
|
-
}
|
|
223
|
+
test.artifacts = normalizeArtifacts(test);
|
|
156
224
|
|
|
157
|
-
|
|
158
|
-
test.
|
|
159
|
-
|
|
160
|
-
test.
|
|
161
|
-
|
|
225
|
+
const allPossibleArtifacts = [
|
|
226
|
+
...(test.artifacts || []),
|
|
227
|
+
...(test.manuallyAttachedArtifacts || []),
|
|
228
|
+
...(test.files || []),
|
|
229
|
+
...(test.meta?.attachments || []),
|
|
230
|
+
];
|
|
162
231
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
232
|
+
test.artifactsUploaded = allPossibleArtifacts.some(artifact => {
|
|
233
|
+
const link = artifact?.link || artifact?.path;
|
|
234
|
+
return link && (link.startsWith('http://') || link.startsWith('https://')) && !link.startsWith('file://');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
normalizeRetries(test);
|
|
238
|
+
if (test.traces) {
|
|
239
|
+
test.traces = typeof test.traces === 'string' ? test.traces : JSON.stringify(test.traces, null, 2);
|
|
240
|
+
}
|
|
167
241
|
});
|
|
168
242
|
|
|
169
243
|
const data = {
|
|
244
|
+
title: this.title || 'Test Results',
|
|
170
245
|
runId: this.store.runId || '',
|
|
171
246
|
status: runParams.status || 'No status info',
|
|
172
247
|
parallel: runParams.isParallel || 'No parallel info',
|
|
173
248
|
runUrl: this.store.runUrl || '',
|
|
174
|
-
executionTime: testExecutionSumTime(
|
|
249
|
+
executionTime: testExecutionSumTime(aggregatedTests),
|
|
175
250
|
executionDate: getCurrentDateTimeFormatted(),
|
|
176
|
-
tests,
|
|
251
|
+
tests: aggregatedTests,
|
|
252
|
+
envVars: collectEnvironmentVariables(),
|
|
177
253
|
};
|
|
178
254
|
// generate output HTML based on the template
|
|
179
255
|
const html = this.#generateHTMLReport(data, templatePath);
|
|
@@ -226,6 +302,23 @@ class HtmlPipe {
|
|
|
226
302
|
(tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length,
|
|
227
303
|
);
|
|
228
304
|
|
|
305
|
+
handlebars.registerHelper('formatDuration', milliseconds => {
|
|
306
|
+
if (!milliseconds || milliseconds === 0) return '0ms';
|
|
307
|
+
|
|
308
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
309
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
310
|
+
const seconds = totalSeconds % 60;
|
|
311
|
+
const ms = milliseconds % 1000;
|
|
312
|
+
|
|
313
|
+
if (minutes > 0) {
|
|
314
|
+
return `${minutes}m ${seconds}s ${ms}ms`;
|
|
315
|
+
} else if (seconds > 0) {
|
|
316
|
+
return `${seconds}s ${ms}ms`;
|
|
317
|
+
} else {
|
|
318
|
+
return `${ms}ms`;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
229
322
|
handlebars.registerHelper(
|
|
230
323
|
'selectComponent',
|
|
231
324
|
() =>
|
|
@@ -266,12 +359,13 @@ class HtmlPipe {
|
|
|
266
359
|
1: 25,
|
|
267
360
|
2: 50,
|
|
268
361
|
};
|
|
269
|
-
const statuses = ['all', 'passed', 'failed', 'skipped'];
|
|
362
|
+
const statuses = ['all', 'passed', 'failed', 'skipped', 'todo'];
|
|
270
363
|
const pageItemGroups = {
|
|
271
364
|
all: {},
|
|
272
365
|
passed: {},
|
|
273
366
|
failed: {},
|
|
274
367
|
skipped: {},
|
|
368
|
+
todo: {},
|
|
275
369
|
totalTests,
|
|
276
370
|
};
|
|
277
371
|
|
|
@@ -290,7 +384,7 @@ class HtmlPipe {
|
|
|
290
384
|
let filteredItems = totalTests;
|
|
291
385
|
|
|
292
386
|
if (status !== 'all') {
|
|
293
|
-
filteredItems = totalTests.filter(item => item.status === status);
|
|
387
|
+
filteredItems = totalTests.filter(item => String(item.status).toLowerCase() === status);
|
|
294
388
|
}
|
|
295
389
|
|
|
296
390
|
pageItemGroups[status][option] = paginateItems(filteredItems, pageSize);
|
|
@@ -302,6 +396,10 @@ class HtmlPipe {
|
|
|
302
396
|
|
|
303
397
|
return JSON.stringify(pageItemGroups);
|
|
304
398
|
});
|
|
399
|
+
|
|
400
|
+
handlebars.registerHelper('ObjectLength', obj => {
|
|
401
|
+
return Object.keys(obj).length;
|
|
402
|
+
});
|
|
305
403
|
}
|
|
306
404
|
|
|
307
405
|
toString() {
|
|
@@ -326,16 +424,234 @@ function testExecutionSumTime(tests) {
|
|
|
326
424
|
return formatDuration(totalMilliseconds);
|
|
327
425
|
}
|
|
328
426
|
|
|
427
|
+
function parseRetryInfo(test) {
|
|
428
|
+
test.retries = test.retries || { retryCount: 0, attempts: [] };
|
|
429
|
+
|
|
430
|
+
if (test.meta && test.meta.retryCount !== undefined) {
|
|
431
|
+
const n = Number(test.meta.retryCount);
|
|
432
|
+
if (!Number.isNaN(n)) test.retries.retryCount = n;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function escapeHtml(str = '') {
|
|
437
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
438
|
+
}
|
|
439
|
+
|
|
329
440
|
/**
|
|
330
|
-
*
|
|
331
|
-
* @param {string}
|
|
332
|
-
* @returns {string}
|
|
441
|
+
* Converts value to plain text. Handles arrays by joining with newlines.
|
|
442
|
+
* @param {string|string[]} value - String or array of strings
|
|
443
|
+
* @returns {string} Plain text representation
|
|
333
444
|
*/
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
445
|
+
function toPlainText(value) {
|
|
446
|
+
if (!value) return '';
|
|
447
|
+
if (Array.isArray(value)) return value.map(v => String(v)).join('\n');
|
|
448
|
+
return String(value);
|
|
449
|
+
}
|
|
337
450
|
|
|
338
|
-
|
|
451
|
+
function stripFailureBlock(text = '') {
|
|
452
|
+
const t = String(text);
|
|
453
|
+
const idx = t.indexOf('################[ Failure ]');
|
|
454
|
+
if (idx !== -1) return t.slice(0, idx).trim();
|
|
455
|
+
const idx2 = t.indexOf('[ Failure ]');
|
|
456
|
+
if (idx2 !== -1) return t.slice(0, idx2).trim();
|
|
457
|
+
return t.trim();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Normalizes log text by removing ANSI codes and trimming whitespace.
|
|
462
|
+
* Removes empty lines and trims trailing spaces from each line.
|
|
463
|
+
* @param {string} text - Raw log text
|
|
464
|
+
* @returns {string} Cleaned log text
|
|
465
|
+
*/
|
|
466
|
+
function normalizeLogs(text = '') {
|
|
467
|
+
return String(text)
|
|
468
|
+
.replace(ansiRegExp(), '')
|
|
469
|
+
.split('\n')
|
|
470
|
+
.map(l => l.trimEnd())
|
|
471
|
+
.filter(l => l.trim())
|
|
472
|
+
.join('\n')
|
|
473
|
+
.trim();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function toHtmlSafe(value) {
|
|
477
|
+
const noAnsi = toPlainText(value).replace(ansiRegExp(), '');
|
|
478
|
+
return escapeHtml(noAnsi).replace(/\n/g, '<br>');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function hasMeaningfulText(value) {
|
|
482
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildMessageForReport({ status, messageRaw, logsText, hasStack }) {
|
|
486
|
+
const hasMsg = hasMeaningfulText(messageRaw);
|
|
487
|
+
const hasLogs = hasMeaningfulText(logsText);
|
|
488
|
+
|
|
489
|
+
if (status === 'failed') {
|
|
490
|
+
if (hasStack) {
|
|
491
|
+
return '';
|
|
492
|
+
}
|
|
493
|
+
const parts = [];
|
|
494
|
+
if (hasMsg) parts.push(messageRaw);
|
|
495
|
+
if (hasLogs) parts.push(`--- Logs ---\n${logsText}`);
|
|
496
|
+
return parts.length ? parts.join('\n\n') : 'No message';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (hasMsg) return messageRaw;
|
|
500
|
+
if (hasLogs) return logsText;
|
|
501
|
+
|
|
502
|
+
return 'No logs';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function stripInlineLogsBlock(text = '') {
|
|
506
|
+
const t = String(text || '');
|
|
507
|
+
|
|
508
|
+
const markers = ['--- Logs ---', '[ Logs ]', 'Logs:'];
|
|
509
|
+
let cut = -1;
|
|
510
|
+
|
|
511
|
+
for (const m of markers) {
|
|
512
|
+
const i = t.indexOf(m);
|
|
513
|
+
if (i !== -1) cut = cut === -1 ? i : Math.min(cut, i);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return (cut === -1 ? t : t.slice(0, cut)).trim();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function extractLogsFromStack(stack) {
|
|
520
|
+
if (!stack) return '';
|
|
521
|
+
|
|
522
|
+
const clean = String(stack).replace(ansiRegExp(), '');
|
|
523
|
+
const lines = clean.split('\n');
|
|
524
|
+
|
|
525
|
+
const startIdx = lines.findIndex(l => l.includes('[ Logs ]'));
|
|
526
|
+
if (startIdx === -1) return '';
|
|
527
|
+
|
|
528
|
+
let endIdx = lines.findIndex(
|
|
529
|
+
(l, i) => i > startIdx && (l.includes('[ Failure ]') || l.includes('################[ Failure ]')),
|
|
530
|
+
);
|
|
531
|
+
if (endIdx === -1) endIdx = lines.length;
|
|
532
|
+
|
|
533
|
+
const slice = lines.slice(startIdx + 1, endIdx);
|
|
534
|
+
|
|
535
|
+
return slice
|
|
536
|
+
.map(l => l.trimEnd())
|
|
537
|
+
.filter(l => l.trim())
|
|
538
|
+
.filter(l => !l.includes('[ Logs ]') && !l.includes('Logs:'))
|
|
539
|
+
.join('\n')
|
|
540
|
+
.trim();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function decodeHtmlEntities(value = '') {
|
|
544
|
+
return String(value || '')
|
|
545
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
546
|
+
.replace(/>/gi, '>')
|
|
547
|
+
.replace(/</gi, '<')
|
|
548
|
+
.replace(/&/gi, '&')
|
|
549
|
+
.replace(/"/gi, '"')
|
|
550
|
+
.replace(/'/gi, "'")
|
|
551
|
+
.replace(/\r\n/g, '\n');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function stripStepMarkedLinesRaw(text = '') {
|
|
555
|
+
const t = decodeHtmlEntities(text);
|
|
556
|
+
return t
|
|
557
|
+
.split('\n')
|
|
558
|
+
.filter(line => {
|
|
559
|
+
const ln = line.replace(ansiRegExp(), '');
|
|
560
|
+
return !/^\s*(?:>|>|[⏩►])\s/i.test(ln);
|
|
561
|
+
})
|
|
562
|
+
.join('\n')
|
|
563
|
+
.trim();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Converts 'pending' status to 'todo' for Testomat.io display
|
|
568
|
+
* @param {string} value - Status value
|
|
569
|
+
* @returns {string} Status with 'pending' converted to 'todo'
|
|
570
|
+
*/
|
|
571
|
+
function normalizeStatus(value) {
|
|
572
|
+
const s = String(value || '').toLowerCase();
|
|
573
|
+
if (s === 'pending') return 'todo';
|
|
574
|
+
return s || 'unknown';
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function pickAttemptStatus(a) {
|
|
578
|
+
if (!a) return 'unknown';
|
|
579
|
+
|
|
580
|
+
if (a.passed === true) return 'passed';
|
|
581
|
+
if (a.passed === false) return 'failed';
|
|
582
|
+
|
|
583
|
+
return normalizeStatus(a.status ?? a.state ?? a.outcome ?? a.result ?? a.verdict ?? a.ok ?? 'unknown');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function pickAttemptDuration(a) {
|
|
587
|
+
const n = a?.duration ?? a?.durationMs ?? a?.run_time ?? a?.time ?? a?.elapsed ?? null;
|
|
588
|
+
|
|
589
|
+
return typeof n === 'number' && !Number.isNaN(n) ? n : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function buildAttemptsFromCount(retryCount, finalStatus) {
|
|
593
|
+
const total = Math.max(1, Number(retryCount || 0) + 1);
|
|
594
|
+
const arr = [];
|
|
595
|
+
|
|
596
|
+
for (let i = 0; i < total; i++) {
|
|
597
|
+
const status = i === total - 1 ? normalizeStatus(finalStatus) : 'unknown';
|
|
598
|
+
arr.push({ status, duration: null });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return arr;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function normalizeRetries(test) {
|
|
605
|
+
test.meta = test.meta || {};
|
|
606
|
+
|
|
607
|
+
parseRetryInfo(test);
|
|
608
|
+
|
|
609
|
+
const finalStatus = normalizeStatus(test.status);
|
|
610
|
+
|
|
611
|
+
const attemptsRaw =
|
|
612
|
+
(Array.isArray(test.attempts) && test.attempts) ||
|
|
613
|
+
(Array.isArray(test.retries?.attempts) && test.retries.attempts) ||
|
|
614
|
+
(Array.isArray(test.meta?.attempts) && test.meta.attempts) ||
|
|
615
|
+
(Array.isArray(test.meta?.retries) && test.meta.retries) ||
|
|
616
|
+
[];
|
|
617
|
+
|
|
618
|
+
const retryCountFromMeta = typeof test.meta.retryCount === 'number' ? test.meta.retryCount : undefined;
|
|
619
|
+
|
|
620
|
+
const retryCountFromRetries = typeof test.retries?.retryCount === 'number' ? test.retries.retryCount : undefined;
|
|
621
|
+
|
|
622
|
+
let attemptsNormalized = [];
|
|
623
|
+
|
|
624
|
+
if (attemptsRaw.length > 0) {
|
|
625
|
+
attemptsNormalized = attemptsRaw.map(a => ({
|
|
626
|
+
status: pickAttemptStatus(a),
|
|
627
|
+
duration: pickAttemptDuration(a),
|
|
628
|
+
}));
|
|
629
|
+
|
|
630
|
+
const lastIdx = attemptsNormalized.length - 1;
|
|
631
|
+
if (lastIdx >= 0) attemptsNormalized[lastIdx].status = finalStatus;
|
|
632
|
+
} else {
|
|
633
|
+
const retryCount = retryCountFromMeta ?? retryCountFromRetries ?? 0;
|
|
634
|
+
|
|
635
|
+
attemptsNormalized = buildAttemptsFromCount(retryCount, finalStatus);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const retryCountFinal = Math.max(0, attemptsNormalized.length - 1);
|
|
639
|
+
|
|
640
|
+
const hadFailures = attemptsNormalized.slice(0, -1).some(a => a.status === 'failed');
|
|
641
|
+
|
|
642
|
+
const passedAfterRetries = finalStatus === 'passed' && hadFailures;
|
|
643
|
+
|
|
644
|
+
test.retries = {
|
|
645
|
+
retryCount: retryCountFinal,
|
|
646
|
+
attempts: attemptsNormalized,
|
|
647
|
+
hadFailures,
|
|
648
|
+
passedAfterRetries,
|
|
649
|
+
finalStatus,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const metaFlaky = test.meta?.flaky === true || test.meta?.isFlaky === true;
|
|
653
|
+
|
|
654
|
+
test.flaky = Boolean(metaFlaky || passedAfterRetries);
|
|
339
655
|
}
|
|
340
656
|
|
|
341
657
|
/**
|
|
@@ -370,4 +686,494 @@ function getCurrentDateTimeFormatted() {
|
|
|
370
686
|
return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
|
|
371
687
|
}
|
|
372
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Aggregates duplicate test records (from retries) into a single entry
|
|
691
|
+
* @param {Array} tests - Array of all tests
|
|
692
|
+
* @returns {Array} - Aggregated array of tests
|
|
693
|
+
*/
|
|
694
|
+
function aggregateTestRetries(tests) {
|
|
695
|
+
if (!Array.isArray(tests) || tests.length === 0) return tests;
|
|
696
|
+
|
|
697
|
+
const grouped = new Map();
|
|
698
|
+
|
|
699
|
+
for (const t of tests) {
|
|
700
|
+
const rid = t?.rid || t?.meta?.rid || t?.meta?.RID || t?.meta?.runRid || t?.meta?.testRid;
|
|
701
|
+
|
|
702
|
+
const key = rid ? `rid:${rid}` : `ft:${t?.file || ''}|${t?.title || ''}`;
|
|
703
|
+
|
|
704
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
705
|
+
grouped.get(key).push(t);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const aggregated = [];
|
|
709
|
+
|
|
710
|
+
grouped.forEach(group => {
|
|
711
|
+
if (group.length === 1) {
|
|
712
|
+
aggregated.push(group[0]);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const attemptsOnly = group.filter(x => x && x.status);
|
|
717
|
+
const base = attemptsOnly.length ? attemptsOnly[attemptsOnly.length - 1] : group[group.length - 1];
|
|
718
|
+
|
|
719
|
+
const allFiles = [];
|
|
720
|
+
const allArtifacts = [];
|
|
721
|
+
const allMetaAttachments = [];
|
|
722
|
+
const allManual = [];
|
|
723
|
+
|
|
724
|
+
for (const x of group) {
|
|
725
|
+
if (Array.isArray(x?.files)) allFiles.push(...x.files);
|
|
726
|
+
if (Array.isArray(x?.artifacts)) allArtifacts.push(...x.artifacts);
|
|
727
|
+
if (Array.isArray(x?.meta?.attachments)) allMetaAttachments.push(...x.meta.attachments);
|
|
728
|
+
if (Array.isArray(x?.manuallyAttachedArtifacts)) allManual.push(...x.manuallyAttachedArtifacts);
|
|
729
|
+
if (Array.isArray(x?.meta?.manuallyAttachedArtifacts)) allManual.push(...x.meta.manuallyAttachedArtifacts);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const attempts = attemptsOnly.map(a => ({
|
|
733
|
+
status: normalizeStatus(a.status),
|
|
734
|
+
duration: a.run_time || a.time || 0,
|
|
735
|
+
}));
|
|
736
|
+
|
|
737
|
+
const retryCount = Math.max(0, attempts.length - 1);
|
|
738
|
+
const hadFailures = attempts.slice(0, -1).some(a => a.status === 'failed');
|
|
739
|
+
const finalStatus = normalizeStatus(base.status);
|
|
740
|
+
const passedAfterRetries = finalStatus === 'passed' && hadFailures;
|
|
741
|
+
|
|
742
|
+
const merged = merge({}, base);
|
|
743
|
+
|
|
744
|
+
if (allFiles.length) merged.files = allFiles;
|
|
745
|
+
if (allArtifacts.length) merged.artifacts = allArtifacts;
|
|
746
|
+
|
|
747
|
+
merged.meta = merged.meta || {};
|
|
748
|
+
if (allMetaAttachments.length) {
|
|
749
|
+
merged.meta.attachments = [...(merged.meta.attachments || []), ...allMetaAttachments];
|
|
750
|
+
}
|
|
751
|
+
if (allManual.length) {
|
|
752
|
+
merged.manuallyAttachedArtifacts = [...(merged.manuallyAttachedArtifacts || []), ...allManual];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
merged.retries = {
|
|
756
|
+
retryCount,
|
|
757
|
+
attempts,
|
|
758
|
+
hadFailures,
|
|
759
|
+
passedAfterRetries,
|
|
760
|
+
finalStatus,
|
|
761
|
+
};
|
|
762
|
+
merged.flaky = Boolean(passedAfterRetries || merged.meta?.flaky || merged.meta?.isFlaky);
|
|
763
|
+
|
|
764
|
+
aggregated.push(merged);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
return aggregated;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function extractStepLines(raw = '') {
|
|
771
|
+
const text = decodeHtmlEntities(toPlainText(raw || ''));
|
|
772
|
+
if (!text.trim()) return { steps: [], restText: '' };
|
|
773
|
+
|
|
774
|
+
const lines = text.split('\n');
|
|
775
|
+
const steps = [];
|
|
776
|
+
const rest = [];
|
|
777
|
+
|
|
778
|
+
for (const line of lines) {
|
|
779
|
+
const cleanedLine = line.replace(ansiRegExp(), '');
|
|
780
|
+
const stepMatch = cleanedLine.match(/^\s*(?:>|>|[⏩►])\s*(.+?)\s*$/i);
|
|
781
|
+
if (stepMatch) {
|
|
782
|
+
steps.push(stepMatch[1].trim());
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const stepWithLabel = cleanedLine.match(/^\s*(?:>|>|[⏩►]\s*)?\s*Step:\s*(.+)\s*$/i);
|
|
787
|
+
if (stepWithLabel) {
|
|
788
|
+
steps.push(stepWithLabel[1].trim());
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (/^\s*Step\s*\d+\s*$/i.test(cleanedLine)) continue;
|
|
793
|
+
|
|
794
|
+
rest.push(line);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return { steps, restText: rest.join('\n').trim() };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function filterUserStepsTree(steps) {
|
|
801
|
+
if (!Array.isArray(steps)) return [];
|
|
802
|
+
|
|
803
|
+
const isUserStep = s => String(s?.category || '').toLowerCase() === 'user';
|
|
804
|
+
|
|
805
|
+
const walk = arr => {
|
|
806
|
+
const out = [];
|
|
807
|
+
for (const s of arr) {
|
|
808
|
+
if (!s) continue;
|
|
809
|
+
|
|
810
|
+
const children = walk(s.steps || []);
|
|
811
|
+
|
|
812
|
+
if (isUserStep(s)) {
|
|
813
|
+
const copy = { ...s };
|
|
814
|
+
if (children.length) copy.steps = children;
|
|
815
|
+
else delete copy.steps;
|
|
816
|
+
out.push(copy);
|
|
817
|
+
} else if (children.length) {
|
|
818
|
+
out.push(...children);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return out;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
return walk(steps);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Builds a tree structure from a flat array of step names
|
|
829
|
+
* This is used when steps are stored as logs (like in Playwright)
|
|
830
|
+
* @param {string[]} stepLines - Array of step names
|
|
831
|
+
* @returns {Array} - Tree structure of steps
|
|
832
|
+
*/
|
|
833
|
+
function buildStepsTreeFromLogs(stepLines) {
|
|
834
|
+
if (!Array.isArray(stepLines) || stepLines.length === 0) return [];
|
|
835
|
+
|
|
836
|
+
const result = [];
|
|
837
|
+
const stack = [];
|
|
838
|
+
const indentStack = [];
|
|
839
|
+
|
|
840
|
+
stepLines.forEach(line => {
|
|
841
|
+
if (!line || !line.trim()) return;
|
|
842
|
+
|
|
843
|
+
const text = line.trim();
|
|
844
|
+
const indent = line.search(/\S/);
|
|
845
|
+
|
|
846
|
+
while (indentStack.length > 0 && indentStack[indentStack.length - 1] >= indent) {
|
|
847
|
+
stack.pop();
|
|
848
|
+
indentStack.pop();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const stepObj = {
|
|
852
|
+
category: 'user',
|
|
853
|
+
title: text,
|
|
854
|
+
duration: 0,
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
if (stack.length === 0) {
|
|
858
|
+
result.push(stepObj);
|
|
859
|
+
} else {
|
|
860
|
+
const parent = stack[stack.length - 1];
|
|
861
|
+
if (!parent.steps) parent.steps = [];
|
|
862
|
+
parent.steps.push(stepObj);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
stack.push(stepObj);
|
|
866
|
+
indentStack.push(indent);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function cleanNoiseBlock(text = '') {
|
|
873
|
+
const t = decodeHtmlEntities(String(text || '')).replace(ansiRegExp(), '');
|
|
874
|
+
|
|
875
|
+
let lines = t
|
|
876
|
+
.split('\n')
|
|
877
|
+
.map(l => l.trim())
|
|
878
|
+
.filter(Boolean);
|
|
879
|
+
|
|
880
|
+
lines = dropISayEcho(lines);
|
|
881
|
+
|
|
882
|
+
return lines.join('\n').trim();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function dropISayEcho(lines) {
|
|
886
|
+
const out = [];
|
|
887
|
+
for (let i = 0; i < lines.length; i++) {
|
|
888
|
+
const cur = lines[i];
|
|
889
|
+
const prev = out[out.length - 1];
|
|
890
|
+
|
|
891
|
+
const m = prev && prev.match(/^I say\s+"([\s\S]*)"$/);
|
|
892
|
+
if (m) {
|
|
893
|
+
const said = m[1];
|
|
894
|
+
if (cur === said) continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
out.push(cur);
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Collects all Testomatio and S3 environment variables
|
|
904
|
+
* Uses hardcoded list to avoid file system dependencies for end users
|
|
905
|
+
* @returns {Object} Object with TESTOMATIO_ and S3_ variables grouped
|
|
906
|
+
*/
|
|
907
|
+
function collectEnvironmentVariables() {
|
|
908
|
+
return getHardcodedEnvVars();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Process environment variables configuration and collect their values
|
|
913
|
+
* @param {Object} varConfigs - Object with variable configurations { [key]: { description } }
|
|
914
|
+
* @param {Set} sensitiveVars - Set of sensitive variable names
|
|
915
|
+
* @returns {Object} Processed environment variables with metadata
|
|
916
|
+
*/
|
|
917
|
+
function processEnvironmentVariables(varConfigs, sensitiveVars) {
|
|
918
|
+
const result = {};
|
|
919
|
+
|
|
920
|
+
for (const [key, config] of Object.entries(varConfigs)) {
|
|
921
|
+
const value = process.env[key];
|
|
922
|
+
const isSensitive = sensitiveVars.has(key);
|
|
923
|
+
|
|
924
|
+
if (isSensitive) {
|
|
925
|
+
if (value !== undefined) {
|
|
926
|
+
result[key] = { value: '***', description: config.description, isSet: true, isSensitive: true };
|
|
927
|
+
} else {
|
|
928
|
+
result[key] = { value: '', description: config.description, isSet: false, isSensitive: true };
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
if (value !== undefined) {
|
|
932
|
+
result[key] = { value, description: config.description, isSet: true };
|
|
933
|
+
} else {
|
|
934
|
+
result[key] = { value: '', description: config.description, isSet: false };
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return result;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Hardcoded environment variables stored in code
|
|
944
|
+
* This is the main source of truth for env vars to avoid file system dependencies
|
|
945
|
+
* @returns {Object} Object with TESTOMATIO_ and S3_ variables
|
|
946
|
+
*/
|
|
947
|
+
function getHardcodedEnvVars() {
|
|
948
|
+
const allVars = {
|
|
949
|
+
testomatio: {
|
|
950
|
+
TESTOMATIO: { description: 'API Key for Testomat.io' },
|
|
951
|
+
TESTOMATIO_API_KEY: { description: 'API Key (alias for TESTOMATIO)' },
|
|
952
|
+
TESTOMATIO_CREATE: { description: 'Create new tests in Testomat.io' },
|
|
953
|
+
TESTOMATIO_DEBUG: { description: 'Enable debug mode' },
|
|
954
|
+
TESTOMATIO_DISABLE_BATCH_UPLOAD: { description: 'Disable batch upload' },
|
|
955
|
+
TESTOMATIO_ENV: { description: 'Environment label (e.g., "Windows, Chrome")' },
|
|
956
|
+
TESTOMATIO_EXCLUDE_FILES_FROM_REPORT_GLOB_PATTERN: { description: 'Glob pattern to exclude files' },
|
|
957
|
+
TESTOMATIO_EXCLUDE_SKIPPED: { description: 'Exclude skipped tests from report' },
|
|
958
|
+
TESTOMATIO_FILENAME: { description: 'HTML report filename' },
|
|
959
|
+
TESTOMATIO_HTML_FILENAME: { description: 'HTML report filename' },
|
|
960
|
+
TESTOMATIO_HTML_REPORT_FOLDER: { description: 'Folder for HTML report' },
|
|
961
|
+
TESTOMATIO_HTML_REPORT_SAVE: { description: 'Save HTML report' },
|
|
962
|
+
TESTOMATIO_INTERCEPT_CONSOLE_LOGS: { description: 'Intercept console logs' },
|
|
963
|
+
TESTOMATIO_MARK_DETACHED: { description: 'Mark tests as detached' },
|
|
964
|
+
TESTOMATIO_MAX_REQUEST_FAILURES: { description: 'Max request failures' },
|
|
965
|
+
TESTOMATIO_MAX_REQUEST_FAILURES_COUNT: { description: 'Max request failures count' },
|
|
966
|
+
TESTOMATIO_MAX_REQUEST_RETRIES_WITHIN_TIME_SECONDS: { description: 'Max retries within time period' },
|
|
967
|
+
TESTOMATIO_NO_STEPS: { description: 'Disable steps reporting' },
|
|
968
|
+
TESTOMATIO_NO_TIMESTAMP: { description: 'Remove timestamps from logs' },
|
|
969
|
+
TESTOMATIO_PROCEED: { description: 'Proceed even if tests fail' },
|
|
970
|
+
TESTOMATIO_PUBLISH: { description: 'Publish results to Testomat.io' },
|
|
971
|
+
TESTOMATIO_REQUEST_TIMEOUT: { description: 'Request timeout in milliseconds' },
|
|
972
|
+
TESTOMATIO_RUN: { description: 'Run ID to report tests to' },
|
|
973
|
+
TESTOMATIO_RUNGROUP_TITLE: { description: 'Title for run group' },
|
|
974
|
+
TESTOMATIO_SHARED_RUN: { description: 'Share run for parallel execution' },
|
|
975
|
+
TESTOMATIO_SHARED_RUN_TIMEOUT: { description: 'Timeout for shared run (in seconds)' },
|
|
976
|
+
TESTOMATIO_STACK_ARTIFACTS: { description: 'Stack artifacts in report' },
|
|
977
|
+
TESTOMATIO_STACK_FILTER: { description: 'Filter stack traces' },
|
|
978
|
+
TESTOMATIO_STACK_PASSED: { description: 'Report stack for passed tests' },
|
|
979
|
+
TESTOMATIO_STEPS_PASSED: { description: 'Report steps for passed tests' },
|
|
980
|
+
TESTOMATIO_SUITE: { description: 'Suite ID for new tests' },
|
|
981
|
+
TESTOMATIO_TOKEN: { description: 'API Token (alias for TESTOMATIO)' },
|
|
982
|
+
TESTOMATIO_TITLE: { description: 'Title for the test run' },
|
|
983
|
+
TESTOMATIO_URL: { description: 'Testomat.io URL (custom instance)' },
|
|
984
|
+
TESTOMATIO_WORKDIR: { description: 'Working directory for relative paths' },
|
|
985
|
+
},
|
|
986
|
+
s3: {
|
|
987
|
+
S3_ACCESS_KEY_ID: { description: 'S3 access key ID' },
|
|
988
|
+
S3_BUCKET: { description: 'S3 bucket name' },
|
|
989
|
+
S3_ENDPOINT: { description: 'S3 endpoint URL' },
|
|
990
|
+
S3_FORCE_PATH_STYLE: { description: 'S3 force path style' },
|
|
991
|
+
S3_KEY: { description: 'S3 access key' },
|
|
992
|
+
S3_PREFIX: { description: 'S3 key prefix' },
|
|
993
|
+
S3_REGION: { description: 'S3 region' },
|
|
994
|
+
S3_SECRET: { description: 'S3 secret key' },
|
|
995
|
+
S3_SECRET_ACCESS_KEY: { description: 'S3 secret access key' },
|
|
996
|
+
S3_SESSION_TOKEN: { description: 'S3 session token' },
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const sensitiveVars = new Set([
|
|
1001
|
+
'TESTOMATIO',
|
|
1002
|
+
'TESTOMATIO_TOKEN',
|
|
1003
|
+
'TESTOMATIO_API_KEY',
|
|
1004
|
+
'S3_KEY',
|
|
1005
|
+
'S3_SECRET',
|
|
1006
|
+
'S3_ACCESS_KEY_ID',
|
|
1007
|
+
'S3_SECRET_ACCESS_KEY',
|
|
1008
|
+
'S3_SESSION_TOKEN',
|
|
1009
|
+
]);
|
|
1010
|
+
|
|
1011
|
+
const envVars = {
|
|
1012
|
+
testomatio: processEnvironmentVariables(allVars.testomatio, sensitiveVars),
|
|
1013
|
+
s3: processEnvironmentVariables(allVars.s3, sensitiveVars),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
return envVars;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Prepares test steps for HTML report display
|
|
1021
|
+
* @param {object} test - Test object
|
|
1022
|
+
* @param {Array} stepsTree - Steps tree from logs
|
|
1023
|
+
* @param {Array} allStepLines - All step lines from message/logs/stack
|
|
1024
|
+
* @param {string} fallbackStepsText - Fallback steps text
|
|
1025
|
+
*/
|
|
1026
|
+
function prepareTestStepsForReport(test, stepsTree, allStepLines, fallbackStepsText) {
|
|
1027
|
+
if (Array.isArray(test.steps) && test.steps.length) {
|
|
1028
|
+
const userSteps = filterUserStepsTree(test.steps);
|
|
1029
|
+
test.stepsArray = userSteps;
|
|
1030
|
+
|
|
1031
|
+
if (userSteps.length) {
|
|
1032
|
+
test.steps = userSteps
|
|
1033
|
+
.map(s => formatStep(s))
|
|
1034
|
+
.flat()
|
|
1035
|
+
.join('\n');
|
|
1036
|
+
} else if (stepsTree) {
|
|
1037
|
+
test.stepsArray = stepsTree;
|
|
1038
|
+
test.steps = stepsTree.map(s => formatStep(s)).flat().join('\n');
|
|
1039
|
+
} else if (fallbackStepsText) {
|
|
1040
|
+
test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 }));
|
|
1041
|
+
test.steps = fallbackStepsText;
|
|
1042
|
+
} else {
|
|
1043
|
+
test.steps = '';
|
|
1044
|
+
test.stepsArray = [];
|
|
1045
|
+
}
|
|
1046
|
+
} else if (stepsTree) {
|
|
1047
|
+
test.stepsArray = stepsTree;
|
|
1048
|
+
test.steps = stepsTree.map(s => formatStep(s)).flat().join('\n');
|
|
1049
|
+
} else if (fallbackStepsText) {
|
|
1050
|
+
test.stepsArray = allStepLines.map(t => ({ category: 'user', title: t, duration: 0 }));
|
|
1051
|
+
test.steps = fallbackStepsText;
|
|
1052
|
+
} else if (typeof test.steps === 'string' && test.steps.trim()) {
|
|
1053
|
+
test.stepsArray = [];
|
|
1054
|
+
test.steps = String(test.steps).replace(ansiRegExp(), '').trim();
|
|
1055
|
+
} else {
|
|
1056
|
+
test.steps = '';
|
|
1057
|
+
test.stepsArray = [];
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Normalizes artifacts from different sources into a unified format
|
|
1063
|
+
* @param {object} test - Test object with artifacts
|
|
1064
|
+
* @returns {Array} - Normalized artifacts array with trace files filtered out
|
|
1065
|
+
*/
|
|
1066
|
+
function normalizeArtifacts(test) {
|
|
1067
|
+
test.artifacts = test.artifacts || [];
|
|
1068
|
+
test.meta = test.meta || {};
|
|
1069
|
+
|
|
1070
|
+
const allArtifacts = [
|
|
1071
|
+
...(test.artifacts || []),
|
|
1072
|
+
...(test.meta?.attachments || []),
|
|
1073
|
+
...(test.manuallyAttachedArtifacts || []),
|
|
1074
|
+
...(test.files || []),
|
|
1075
|
+
...(test.meta?.manuallyAttachedArtifacts || []),
|
|
1076
|
+
];
|
|
1077
|
+
|
|
1078
|
+
return allArtifacts
|
|
1079
|
+
.map(artifact => {
|
|
1080
|
+
if (typeof artifact === 'string') {
|
|
1081
|
+
const abs = path.isAbsolute(artifact) ? artifact : path.resolve(process.cwd(), artifact);
|
|
1082
|
+
const href = artifact.startsWith('file://') ? artifact : fileUrl(abs, { resolve: true });
|
|
1083
|
+
const base = path.basename(abs);
|
|
1084
|
+
|
|
1085
|
+
return {
|
|
1086
|
+
name: base,
|
|
1087
|
+
title: base,
|
|
1088
|
+
path: href,
|
|
1089
|
+
fsPath: abs,
|
|
1090
|
+
relativePath: artifact,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (artifact?.path) {
|
|
1095
|
+
const raw = String(artifact.path);
|
|
1096
|
+
const isFileUrl = raw.startsWith('file://');
|
|
1097
|
+
const abs = isFileUrl ? null : path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
|
1098
|
+
const href = isFileUrl ? raw : fileUrl(abs, { resolve: true });
|
|
1099
|
+
const base = abs ? path.basename(abs) : artifact.name || artifact.title || 'attachment';
|
|
1100
|
+
|
|
1101
|
+
return {
|
|
1102
|
+
...artifact,
|
|
1103
|
+
name: artifact.name || artifact.title || base,
|
|
1104
|
+
title: artifact.title || artifact.name || base,
|
|
1105
|
+
path: href,
|
|
1106
|
+
fsPath: abs || artifact.fsPath || null,
|
|
1107
|
+
relativePath: artifact.relativePath || raw,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return artifact;
|
|
1112
|
+
})
|
|
1113
|
+
.filter(Boolean)
|
|
1114
|
+
.filter(artifact => {
|
|
1115
|
+
const isTrace = (artifact.title === 'trace' || artifact.name === 'trace') &&
|
|
1116
|
+
(artifact.type === 'application/zip' ||
|
|
1117
|
+
artifact.path?.endsWith('.zip') ||
|
|
1118
|
+
artifact.relativePath?.endsWith('.zip'));
|
|
1119
|
+
return !isTrace;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Loads trace files from test.files and converts them to base64 data URLs
|
|
1125
|
+
* @param {object} test - Test object with files array
|
|
1126
|
+
*/
|
|
1127
|
+
function loadTracesFromFiles(test) {
|
|
1128
|
+
if (!test.traces && test.files && Array.isArray(test.files) && test.files.length > 0) {
|
|
1129
|
+
const traceFiles = test.files.filter(f =>
|
|
1130
|
+
f.path &&
|
|
1131
|
+
f.path.trim().length > 0 &&
|
|
1132
|
+
(f.title === 'trace' || f.name === 'trace') &&
|
|
1133
|
+
(f.type === 'application/zip' || f.path.endsWith('.zip'))
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
if (traceFiles.length > 0) {
|
|
1137
|
+
const traceDataList = [];
|
|
1138
|
+
traceFiles.forEach(f => {
|
|
1139
|
+
if (!fs.existsSync(f.path)) {
|
|
1140
|
+
console.warn(`Trace file not found: ${f.path}`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
try {
|
|
1145
|
+
const fileBuffer = fs.readFileSync(f.path, null);
|
|
1146
|
+
|
|
1147
|
+
if (!fileBuffer || fileBuffer.length === 0) {
|
|
1148
|
+
console.warn(`Empty trace file: ${f.path}`);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const base64 = fileBuffer.toString('base64');
|
|
1152
|
+
|
|
1153
|
+
let filename = 'trace.zip';
|
|
1154
|
+
try {
|
|
1155
|
+
filename = path.basename(f.path);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
console.warn(`Could not extract filename from ${f.path}, using default`);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const dataUrl = `data:application/zip;base64,${base64}`;
|
|
1161
|
+
|
|
1162
|
+
traceDataList.push({
|
|
1163
|
+
dataUrl,
|
|
1164
|
+
name: filename
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
console.error(`Failed to convert trace to base64: ${f.path}`, e.message);
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
if (traceDataList.length > 0) {
|
|
1173
|
+
test.traces = traceDataList;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
373
1179
|
export default HtmlPipe;
|