@testomatio/reporter 2.3.9-beta-bin-fix → 2.4.0

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 (62) hide show
  1. package/README.md +3 -2
  2. package/lib/adapter/codecept.js +12 -9
  3. package/lib/bin/cli.js +40 -11
  4. package/lib/bin/reportXml.js +5 -2
  5. package/lib/client.d.ts +1 -11
  6. package/lib/client.js +57 -152
  7. package/lib/data-storage.d.ts +1 -1
  8. package/lib/helpers.d.ts +1 -0
  9. package/lib/helpers.js +4 -0
  10. package/lib/junit-adapter/csharp.d.ts +0 -1
  11. package/lib/junit-adapter/csharp.js +43 -7
  12. package/lib/junit-adapter/nunit-parser.d.ts +82 -0
  13. package/lib/junit-adapter/nunit-parser.js +433 -0
  14. package/lib/pipe/bitbucket.js +5 -5
  15. package/lib/pipe/coverage.d.ts +82 -0
  16. package/lib/pipe/coverage.js +373 -0
  17. package/lib/pipe/gitlab.js +4 -4
  18. package/lib/pipe/index.js +2 -0
  19. package/lib/pipe/testomatio.d.ts +3 -2
  20. package/lib/pipe/testomatio.js +44 -18
  21. package/lib/reporter-functions.js +14 -12
  22. package/lib/reporter.d.ts +31 -21
  23. package/lib/reporter.js +40 -5
  24. package/lib/services/artifacts.d.ts +1 -1
  25. package/lib/services/key-values.d.ts +1 -1
  26. package/lib/services/links.d.ts +1 -1
  27. package/lib/services/logger.d.ts +1 -1
  28. package/lib/uploader.js +4 -0
  29. package/lib/utils/log-formatter.d.ts +28 -0
  30. package/lib/utils/log-formatter.js +127 -0
  31. package/lib/utils/pipe_utils.d.ts +15 -0
  32. package/lib/utils/pipe_utils.js +44 -2
  33. package/lib/utils/utils.d.ts +6 -0
  34. package/lib/utils/utils.js +260 -25
  35. package/lib/xmlReader.d.ts +32 -26
  36. package/lib/xmlReader.js +121 -52
  37. package/package.json +12 -7
  38. package/src/adapter/codecept.js +19 -19
  39. package/src/adapter/mocha.js +1 -1
  40. package/src/adapter/playwright.js +2 -2
  41. package/src/bin/cli.js +51 -13
  42. package/src/bin/reportXml.js +5 -2
  43. package/src/client.js +69 -130
  44. package/src/helpers.js +1 -0
  45. package/src/junit-adapter/csharp.js +48 -6
  46. package/src/junit-adapter/nunit-parser.js +474 -0
  47. package/src/pipe/bitbucket.js +5 -5
  48. package/src/pipe/coverage.js +440 -0
  49. package/src/pipe/debug.js +1 -2
  50. package/src/pipe/gitlab.js +4 -4
  51. package/src/pipe/index.js +2 -0
  52. package/src/pipe/testomatio.js +109 -85
  53. package/src/reporter-functions.js +15 -12
  54. package/src/reporter.js +6 -4
  55. package/src/services/links.js +1 -1
  56. package/src/uploader.js +5 -0
  57. package/src/utils/log-formatter.js +113 -0
  58. package/src/utils/pipe_utils.js +52 -3
  59. package/src/utils/utils.js +277 -22
  60. package/src/xmlReader.js +144 -46
  61. package/types/types.d.ts +364 -0
  62. package/types/vitest.types.d.ts +93 -0
@@ -6,6 +6,7 @@ import isValid from 'is-valid-path';
6
6
  import createDebugMessages from 'debug';
7
7
  import os from 'os';
8
8
  import { fileURLToPath } from 'url';
9
+ import { execSync } from 'child_process';
9
10
 
10
11
  const debug = createDebugMessages('@testomatio/reporter:util');
11
12
 
@@ -58,6 +59,21 @@ const validateSuiteId = suiteId => {
58
59
  return match ? match[0] : null;
59
60
  };
60
61
 
62
+ /**
63
+ * Gets current git commit SHA
64
+ * @returns {String|null} git commit SHA or null if not available
65
+ */
66
+ const getGitCommitSha = () => {
67
+ try {
68
+ const sha = execSync('git rev-parse --short HEAD', {
69
+ stdio: ['ignore', 'pipe', 'ignore']
70
+ }).toString().trim();
71
+ return sha || null;
72
+ } catch (error) {
73
+ return null;
74
+ }
75
+ };
76
+
61
77
  const ansiRegExp = () => {
62
78
  const pattern = [
63
79
  '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
@@ -76,21 +92,29 @@ const isValidUrl = s => {
76
92
  }
77
93
  };
78
94
 
79
- const fileMatchRegex = /file:(\/+(?:[A-Za-z]:[\\/]|\/)?[^\s]*?\.(png|avi|webm|jpg|html|txt))/gi;
95
+ const fileMatchRegex = /file:(\/*)([A-Za-z]:[\\/].*?|\/.*?)\.(png|avi|webm|jpg|html|txt)/gi;
80
96
 
81
97
  const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
82
- const files = Array.from(stack.matchAll(fileMatchRegex))
83
- .map(f => f[1].trim())
98
+ let files = Array.from(stack.matchAll(fileMatchRegex))
99
+ .map(match => {
100
+ // match[0] is full match, match[1] is slashes, match[2] is path, match[3] is extension
101
+ const slashes = match[1] || '';
102
+ const path = match[2];
103
+ const extension = match[3];
104
+ return `${slashes}${path}.${extension}`;
105
+ })
106
+ .map(f => f.trim())
84
107
  .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
85
108
  .map(f => {
86
- // Convert Windows paths to Linux paths for testing purposes
87
- if (f.match(/^[A-Za-z]:[\\\/]/)) {
88
- // Convert Windows path to Linux equivalent for test scenarios
89
- return f.replace(/^[A-Za-z]:[\\\/]/, '/').replace(/\\/g, '/');
90
- }
91
- return f;
109
+ // Normalize path separators for cross-platform compatibility
110
+ return f.replace(/\\/g, '/');
92
111
  });
93
112
 
113
+ // If we're not checking file existence, remove Windows drive letters for consistency
114
+ if (!checkExists) {
115
+ files = files.map(f => f.replace(/^([A-Za-z]):/, ''));
116
+ }
117
+
94
118
  debug('Found files in stack trace: ', files);
95
119
 
96
120
  return files.filter(f => {
@@ -105,21 +129,92 @@ const fetchSourceCodeFromStackTrace = (stack = '') => {
105
129
  const stackLines = stack
106
130
  .split('\n')
107
131
  .filter(l => l.includes(':'))
108
- // .map(l => l.match(/\[(.*?)\]/)?.[1] || l) // minitest format
109
- // .map(l => l.split(':')[0])
110
132
  .map(l => l.trim())
111
- .map(l => l.split(' ').find(p => p.includes(':')) || '')
112
- .filter(l => isValid(l?.split(':')[0]))
133
+ .map(l => {
134
+ // Remove 'at ' prefix if present
135
+ if (l.startsWith('at ')) {
136
+ return l.substring(3).trim();
137
+ }
138
+ // Find the part that looks like a file path with line number
139
+ const parts = l.split(' ');
140
+ for (const part of parts) {
141
+ // Check if this part has a colon
142
+ if (part.includes(':')) {
143
+ // For Windows paths, we need to handle drive letters (C:, D:, etc.)
144
+ // Split by colon but keep drive letter with the path
145
+ const colonParts = part.split(':');
146
+ let filePath;
147
+
148
+ // Check if first part is a Windows drive letter (single letter)
149
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
150
+ // Windows path like D:\path\file.php:24
151
+ // Reconstruct as D:\path\file.php
152
+ filePath = colonParts[0] + ':' + colonParts[1];
153
+ } else {
154
+ // Unix path like /path/file.php:24
155
+ filePath = colonParts[0];
156
+ }
157
+
158
+ // Only consider it valid if the file exists
159
+ if (fs.existsSync(filePath)) {
160
+ return part;
161
+ }
162
+ }
163
+ }
164
+ // If no valid file path found in parts, return the whole line
165
+ // It will be filtered out later if it's not a valid file path
166
+ return parts.find(p => p.includes(':')) || l;
167
+ })
168
+ .filter(l => {
169
+ // Extract file path from line (accounting for Windows drive letters)
170
+ if (!l) return false;
171
+ const colonParts = l.split(':');
172
+ let filePath;
173
+
174
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
175
+ // Windows path
176
+ filePath = colonParts[0] + ':' + colonParts[1];
177
+ } else {
178
+ // Unix path
179
+ filePath = colonParts[0];
180
+ }
181
+
182
+ return filePath && fs.existsSync(filePath);
183
+ })
113
184
 
114
185
  // // filter out 3rd party libs
115
186
  .filter(l => !l?.includes(`vendor${sep}`))
116
187
  .filter(l => !l?.includes(`node_modules${sep}`))
117
- .filter(l => fs.existsSync(l.split(':')[0]))
118
- .filter(l => fs.lstatSync(l.split(':')[0]).isFile());
188
+ .filter(l => {
189
+ // Extract file path for final check (accounting for Windows drive letters)
190
+ const colonParts = l.split(':');
191
+ let filePath;
192
+
193
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
194
+ filePath = colonParts[0] + ':' + colonParts[1];
195
+ } else {
196
+ filePath = colonParts[0];
197
+ }
198
+
199
+ return fs.lstatSync(filePath).isFile();
200
+ });
119
201
 
120
202
  if (!stackLines.length) return '';
121
203
 
122
- const [file, line] = stackLines[0].split(':');
204
+ // Extract file and line number (accounting for Windows drive letters)
205
+ const firstLine = stackLines[0];
206
+ const colonParts = firstLine.split(':');
207
+ let file, line;
208
+
209
+ if (colonParts.length >= 3 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
210
+ // Windows path like D:\path\file.php:24
211
+ file = colonParts[0] + ':' + colonParts[1];
212
+ line = colonParts[2];
213
+ } else {
214
+ // Unix path like /path/file.php:24
215
+ file = colonParts[0];
216
+ line = colonParts[1];
217
+ }
123
218
 
124
219
  const prepend = 3;
125
220
  const source = fetchSourceCode(fs.readFileSync(file).toString(), { line, prepend, limit: 7 });
@@ -139,6 +234,8 @@ export const TEST_ID_REGEX = /@T([\w\d]{8})/;
139
234
  export const SUITE_ID_REGEX = /@S([\w\d]{8})/;
140
235
 
141
236
  const fetchIdFromCode = (code, opts = {}) => {
237
+ if (!code) return null;
238
+
142
239
  const comments = code
143
240
  .split('\n')
144
241
  .map(l => l.trim())
@@ -180,8 +277,65 @@ const fetchSourceCode = (contents, opts = {}) => {
180
277
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
181
278
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
182
279
  } else if (opts.lang === 'csharp') {
183
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
184
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
280
+ // Find the method declaration line
281
+ let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
282
+
283
+ if (methodLineIndex === -1) {
284
+ methodLineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
285
+ }
286
+
287
+ if (methodLineIndex === -1) {
288
+ methodLineIndex = lines.findIndex(l => l.includes(`${title}(`));
289
+ }
290
+
291
+ // If found, scan upwards to find [TestCase], [Test] attributes and XML comments
292
+ if (methodLineIndex !== -1) {
293
+ lineIndex = methodLineIndex;
294
+
295
+ // Scan upwards to find the start of attributes and comments
296
+ for (let i = methodLineIndex - 1; i >= 0; i--) {
297
+ const trimmedLine = lines[i].trim();
298
+
299
+ // Include [TestCase], [Test], and other attributes
300
+ if (trimmedLine.startsWith('[')) {
301
+ lineIndex = i;
302
+ continue;
303
+ }
304
+
305
+ // Include XML documentation comments
306
+ if (trimmedLine.startsWith('///')) {
307
+ lineIndex = i;
308
+ continue;
309
+ }
310
+
311
+ // Stop at empty lines (with some tolerance)
312
+ if (trimmedLine === '') {
313
+ // Check if next non-empty line is an attribute or comment
314
+ let hasMoreAttributes = false;
315
+ for (let j = i - 1; j >= 0; j--) {
316
+ const nextTrimmed = lines[j].trim();
317
+ if (nextTrimmed === '') continue;
318
+ if (nextTrimmed.startsWith('[') || nextTrimmed.startsWith('///')) {
319
+ hasMoreAttributes = true;
320
+ lineIndex = j;
321
+ }
322
+ break;
323
+ }
324
+ if (!hasMoreAttributes) break;
325
+ continue;
326
+ }
327
+
328
+ // Stop at other method declarations or class-level elements
329
+ if (
330
+ trimmedLine.includes('public ') ||
331
+ trimmedLine.includes('private ') ||
332
+ trimmedLine.includes('protected ') ||
333
+ trimmedLine.includes('internal ')
334
+ ) {
335
+ if (!trimmedLine.startsWith('[')) break;
336
+ }
337
+ }
338
+ }
185
339
  } else {
186
340
  lineIndex = lines.findIndex(l => l.includes(title));
187
341
  }
@@ -191,11 +345,31 @@ const fetchSourceCode = (contents, opts = {}) => {
191
345
  lineIndex -= opts.prepend;
192
346
  }
193
347
 
194
- if (lineIndex) {
348
+ if (lineIndex !== -1 && lineIndex !== undefined) {
195
349
  const result = [];
350
+ let braceDepth = 0; // Track brace depth for C# methods
351
+ let methodStartFound = false; // Flag to indicate we've found the method opening brace
352
+
196
353
  for (let i = lineIndex; i < lineIndex + limit; i++) {
197
354
  if (lines[i] === undefined) continue;
198
355
 
356
+ // Track brace depth for C# to stop after method closes
357
+ if (opts.lang === 'csharp') {
358
+ const line = lines[i];
359
+ // Count opening and closing braces
360
+ const openBraces = (line.match(/\{/g) || []).length;
361
+ const closeBraces = (line.match(/\}/g) || []).length;
362
+
363
+ if (openBraces > 0) methodStartFound = true;
364
+ braceDepth += openBraces - closeBraces;
365
+
366
+ // If we've started the method and depth returns to 0, method is complete
367
+ if (methodStartFound && braceDepth === 0 && closeBraces > 0) {
368
+ // Don't include the closing brace - just break
369
+ break;
370
+ }
371
+ }
372
+
199
373
  if (i > lineIndex + 2 && !opts.prepend) {
200
374
  // annotation
201
375
  if (opts.lang === 'php' && lines[i].trim().startsWith('#[')) break;
@@ -216,7 +390,25 @@ const fetchSourceCode = (contents, opts = {}) => {
216
390
  if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
217
391
  if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
218
392
  if (opts.lang === 'java' && lines[i].includes(' class ')) break;
393
+ // For C#, additional checks if brace tracking didn't stop us
394
+ if (opts.lang === 'csharp') {
395
+ const trimmed = lines[i].trim();
396
+ // Stop at attribute that marks beginning of next test (but not if we're still in the current method)
397
+ if (trimmed.match(/^\[(Test|TestCase|Theory|Fact)/) && methodStartFound && braceDepth === 0) break;
398
+ // Stop at XML documentation comments that belong to next method
399
+ if (trimmed.startsWith('///') && methodStartFound && braceDepth === 0) break;
400
+ // Stop at another method declaration (but not if we're still in the current method)
401
+ if (
402
+ trimmed.match(/^\s*(public|private|protected|internal)\s+(\w+|async\s+\w+)\s+\w+\s*\(/) &&
403
+ methodStartFound &&
404
+ braceDepth === 0
405
+ )
406
+ break;
407
+ // Stop at class declaration
408
+ if (trimmed.includes(' class ') && trimmed.includes('public')) break;
409
+ }
219
410
  }
411
+
220
412
  result.push(lines[i]);
221
413
  }
222
414
  return result.join('\n');
@@ -429,10 +621,71 @@ function transformEnvVarToBoolean(value) {
429
621
  }
430
622
 
431
623
  function truncate(s, size = 255) {
432
- if (s.toString().trim().length < size) {
433
- return s.toString();
624
+ if (s === undefined || s === null) {
625
+ return '';
626
+ }
627
+ const str = s.toString();
628
+ if (str.trim().length < size) {
629
+ return str;
630
+ }
631
+ return `${str.substring(0, size)}...`;
632
+ }
633
+
634
+ function applyFilter(command, tests) {
635
+ if (!tests || !tests.length) return command;
636
+
637
+ const lower = (command || '').toLowerCase();
638
+ const regexPattern = `(${tests.join('|')})`;
639
+
640
+ if (lower.includes('jest')) {
641
+ return `${command} --testNamePattern ${regexPattern}`;
642
+ }
643
+
644
+ if (lower.includes('cypress')) {
645
+ const grepValue = tests.join(',');
646
+ const baseEnv = {
647
+ grep: grepValue,
648
+ grepFilterSpecs: true,
649
+ grepOmitFiltered: true,
650
+ };
651
+
652
+ if (command.includes('--env')) {
653
+ return command.replace(
654
+ /--env\s+(['"]?)([^\s'"]+)\1/,
655
+ (match, quote, envVal) => {
656
+ const existingEnv = {};
657
+
658
+ if (envVal.startsWith('{') && envVal.endsWith('}')) {
659
+ try {
660
+ Object.assign(existingEnv, JSON.parse(envVal));
661
+ } catch (e) {
662
+ }
663
+ }
664
+
665
+ if (!Object.keys(existingEnv).length) {
666
+ envVal.split(',').forEach((pair) => {
667
+ const [k, v] = pair.split('=');
668
+ if (!k) return;
669
+
670
+ if (v === 'true') existingEnv[k] = true;
671
+ else if (v === 'false') existingEnv[k] = false;
672
+ else existingEnv[k] = v;
673
+ });
674
+ }
675
+
676
+ const merged = { ...existingEnv, ...baseEnv };
677
+ const json = JSON.stringify(merged);
678
+
679
+ return `--env ${json}`;
680
+ },
681
+ );
682
+ }
683
+
684
+ const json = JSON.stringify(baseEnv);
685
+ return `${command} --env ${json}`;
434
686
  }
435
- return `${s.toString().substring(0, size)}...`;
687
+
688
+ return `${command} --grep ${regexPattern}`;
436
689
  }
437
690
 
438
691
  export {
@@ -449,6 +702,7 @@ export {
449
702
  foundedTestLog,
450
703
  formatStep,
451
704
  getCurrentDateTime,
705
+ getGitCommitSha,
452
706
  getTestomatIdFromTestTitle,
453
707
  humanize,
454
708
  isValidUrl,
@@ -460,4 +714,5 @@ export {
460
714
  testRunnerHelper,
461
715
  transformEnvVarToBoolean,
462
716
  validateSuiteId,
717
+ applyFilter
463
718
  };
package/src/xmlReader.js CHANGED
@@ -6,6 +6,7 @@ import { XMLParser } from 'fast-xml-parser';
6
6
  import { APP_PREFIX, STATUS } from './constants.js';
7
7
  import { randomUUID } from 'crypto';
8
8
  import { fileURLToPath } from 'url';
9
+ import { NUnitXmlParser } from './junit-adapter/nunit-parser.js';
9
10
  import {
10
11
  fetchFilesFromStackTrace,
11
12
  fetchIdFromOutput,
@@ -14,6 +15,7 @@ import {
14
15
  fetchIdFromCode,
15
16
  humanize,
16
17
  TEST_ID_REGEX,
18
+ transformEnvVarToBoolean,
17
19
  } from './utils/utils.js';
18
20
  import { pipesFactory } from './pipe/index.js';
19
21
  import adapterFactory from './junit-adapter/index.js';
@@ -35,6 +37,7 @@ const {
35
37
  TESTOMATIO_ENV,
36
38
  TESTOMATIO_RUN,
37
39
  TESTOMATIO_MARK_DETACHED,
40
+ TESTOMATIO_LEGACY_NUNIT,
38
41
  } = process.env;
39
42
 
40
43
  const options = {
@@ -75,6 +78,11 @@ class XmlReader {
75
78
  this.stats.language = opts.lang?.toLowerCase();
76
79
  this.uploader = new S3Uploader();
77
80
 
81
+ // Enhanced NUnit parsing - enabled by default for NUnit XML
82
+ // Can be disabled via opts.enhancedNunit = false or TESTOMATIO_LEGACY_NUNIT=1
83
+ this.enhancedNunit = !transformEnvVarToBoolean(TESTOMATIO_LEGACY_NUNIT);
84
+ this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled
85
+
78
86
  // @ts-ignore
79
87
  const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
80
88
  this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version;
@@ -126,7 +134,8 @@ class XmlReader {
126
134
  }
127
135
 
128
136
  processJUnit(jsonSuite) {
129
- const { testsuite, name, tests, failures, errors } = jsonSuite;
137
+ const { testsuite, name, failures, errors } = jsonSuite;
138
+ const tests = testsuite?.tests || jsonSuite.tests;
130
139
 
131
140
  reduceOptions.preferClassname = this.stats.language === 'python';
132
141
  const resultTests = processTestSuite(testsuite);
@@ -157,6 +166,14 @@ class XmlReader {
157
166
  }
158
167
 
159
168
  processNUnit(jsonSuite) {
169
+ // Use enhanced NUnit parser if enabled and this is actually NUnit XML
170
+ if (this.enhancedNunit && this.isNUnitXml(jsonSuite)) {
171
+ debug('Using enhanced NUnit parser');
172
+ return this.processNUnitEnhanced(jsonSuite);
173
+ }
174
+
175
+ // Fallback to legacy parser for backward compatibility
176
+ debug('Using legacy NUnit parser');
160
177
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
161
178
 
162
179
  reduceOptions.preferClassname = this.stats.language === 'python';
@@ -175,63 +192,71 @@ class XmlReader {
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * Check if the XML is actually NUnit format (has test-suite hierarchy)
197
+ * @param {Object} jsonSuite - Parsed XML suite object
198
+ * @returns {boolean} - True if this is NUnit XML format
199
+ */
200
+ isNUnitXml(jsonSuite) {
201
+ // NUnit XML has test-suite elements with type attributes
202
+ if (jsonSuite['test-suite']) {
203
+ const testSuite = Array.isArray(jsonSuite['test-suite']) ? jsonSuite['test-suite'][0] : jsonSuite['test-suite'];
204
+
205
+ // Check for NUnit-specific test-suite types
206
+ return (
207
+ testSuite &&
208
+ testSuite.type &&
209
+ ['Assembly', 'TestSuite', 'TestFixture', 'ParameterizedMethod'].includes(testSuite.type)
210
+ );
211
+ }
212
+ return false;
213
+ }
214
+
215
+ processNUnitEnhanced(jsonSuite) {
216
+ debug('Processing NUnit XML with enhanced parser');
217
+
218
+ try {
219
+ const nunitParser = new NUnitXmlParser({
220
+ groupParameterized: this.groupParameterized,
221
+ ...this.opts,
222
+ });
223
+
224
+ const result = nunitParser.parseTestRun(jsonSuite);
225
+
226
+ // Add parsed tests to our collection
227
+ this.tests = this.tests.concat(result.tests);
228
+
229
+ debug(`Enhanced NUnit parser processed ${result.tests.length} tests`);
230
+
231
+ return result;
232
+ } catch (error) {
233
+ debug('Enhanced NUnit parser failed, falling back to legacy parser:', error.message);
234
+ console.warn(`${APP_PREFIX} Enhanced NUnit parsing failed, using legacy parser: ${error.message}`);
235
+
236
+ // Fallback to legacy parser
237
+ this.enhancedNunit = false;
238
+ return this.processNUnit(jsonSuite);
239
+ }
240
+ }
241
+
178
242
  processTRX(jsonSuite) {
179
243
  let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest;
180
244
  if (!Array.isArray(defs)) defs = [defs].filter(d => !!d);
181
245
 
182
- const tests =
183
- defs.map(td => {
184
- const title = td.name.replace(/\(.*?\)/, '').trim();
185
- let example = td.name.match(/\((.*?)\)/);
186
- if (example) example = { ...example[1].split(',') };
187
- const suite = td.TestMethod.className.split(', ')[0].split('.');
188
- const suite_title = suite.pop();
189
- return {
190
- title,
191
- example,
192
- file: suite.join('/'),
193
- description: td.Description,
194
- suite_title,
195
- id: td.Execution.id,
196
- };
197
- }) || [];
246
+ // Parse test definitions
247
+ const tests = defs.map(td => this._parseTRXTestDefinition(td));
198
248
 
249
+ // Parse test results
199
250
  let result = jsonSuite?.TestRun?.Results?.UnitTestResult;
200
251
  if (!Array.isArray(result)) result = [result].filter(d => !!d);
201
252
 
202
- const results = result.map(td => ({
203
- id: td.executionId,
204
- // seconds are used in junit reports, but ms are used by testomatio
205
- run_time: parseFloat(td.duration) * 1000,
206
- status: td.outcome,
207
- stack: td.Output.StdOut,
208
- files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
209
- }));
210
-
211
- results.forEach(r => {
212
- const test = tests.find(t => t.id === r.id) || {};
213
- r.suite_title = test.suite_title;
214
- r.title = test.title?.trim();
215
- if (test.code) r.code = test.code;
216
- if (test.description) r.description = test.description;
217
- if (test.example) r.example = test.example;
218
- if (test.file) r.file = test.file;
219
- r.create = true;
220
- r.overwrite = true;
221
- if (r.status === 'Passed') r.status = STATUS.PASSED;
222
- if (r.status === 'Failed') r.status = STATUS.FAILED;
223
- if (r.status === 'Skipped') r.status = STATUS.SKIPPED;
224
- delete r.id;
225
- });
253
+ const results = result.map(td => this._parseTRXTestResult(td, tests));
226
254
 
227
255
  debug(results);
228
256
 
229
257
  const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {};
230
-
231
258
  const failed_count = parseInt(counters.failed, 10) + parseInt(counters.error, 10);
232
-
233
- let status = STATUS.PASSED.toString();
234
- if (failed_count > 0) status = STATUS.FAILED;
259
+ const status = failed_count > 0 ? STATUS.FAILED : STATUS.PASSED.toString();
235
260
 
236
261
  this.tests = results.filter(t => !!t.title);
237
262
 
@@ -246,6 +271,74 @@ class XmlReader {
246
271
  };
247
272
  }
248
273
 
274
+ _parseTRXTestDefinition(td) {
275
+ const title = td.name.replace(/\(.*?\)/, '').trim();
276
+ const exampleMatch = td.name.match(/\((.*?)\)/);
277
+ const example = exampleMatch
278
+ ? {
279
+ ...exampleMatch[1]
280
+ .split(',')
281
+ .map(p => p.trim())
282
+ .filter(p => p !== ''),
283
+ }
284
+ : null;
285
+
286
+ const suite = td.TestMethod.className.split(', ')[0].split('.');
287
+ const suite_title = suite.pop();
288
+
289
+ // Convert namespace to file path for C#
290
+ const file = `${suite.join('/')}.cs`;
291
+
292
+ return {
293
+ title, // Base name without parameters for test import
294
+ example, // Parameters object for parameterized tests
295
+ file, // File path with .cs extension
296
+ description: td.Description,
297
+ suite_title,
298
+ id: td.Execution.id,
299
+ };
300
+ }
301
+
302
+ _parseTRXTestResult(td, tests) {
303
+ const test = tests.find(t => t.id === td.executionId) || {};
304
+
305
+ const result = {
306
+ suite_title: test.suite_title,
307
+ title: test.title?.trim(),
308
+ file: test.file,
309
+ description: test.description,
310
+ code: test.code,
311
+ run_time: parseFloat(td.duration) * 1000,
312
+ stack: td.Output?.StdOut || '',
313
+ files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
314
+ create: true,
315
+ overwrite: true,
316
+ };
317
+
318
+ // Add example for parameterized tests
319
+ if (test.example) {
320
+ result.example = test.example;
321
+ }
322
+
323
+ // Map TRX status to Testomat.io status
324
+ result.status = this._mapTRXStatus(td.outcome);
325
+
326
+ return result;
327
+ }
328
+
329
+ _mapTRXStatus(outcome) {
330
+ switch (outcome) {
331
+ case 'Passed':
332
+ return STATUS.PASSED;
333
+ case 'Failed':
334
+ return STATUS.FAILED;
335
+ case 'Skipped':
336
+ return STATUS.SKIPPED;
337
+ default:
338
+ return STATUS.PASSED;
339
+ }
340
+ }
341
+
249
342
  processXUnit(assemblies) {
250
343
  const tests = [];
251
344
 
@@ -512,7 +605,12 @@ function reduceTestCases(prev, item) {
512
605
 
513
606
  const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
514
607
  if (exampleMatches) {
515
- example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
608
+ example = {
609
+ ...exampleMatches[1]
610
+ .split(',')
611
+ .map(v => v.trim().replace(/[^\w\s-]/g, ''))
612
+ .filter(v => v !== ''),
613
+ };
516
614
  title = title.replace(/\(.*?\)/, '').trim();
517
615
  }
518
616