@testomatio/reporter 2.7.6 → 2.7.9-beta.1-markdown

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.
@@ -0,0 +1,743 @@
1
+ import createDebugMessages from 'debug';
2
+ import merge from 'lodash.merge';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import pc from 'picocolors';
6
+ import fileUrl from 'file-url';
7
+ import { fileSystem, isSameTest, ansiRegExp } from '../utils/utils.js';
8
+ import { MARKDOWN_REPORT } from '../constants.js';
9
+
10
+ const debug = createDebugMessages('@testomatio/reporter:pipe:markdown');
11
+
12
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
13
+
14
+ class MarkdownPipe {
15
+ constructor(params, store = {}) {
16
+ this.store = store || {};
17
+ this.title = params.title || process.env.TESTOMATIO_TITLE;
18
+ this.apiKey = params.apiKey || process.env.TESTOMATIO;
19
+ this.isMarkdown = process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE;
20
+
21
+ debug('Markdown Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
22
+
23
+ this.isEnabled = false;
24
+ this.markdownOutputPath = '';
25
+ this.filenameMsg = '';
26
+ this.tests = [];
27
+
28
+ if (!this.isMarkdown) return;
29
+
30
+ this.isEnabled = true;
31
+ this.markdownReportDir = process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER || MARKDOWN_REPORT.FOLDER;
32
+
33
+ const envName = process.env.TESTOMATIO_MARKDOWN_FILENAME;
34
+ if (envName && envName.endsWith('.md')) {
35
+ this.markdownReportName = envName;
36
+ } else if (envName) {
37
+ this.markdownReportName = MARKDOWN_REPORT.REPORT_DEFAULT_NAME;
38
+ this.filenameMsg =
39
+ 'Markdown filename must include the extension ".md".' +
40
+ ` The default report name "${this.markdownReportDir}/${MARKDOWN_REPORT.REPORT_DEFAULT_NAME}" is used!`;
41
+ } else {
42
+ this.markdownReportName = MARKDOWN_REPORT.REPORT_DEFAULT_NAME;
43
+ }
44
+
45
+ this.markdownOutputPath = path.join(this.markdownReportDir, this.markdownReportName);
46
+ fileSystem.createDir(this.markdownReportDir);
47
+
48
+ debug(
49
+ pc.yellow('Markdown Pipe:'),
50
+ `Save Markdown report: ${this.isEnabled}`,
51
+ `Markdown report folder: ${this.markdownReportDir}, report name: ${this.markdownReportName}`,
52
+ );
53
+ }
54
+
55
+ async createRun() {
56
+ // empty
57
+ }
58
+
59
+ async prepareRun() {}
60
+
61
+ updateRun() {
62
+ // empty
63
+ }
64
+
65
+ /**
66
+ * @param {import('../../types/types.js').MarkdownTestData} test
67
+ */
68
+ addTest(test) {
69
+ if (!this.isEnabled) return;
70
+
71
+ if (test?.stack && typeof test.stack === 'string') {
72
+ test.stack = test.stack.replace(ansiRegExp(), '');
73
+ }
74
+
75
+ const hasPayload =
76
+ Boolean(test?.status) ||
77
+ (Array.isArray(test?.files) && test.files.length) ||
78
+ (Array.isArray(test?.artifacts) && test.artifacts.length) ||
79
+ (Array.isArray(test?.steps) && test.steps.length) ||
80
+ Boolean(test?.message) ||
81
+ Boolean(test?.logs) ||
82
+ (test?.meta &&
83
+ ((Array.isArray(test.meta.attachments) && test.meta.attachments.length) || test.meta.traces !== undefined));
84
+
85
+ if (!hasPayload) return;
86
+
87
+ const index = this.tests.findIndex(t => isSameTest(t, test));
88
+ if (index >= 0) {
89
+ this.tests[index] = merge(this.tests[index], test);
90
+ return;
91
+ }
92
+
93
+ this.tests.push(test);
94
+ }
95
+
96
+ async finishRun(runParams) {
97
+ if (!this.isEnabled) return;
98
+
99
+ this.buildReport({
100
+ runParams,
101
+ tests: this.tests,
102
+ outputPath: this.markdownOutputPath,
103
+ warningMsg: this.filenameMsg,
104
+ });
105
+ }
106
+
107
+ buildReport(opts) {
108
+ const { runParams, tests, outputPath, warningMsg: msg } = opts;
109
+
110
+ debug('Markdown tests data:', tests);
111
+
112
+ if (!outputPath) {
113
+ console.log(pc.yellow(`🚨 Markdown export path is not set, ignoring...`));
114
+ return;
115
+ }
116
+
117
+ console.log(pc.yellow(`⏳ The test results will be added to the Markdown report. It will take some time...`));
118
+
119
+ if (msg) {
120
+ console.log(pc.blue(msg));
121
+ }
122
+
123
+ const aggregated = aggregateTestRetries(tests);
124
+ const stats = computeStats(aggregated);
125
+
126
+ const data = {
127
+ title: this.title || 'Test Results',
128
+ runId: this.store.runId || '',
129
+ runUrl: this.store.runUrl || '',
130
+ status: runParams?.status || 'unknown',
131
+ isParallel: runParams?.isParallel,
132
+ executionTime: testExecutionSumTime(aggregated),
133
+ executionDate: getCurrentDateTimeFormatted(),
134
+ tests: aggregated,
135
+ stats,
136
+ envVars: collectEnvironmentVariables(),
137
+ };
138
+
139
+ const md = renderDocument(data);
140
+
141
+ fs.writeFileSync(outputPath, md, 'utf-8');
142
+
143
+ if (fs.existsSync(outputPath)) {
144
+ const absolutePath = path.resolve(outputPath);
145
+ const fileUrlPath = fileUrl(absolutePath, { resolve: true });
146
+ debug('Markdown report path:', fileUrlPath);
147
+ console.log(pc.green(`📝 The Markdown report was successfully generated. Full filepath: ${fileUrlPath}`));
148
+ } else {
149
+ console.log(pc.red(`🚨 Failed to generate the Markdown report.`));
150
+ }
151
+ }
152
+
153
+ async sync() {
154
+ // MarkdownPipe doesn't buffer, no-op
155
+ }
156
+
157
+ toString() {
158
+ return 'Markdown Reporter';
159
+ }
160
+ }
161
+
162
+ function renderDocument(data) {
163
+ const sections = [];
164
+ sections.push(renderHeader(data));
165
+ sections.push(renderRunMetadata(data));
166
+ sections.push(renderEnvSection(data.envVars));
167
+ sections.push(renderTests(data.tests));
168
+ return sections.filter(Boolean).join('\n\n') + '\n';
169
+ }
170
+
171
+ function renderHeader(data) {
172
+ const overall = String(data.status || 'unknown').toLowerCase();
173
+ const headline = `# ${data.title} — ${overall.toUpperCase()}`;
174
+
175
+ const s = data.stats;
176
+ const summaryTable = [
177
+ '## Summary',
178
+ '',
179
+ '| Total | Passed | Failed | Skipped | Todo | Flaky |',
180
+ '| ----- | ------ | ------ | ------- | ---- | ----- |',
181
+ `| ${s.total} | ${s.passed} | ${s.failed} | ${s.skipped} | ${s.todo} | ${s.flaky} |`,
182
+ ].join('\n');
183
+
184
+ return `${headline}\n\n${summaryTable}`;
185
+ }
186
+
187
+ function renderRunMetadata(data) {
188
+ const rows = [];
189
+
190
+ rows.push(['Status', mdInline(data.status || 'unknown')]);
191
+
192
+ if (data.runId) {
193
+ rows.push(['Run ID', `\`${mdInline(data.runId)}\``]);
194
+ }
195
+ if (data.runUrl) {
196
+ rows.push(['Run URL', `<${data.runUrl}>`]);
197
+ }
198
+
199
+ rows.push(['Started', mdInline(data.executionDate)]);
200
+ rows.push(['Duration', `\`${mdInline(data.executionTime)}\``]);
201
+
202
+ let parallelLabel = 'No parallel info';
203
+ if (data.isParallel === true) parallelLabel = 'true';
204
+ else if (data.isParallel === false) parallelLabel = 'false';
205
+ rows.push(['Parallel', parallelLabel]);
206
+
207
+ const lines = ['## Run Metadata', '', '| Key | Value |', '| --- | ----- |'];
208
+ for (const [k, v] of rows) {
209
+ lines.push(`| ${k} | ${v} |`);
210
+ }
211
+ return lines.join('\n');
212
+ }
213
+
214
+ function renderEnvSection(envVars) {
215
+ if (!envVars) return '';
216
+
217
+ const blocks = ['## Environment'];
218
+
219
+ const groups = [
220
+ { title: 'Testomat.io variables', vars: envVars.testomatio || {} },
221
+ { title: 'S3 variables', vars: envVars.s3 || {} },
222
+ ];
223
+
224
+ for (const group of groups) {
225
+ const entries = Object.entries(group.vars).filter(([, v]) => v && v.isSet);
226
+ if (!entries.length) continue;
227
+
228
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
229
+
230
+ const lines = [];
231
+ lines.push('<details>');
232
+ lines.push(`<summary>${group.title} (${entries.length})</summary>`);
233
+ lines.push('');
234
+ lines.push('| Variable | Value |');
235
+ lines.push('| -------- | ----- |');
236
+ for (const [name, info] of entries) {
237
+ lines.push(`| \`${name}\` | ${mdTableCell(String(info.value ?? ''))} |`);
238
+ }
239
+ lines.push('');
240
+ lines.push('</details>');
241
+
242
+ blocks.push(lines.join('\n'));
243
+ }
244
+
245
+ if (blocks.length === 1) return '';
246
+ return blocks.join('\n\n');
247
+ }
248
+
249
+ function renderTests(tests) {
250
+ if (!Array.isArray(tests) || tests.length === 0) {
251
+ return '## Tests\n\n_No test results recorded._';
252
+ }
253
+
254
+ const bySuite = new Map();
255
+ for (const test of tests) {
256
+ let suite = test.suite_title;
257
+ if (typeof suite !== 'string' || !suite.trim()) {
258
+ suite = 'Unknown suite';
259
+ }
260
+ if (!bySuite.has(suite)) bySuite.set(suite, []);
261
+ bySuite.get(suite).push(test);
262
+ }
263
+
264
+ const blocks = ['## Tests'];
265
+
266
+ for (const [suite, suiteTests] of bySuite) {
267
+ blocks.push(`### Suite: ${mdInline(suite)}`);
268
+ for (const test of suiteTests) {
269
+ blocks.push(renderTest(test));
270
+ }
271
+ }
272
+
273
+ return blocks.join('\n\n');
274
+ }
275
+
276
+ function renderTest(test) {
277
+ let title = test.title;
278
+ if (typeof title !== 'string' || !title.trim()) {
279
+ title = 'Unknown test title';
280
+ }
281
+
282
+ const status = normalizeStatus(test.status);
283
+ let displayStatus = status;
284
+ if ((status === 'skipped' || status === 'pending') && test.meta?.todo) {
285
+ displayStatus = 'todo';
286
+ }
287
+
288
+ const duration = formatStepDuration(test.run_time);
289
+ let header = `#### ${mdInline(title)}`;
290
+ if (duration) header += ` — ${duration}`;
291
+
292
+ const lines = [header];
293
+
294
+ const meta = [`- **Status:** ${displayStatus}`];
295
+
296
+ const retries = computeRetries(test);
297
+ if (retries.retryCount > 0) {
298
+ let retryLine = `- **Retries:** ${retries.retryCount}`;
299
+ if (retries.flaky) retryLine += ' (flaky)';
300
+ meta.push(retryLine);
301
+ }
302
+ if (typeof test.test_id === 'string' && test.test_id) {
303
+ meta.push(`- **Test ID:** \`${test.test_id}\``);
304
+ }
305
+ lines.push(meta.join('\n'));
306
+
307
+ const stepsBlock = renderSteps(test);
308
+ if (stepsBlock) lines.push(stepsBlock);
309
+
310
+ const messageBlock = renderMessage(test);
311
+ if (messageBlock) lines.push(messageBlock);
312
+
313
+ const stackBlock = renderStack(test);
314
+ if (stackBlock) lines.push(stackBlock);
315
+
316
+ const logsBlock = renderLogs(test);
317
+ if (logsBlock) lines.push(logsBlock);
318
+
319
+ const artifactsBlock = renderArtifacts(test);
320
+ if (artifactsBlock) lines.push(artifactsBlock);
321
+
322
+ return lines.join('\n\n');
323
+ }
324
+
325
+ function renderSteps(test) {
326
+ const steps = test.steps;
327
+
328
+ if (Array.isArray(steps) && steps.length > 0) {
329
+ const userSteps = filterUserStepsTree(steps);
330
+ const tree = userSteps.length ? userSteps : steps;
331
+ const bullets = renderStepTree(tree, 0);
332
+ if (!bullets) return '';
333
+ return `**Steps**\n\n${bullets}`;
334
+ }
335
+
336
+ if (typeof steps === 'string' && steps.trim()) {
337
+ const cleaned = steps.replace(ansiRegExp(), '').trim();
338
+ return `**Steps**\n\n${fence(cleaned)}`;
339
+ }
340
+
341
+ return '';
342
+ }
343
+
344
+ function renderStepTree(steps, depth) {
345
+ const indent = ' '.repeat(depth);
346
+ const lines = [];
347
+ for (const step of steps) {
348
+ if (!step) continue;
349
+ let title = step.title;
350
+ if (typeof title !== 'string') title = String(title ?? '');
351
+ title = title.replace(ansiRegExp(), '').trim();
352
+ if (!title) continue;
353
+
354
+ const dur = formatStepDuration(step.duration);
355
+ let bullet = `${indent}- ${mdInline(title)}`;
356
+ if (dur) bullet += ` _(${dur})_`;
357
+ if (step.error) bullet += ' **[failed]**';
358
+ lines.push(bullet);
359
+
360
+ if (Array.isArray(step.steps) && step.steps.length) {
361
+ const nested = renderStepTree(step.steps, depth + 1);
362
+ if (nested) lines.push(nested);
363
+ }
364
+ }
365
+ return lines.join('\n');
366
+ }
367
+
368
+ function filterUserStepsTree(steps) {
369
+ if (!Array.isArray(steps)) return [];
370
+
371
+ const isUserStep = s => String(s?.category || '').toLowerCase() === 'user';
372
+
373
+ const walk = arr => {
374
+ const out = [];
375
+ for (const s of arr) {
376
+ if (!s) continue;
377
+ const children = walk(s.steps || []);
378
+ if (isUserStep(s)) {
379
+ const copy = { ...s };
380
+ if (children.length) copy.steps = children;
381
+ else delete copy.steps;
382
+ out.push(copy);
383
+ } else if (children.length) {
384
+ out.push(...children);
385
+ }
386
+ }
387
+ return out;
388
+ };
389
+
390
+ return walk(steps);
391
+ }
392
+
393
+ function renderMessage(test) {
394
+ if (!test.message) return '';
395
+ const cleaned = String(test.message).replace(ansiRegExp(), '').trim();
396
+ if (!cleaned) return '';
397
+ const blockquote = cleaned
398
+ .split('\n')
399
+ .map(line => `> ${line}`)
400
+ .join('\n');
401
+ return `**Message**\n\n${blockquote}`;
402
+ }
403
+
404
+ function renderStack(test) {
405
+ if (!test.stack) return '';
406
+ const cleaned = String(test.stack).replace(ansiRegExp(), '').trim();
407
+ if (!cleaned) return '';
408
+ return `**Stack Trace**\n\n${fence(cleaned)}`;
409
+ }
410
+
411
+ function renderLogs(test) {
412
+ const sources = [test.logs, test.meta?.logs, test.meta?.console, test.meta?.stdout, test.meta?.stderr];
413
+ let raw = '';
414
+ for (const s of sources) {
415
+ if (s && String(s).trim()) {
416
+ raw = String(s);
417
+ break;
418
+ }
419
+ }
420
+ if (!raw) return '';
421
+ const cleaned = raw.replace(ansiRegExp(), '').trim();
422
+ if (!cleaned) return '';
423
+ return `**Logs**\n\n${fence(cleaned)}`;
424
+ }
425
+
426
+ function renderArtifacts(test) {
427
+ const all = [
428
+ ...(Array.isArray(test.artifacts) ? test.artifacts : []),
429
+ ...(Array.isArray(test.files) ? test.files : []),
430
+ ...(test.meta && Array.isArray(test.meta.attachments) ? test.meta.attachments : []),
431
+ ...(Array.isArray(test.manuallyAttachedArtifacts) ? test.manuallyAttachedArtifacts : []),
432
+ ];
433
+
434
+ const items = [];
435
+ const seen = new Set();
436
+
437
+ for (const raw of all) {
438
+ const item = normalizeArtifact(raw);
439
+ if (!item) continue;
440
+ if (isTraceZip(item)) continue;
441
+ if (seen.has(item.href)) continue;
442
+ seen.add(item.href);
443
+ items.push(item);
444
+ }
445
+
446
+ if (!items.length) return '';
447
+
448
+ const lines = ['**Artifacts**', ''];
449
+ for (const item of items) {
450
+ if (item.isImage) {
451
+ lines.push(`- ![${mdInline(item.name)}](${item.href})`);
452
+ } else {
453
+ lines.push(`- [${mdInline(item.name)}](${item.href})`);
454
+ }
455
+ }
456
+ return lines.join('\n');
457
+ }
458
+
459
+ function normalizeArtifact(raw) {
460
+ if (raw == null) return null;
461
+
462
+ if (typeof raw === 'string') {
463
+ if (!raw.trim()) return null;
464
+ if (/^https?:\/\//i.test(raw)) {
465
+ let base = raw;
466
+ try {
467
+ base = path.basename(new URL(raw).pathname) || raw;
468
+ } catch (_) {
469
+ base = raw;
470
+ }
471
+ return { name: base, href: raw, isImage: looksLikeImage(raw) };
472
+ }
473
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
474
+ let href = raw;
475
+ if (raw.startsWith('file://')) {
476
+ href = raw;
477
+ } else {
478
+ href = fileUrl(abs, { resolve: true });
479
+ }
480
+ return { name: path.basename(abs), href, isImage: looksLikeImage(abs) };
481
+ }
482
+
483
+ const rawPath = raw.path || raw.link || raw.url;
484
+ if (!rawPath || typeof rawPath !== 'string') return null;
485
+
486
+ const isHttp = /^https?:\/\//i.test(rawPath);
487
+ const isFileUrl = rawPath.startsWith('file://');
488
+ let href;
489
+ let name;
490
+
491
+ if (isHttp || isFileUrl) {
492
+ href = rawPath;
493
+ if (raw.name) {
494
+ name = raw.name;
495
+ } else if (raw.title) {
496
+ name = raw.title;
497
+ } else if (isHttp) {
498
+ try {
499
+ name = path.basename(new URL(rawPath).pathname) || 'attachment';
500
+ } catch (_) {
501
+ name = 'attachment';
502
+ }
503
+ } else {
504
+ name = path.basename(rawPath.replace(/^file:\/\//, '')) || 'attachment';
505
+ }
506
+ } else {
507
+ const abs = path.isAbsolute(rawPath) ? rawPath : path.resolve(process.cwd(), rawPath);
508
+ href = fileUrl(abs, { resolve: true });
509
+ name = raw.name || raw.title || path.basename(abs);
510
+ }
511
+
512
+ let isImage = false;
513
+ if (typeof raw.type === 'string' && raw.type.toLowerCase().startsWith('image/')) {
514
+ isImage = true;
515
+ } else {
516
+ isImage = looksLikeImage(rawPath);
517
+ }
518
+
519
+ return { name, href, isImage };
520
+ }
521
+
522
+ function looksLikeImage(p) {
523
+ if (typeof p !== 'string') return false;
524
+ const lower = p.toLowerCase().split('?')[0].split('#')[0];
525
+ return IMAGE_EXTS.some(ext => lower.endsWith(ext));
526
+ }
527
+
528
+ function isTraceZip(item) {
529
+ if (!item) return false;
530
+ const isTraceName = item.name === 'trace' || item.name === 'trace.zip';
531
+ const isZip = typeof item.href === 'string' && item.href.toLowerCase().split('?')[0].endsWith('.zip');
532
+ return isTraceName && isZip;
533
+ }
534
+
535
+ function fence(text, lang = '') {
536
+ const content = String(text).replace(/\r\n/g, '\n');
537
+ let ticks = '```';
538
+ while (content.includes(ticks)) {
539
+ ticks += '`';
540
+ }
541
+ if (lang) {
542
+ return `${ticks}${lang}\n${content}\n${ticks}`;
543
+ }
544
+ return `${ticks}\n${content}\n${ticks}`;
545
+ }
546
+
547
+ function mdInline(text) {
548
+ if (text == null) return '';
549
+ return String(text).replace(/\r?\n/g, ' ').trim();
550
+ }
551
+
552
+ function mdTableCell(text) {
553
+ if (text == null) return '';
554
+ return String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>').trim();
555
+ }
556
+
557
+ function formatStepDuration(value) {
558
+ if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) return '';
559
+ if (value < 1000) return `${value}ms`;
560
+ const seconds = Math.floor(value / 1000);
561
+ const ms = value % 1000;
562
+ if (ms === 0) return `${seconds}s`;
563
+ return `${seconds}s ${ms}ms`;
564
+ }
565
+
566
+ function computeStats(tests) {
567
+ const stats = { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0, flaky: 0 };
568
+ if (!Array.isArray(tests)) return stats;
569
+
570
+ for (const test of tests) {
571
+ stats.total += 1;
572
+ let status = normalizeStatus(test.status);
573
+ if ((status === 'skipped' || status === 'pending') && test.meta?.todo) {
574
+ status = 'todo';
575
+ }
576
+ if (status === 'pending') status = 'todo';
577
+
578
+ if (status === 'passed') stats.passed += 1;
579
+ else if (status === 'failed') stats.failed += 1;
580
+ else if (status === 'skipped') stats.skipped += 1;
581
+ else if (status === 'todo') stats.todo += 1;
582
+
583
+ if (test.flaky === true) stats.flaky += 1;
584
+ }
585
+ return stats;
586
+ }
587
+
588
+ function computeRetries(test) {
589
+ const fromObj = test.retries;
590
+ let retryCount = 0;
591
+ let flaky = Boolean(test.flaky);
592
+
593
+ if (fromObj && typeof fromObj === 'object') {
594
+ if (typeof fromObj.retryCount === 'number') retryCount = fromObj.retryCount;
595
+ if (Array.isArray(fromObj.attempts)) {
596
+ retryCount = Math.max(retryCount, fromObj.attempts.length - 1);
597
+ }
598
+ if (fromObj.passedAfterRetries) flaky = true;
599
+ }
600
+
601
+ if (test.meta && typeof test.meta.retryCount === 'number') {
602
+ retryCount = Math.max(retryCount, test.meta.retryCount);
603
+ }
604
+
605
+ return { retryCount, flaky };
606
+ }
607
+
608
+ function normalizeStatus(value) {
609
+ const s = String(value || '').toLowerCase();
610
+ if (s === 'pending') return 'pending';
611
+ if (!s) return 'unknown';
612
+ return s;
613
+ }
614
+
615
+ function testExecutionSumTime(tests) {
616
+ if (!Array.isArray(tests)) return '0h 0m 0s 0ms';
617
+ const totalMs = tests.reduce((sum, test) => {
618
+ if (typeof test.run_time === 'number' && !Number.isNaN(test.run_time)) {
619
+ return sum + test.run_time;
620
+ }
621
+ return sum;
622
+ }, 0);
623
+ return formatDuration(totalMs);
624
+ }
625
+
626
+ function formatDuration(duration) {
627
+ const ms = duration % 1000;
628
+ let rest = (duration - ms) / 1000;
629
+ const seconds = rest % 60;
630
+ rest = (rest - seconds) / 60;
631
+ const minutes = rest % 60;
632
+ const hours = (rest - minutes) / 60;
633
+ return `${hours}h ${minutes}m ${seconds}s ${ms}ms`;
634
+ }
635
+
636
+ function getCurrentDateTimeFormatted() {
637
+ const d = new Date();
638
+ const pad = n => String(n).padStart(2, '0');
639
+ const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
640
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
641
+ return `(${date} ${time})`;
642
+ }
643
+
644
+ function aggregateTestRetries(tests) {
645
+ if (!Array.isArray(tests) || tests.length === 0) return tests || [];
646
+
647
+ const grouped = new Map();
648
+ for (const t of tests) {
649
+ const rid = t?.rid || t?.meta?.rid || t?.meta?.RID || t?.meta?.runRid || t?.meta?.testRid;
650
+ let key;
651
+ if (rid) {
652
+ key = `rid:${rid}`;
653
+ } else {
654
+ key = `ft:${t?.file || ''}|${t?.title || ''}`;
655
+ }
656
+ if (!grouped.has(key)) grouped.set(key, []);
657
+ grouped.get(key).push(t);
658
+ }
659
+
660
+ const aggregated = [];
661
+ grouped.forEach(group => {
662
+ if (group.length === 1) {
663
+ aggregated.push(group[0]);
664
+ return;
665
+ }
666
+
667
+ const attemptsOnly = group.filter(x => x && x.status);
668
+ let base;
669
+ if (attemptsOnly.length) {
670
+ base = attemptsOnly[attemptsOnly.length - 1];
671
+ } else {
672
+ base = group[group.length - 1];
673
+ }
674
+
675
+ const allFiles = [];
676
+ const allArtifacts = [];
677
+ const allMetaAttachments = [];
678
+ const allManual = [];
679
+
680
+ for (const x of group) {
681
+ if (Array.isArray(x?.files)) allFiles.push(...x.files);
682
+ if (Array.isArray(x?.artifacts)) allArtifacts.push(...x.artifacts);
683
+ if (Array.isArray(x?.meta?.attachments)) allMetaAttachments.push(...x.meta.attachments);
684
+ if (Array.isArray(x?.manuallyAttachedArtifacts)) allManual.push(...x.manuallyAttachedArtifacts);
685
+ }
686
+
687
+ const attempts = attemptsOnly.map(a => ({
688
+ status: normalizeStatus(a.status),
689
+ duration: a.run_time || a.time || 0,
690
+ }));
691
+
692
+ const retryCount = Math.max(0, attempts.length - 1);
693
+ const hadFailures = attempts.slice(0, -1).some(a => a.status === 'failed');
694
+ const finalStatus = normalizeStatus(base.status);
695
+ const passedAfterRetries = finalStatus === 'passed' && hadFailures;
696
+
697
+ const merged = merge({}, base);
698
+ if (allFiles.length) merged.files = allFiles;
699
+ if (allArtifacts.length) merged.artifacts = allArtifacts;
700
+ merged.meta = merged.meta || {};
701
+ if (allMetaAttachments.length) {
702
+ merged.meta.attachments = [...(merged.meta.attachments || []), ...allMetaAttachments];
703
+ }
704
+ if (allManual.length) {
705
+ merged.manuallyAttachedArtifacts = [...(merged.manuallyAttachedArtifacts || []), ...allManual];
706
+ }
707
+
708
+ merged.retries = { retryCount, attempts, hadFailures, passedAfterRetries, finalStatus };
709
+ merged.flaky = Boolean(passedAfterRetries || merged.meta?.flaky || merged.meta?.isFlaky);
710
+
711
+ aggregated.push(merged);
712
+ });
713
+
714
+ return aggregated;
715
+ }
716
+
717
+ const SENSITIVE_PATTERNS = [/TOKEN/, /SECRET/, /PASSWORD/, /KEY/, /^TESTOMATIO$/];
718
+
719
+ function isSensitiveVarName(name) {
720
+ return SENSITIVE_PATTERNS.some(re => re.test(name));
721
+ }
722
+
723
+ function collectEnvironmentVariables() {
724
+ const groups = { testomatio: {}, s3: {} };
725
+
726
+ for (const [name, value] of Object.entries(process.env)) {
727
+ if (value === undefined) continue;
728
+
729
+ let group = null;
730
+ if (name === 'TESTOMATIO' || name.startsWith('TESTOMATIO_')) group = 'testomatio';
731
+ else if (name.startsWith('S3_')) group = 's3';
732
+ if (!group) continue;
733
+
734
+ let displayValue = value;
735
+ if (isSensitiveVarName(name)) displayValue = '***';
736
+
737
+ groups[group][name] = { value: displayValue, isSet: true };
738
+ }
739
+
740
+ return groups;
741
+ }
742
+
743
+ export default MarkdownPipe;