@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.
Files changed (41) hide show
  1. package/README.md +10 -10
  2. package/lib/adapter/playwright.d.ts +2 -12
  3. package/lib/adapter/playwright.js +15 -72
  4. package/lib/adapter/utils/playwright.d.ts +25 -0
  5. package/lib/adapter/utils/playwright.js +123 -0
  6. package/lib/adapter/vitest.js +2 -1
  7. package/lib/bin/cli.js +1 -1
  8. package/lib/data-storage.d.ts +1 -1
  9. package/lib/data-storage.js +1 -0
  10. package/lib/pipe/debug.js +1 -1
  11. package/lib/pipe/html.d.ts +2 -3
  12. package/lib/pipe/html.js +745 -37
  13. package/lib/pipe/testomatio.js +13 -2
  14. package/lib/reporter-functions.d.ts +36 -11
  15. package/lib/reporter-functions.js +67 -25
  16. package/lib/reporter.d.ts +42 -86
  17. package/lib/services/artifacts.d.ts +1 -1
  18. package/lib/services/key-values.d.ts +1 -1
  19. package/lib/services/links.d.ts +1 -1
  20. package/lib/services/logger.d.ts +1 -1
  21. package/lib/template/testomatio-old.hbs +1421 -0
  22. package/lib/template/testomatio.hbs +3200 -1157
  23. package/lib/utils/log-formatter.d.ts +2 -1
  24. package/lib/utils/log-formatter.js +8 -4
  25. package/package.json +5 -2
  26. package/src/adapter/playwright.js +15 -79
  27. package/src/adapter/utils/playwright.js +121 -0
  28. package/src/adapter/vitest.js +2 -1
  29. package/src/bin/cli.js +1 -1
  30. package/src/data-storage.js +1 -0
  31. package/src/pipe/debug.js +1 -1
  32. package/src/pipe/html.js +844 -38
  33. package/src/pipe/testomatio.js +13 -2
  34. package/src/reporter-functions.js +68 -28
  35. package/src/template/testomatio-old.hbs +1421 -0
  36. package/src/template/testomatio.hbs +3200 -1157
  37. package/src/utils/log-formatter.js +9 -4
  38. package/types/types.d.ts +29 -5
  39. package/lib/services/labels.d.ts +0 -0
  40. package/lib/services/labels.js +0 -0
  41. 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').RunData} test - object which includes each test entry.
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 (!test.status) return;
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
- tests.forEach(test => {
133
- // steps could be an array or a string
134
- test.steps = Array.isArray(test.steps)
135
- ? (test.steps = test.steps
136
- .map(step => formatStep(step))
137
- .flat()
138
- .join('\n'))
139
- : test.steps;
140
-
141
- if (!test.message?.trim()) {
142
- test.message = "This test has no 'message' code";
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
- if (!test.files?.length) {
154
- test.files = 'This test has no files';
155
- }
223
+ test.artifacts = normalizeArtifacts(test);
156
224
 
157
- if (!test.steps?.trim()) {
158
- test.steps = "This test has no 'steps' code";
159
- } else {
160
- test.steps = removeAnsiColorCodes(test.steps);
161
- }
225
+ const allPossibleArtifacts = [
226
+ ...(test.artifacts || []),
227
+ ...(test.manuallyAttachedArtifacts || []),
228
+ ...(test.files || []),
229
+ ...(test.meta?.attachments || []),
230
+ ];
162
231
 
163
- // TODO: future-proof: currently there is no need to display Artifacts and Metadata in HTML
164
- test.artifacts = test.artifacts || [];
165
- test.meta = test.meta || {};
166
- // TODO: u can added an additional test values to this checks in the future
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(tests),
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
438
+ }
439
+
329
440
  /**
330
- * Removes ANSI color codes and converts newline characters to HTML line breaks in a given string.
331
- * @param {string} str - The input string containing ANSI color codes.
332
- * @returns {string} - The updated string with removed ANSI color codes and replaced newline characters.
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 removeAnsiColorCodes(str) {
335
- let updatedStr = str.replace(ansiRegExp(), '');
336
- updatedStr = updatedStr.replace(/\n/g, '<br>');
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
- return updatedStr;
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(/&gt;/gi, '>')
547
+ .replace(/&lt;/gi, '<')
548
+ .replace(/&amp;/gi, '&')
549
+ .replace(/&quot;/gi, '"')
550
+ .replace(/&#39;/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*(?:>|&gt;|[⏩►])\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*(?:>|&gt;|[⏩►])\s*(.+?)\s*$/i);
781
+ if (stepMatch) {
782
+ steps.push(stepMatch[1].trim());
783
+ continue;
784
+ }
785
+
786
+ const stepWithLabel = cleanedLine.match(/^\s*(?:>|&gt;|[⏩►]\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;