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