@testomatio/reporter 2.3.0 → 2.3.2-beta.3-xml-import

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.
@@ -1,5 +1,4 @@
1
1
  export default CSharpAdapter;
2
2
  declare class CSharpAdapter extends Adapter {
3
- getFilePath(t: any): string;
4
3
  }
5
4
  import Adapter from './adapter.js';
@@ -7,24 +7,53 @@ const path_1 = __importDefault(require("path"));
7
7
  const adapter_js_1 = __importDefault(require("./adapter.js"));
8
8
  class CSharpAdapter extends adapter_js_1.default {
9
9
  formatTest(t) {
10
- const title = t.title.replace(/\(.*?\)/, '').trim();
11
- const example = t.title.match(/\((.*?)\)/);
12
- if (example)
13
- t.example = { ...example[1].split(',') };
10
+ // Don't override example if it already exists from NUnit XML processing
11
+ // The xmlReader.js already extracts parameters correctly from <arguments>
12
+ if (!t.example) {
13
+ const title = t.title.replace(/\(.*?\)/, '').trim();
14
+ const exampleMatch = t.title.match(/\((.*?)\)/);
15
+ if (exampleMatch) {
16
+ // Keep as array for consistency with NUnit XML processing
17
+ t.example = exampleMatch[1].split(',').map(param => param.trim());
18
+ }
19
+ t.title = title.trim();
20
+ }
14
21
  const suite = t.suite_title.split('.');
15
22
  t.suite_title = suite.pop();
16
23
  t.file = namespaceToFileName(t.file);
17
- t.title = title.trim();
18
24
  return t;
19
25
  }
20
26
  getFilePath(t) {
21
- const fileName = namespaceToFileName(t.file);
27
+ if (!t.file)
28
+ return null;
29
+ // Normalize path separators for cross-platform compatibility
30
+ let filePath = t.file.replace(/\\/g, '/');
31
+ // If file already has .cs extension, use it directly
32
+ if (filePath.endsWith('.cs')) {
33
+ // Make relative path if it's absolute
34
+ if (path_1.default.isAbsolute(filePath)) {
35
+ // Try to find project-relative path
36
+ const cwd = process.cwd().replace(/\\/g, '/');
37
+ if (filePath.startsWith(cwd)) {
38
+ filePath = path_1.default.relative(cwd, filePath).replace(/\\/g, '/');
39
+ }
40
+ }
41
+ return filePath;
42
+ }
43
+ // Convert namespace path to file path
44
+ const fileName = namespaceToFileName(filePath);
22
45
  return fileName;
23
46
  }
24
47
  }
25
48
  module.exports = CSharpAdapter;
26
49
  function namespaceToFileName(fileName) {
50
+ if (!fileName)
51
+ return '';
52
+ // If already a .cs file path, clean it up
53
+ if (fileName.endsWith('.cs')) {
54
+ return fileName.replace(/\\/g, '/');
55
+ }
27
56
  const fileParts = fileName.split('.');
28
57
  fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
29
- return `${fileParts.join(path_1.default.sep)}.cs`;
58
+ return `${fileParts.join('/')}.cs`;
30
59
  }
@@ -172,6 +172,8 @@ exports.fetchSourceCodeFromStackTrace = fetchSourceCodeFromStackTrace;
172
172
  exports.TEST_ID_REGEX = /@T([\w\d]{8})/;
173
173
  exports.SUITE_ID_REGEX = /@S([\w\d]{8})/;
174
174
  const fetchIdFromCode = (code, opts = {}) => {
175
+ if (!code)
176
+ return null;
175
177
  const comments = code
176
178
  .split('\n')
177
179
  .map(l => l.trim())
@@ -214,10 +216,29 @@ const fetchSourceCode = (contents, opts = {}) => {
214
216
  lineIndex = lines.findIndex(l => l.includes(`${title}(`));
215
217
  }
216
218
  else if (opts.lang === 'csharp') {
217
- if (lineIndex === -1)
218
- lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
219
- if (lineIndex === -1)
219
+ // Enhanced C# method detection for NUnit tests
220
+ lineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
221
+ if (lineIndex === -1) {
222
+ lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
223
+ }
224
+ if (lineIndex === -1) {
220
225
  lineIndex = lines.findIndex(l => l.includes(`${title}(`));
226
+ }
227
+ // Look for TestCase or Test attributes above the method
228
+ if (lineIndex === -1) {
229
+ const testAttributeIndex = lines.findIndex((l, index) => {
230
+ if (l.includes('[TestCase') || l.includes('[Test')) {
231
+ // Check next few lines for the method
232
+ const nextLines = lines.slice(index, Math.min(lines.length, index + 5));
233
+ const hasMethod = nextLines.some(nextLine => nextLine.includes(`${title}(`));
234
+ return hasMethod;
235
+ }
236
+ return false;
237
+ });
238
+ if (testAttributeIndex !== -1) {
239
+ lineIndex = testAttributeIndex;
240
+ }
241
+ }
221
242
  }
222
243
  else {
223
244
  lineIndex = lines.findIndex(l => l.includes(title));
@@ -226,7 +247,7 @@ const fetchSourceCode = (contents, opts = {}) => {
226
247
  if (opts.prepend) {
227
248
  lineIndex -= opts.prepend;
228
249
  }
229
- if (lineIndex) {
250
+ if (lineIndex !== -1 && lineIndex !== undefined) {
230
251
  const result = [];
231
252
  for (let i = lineIndex; i < lineIndex + limit; i++) {
232
253
  if (lines[i] === undefined)
@@ -269,6 +290,14 @@ const fetchSourceCode = (contents, opts = {}) => {
269
290
  break;
270
291
  if (opts.lang === 'java' && lines[i].includes(' class '))
271
292
  break;
293
+ if (opts.lang === 'csharp' && lines[i].trim().match(/^\[Test/))
294
+ break;
295
+ if (opts.lang === 'csharp' && lines[i].includes(' public void '))
296
+ break;
297
+ if (opts.lang === 'csharp' && lines[i].includes(' public async Task '))
298
+ break;
299
+ if (opts.lang === 'csharp' && lines[i].includes(' class ') && lines[i].includes('public'))
300
+ break;
272
301
  }
273
302
  result.push(lines[i]);
274
303
  }
@@ -46,7 +46,7 @@ declare class XmlReader {
46
46
  passed_count: number;
47
47
  skipped_count: number;
48
48
  status: string;
49
- tests: any[];
49
+ tests: any;
50
50
  tests_count: number;
51
51
  };
52
52
  processNUnit(jsonSuite: any): {
@@ -77,6 +77,13 @@ declare class XmlReader {
77
77
  skipped_count: number;
78
78
  tests: any[];
79
79
  };
80
+ deduplicateTestsByFQN(tests: any): any[];
81
+ generateFQN(test: any): string;
82
+ generateNormalizedFQN(test: any): string;
83
+ extractAssemblyName(test: any): any;
84
+ extractNamespace(test: any): any;
85
+ extractClassName(test: any): any;
86
+ extractCsFileFromPath(test: any): any;
80
87
  calculateStats(): {};
81
88
  fetchSourceCode(): void;
82
89
  formatTests(): void;
package/lib/xmlReader.js CHANGED
@@ -131,7 +131,18 @@ class XmlReader {
131
131
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
132
132
  reduceOptions.preferClassname = this.stats.language === 'python';
133
133
  const resultTests = processTestSuite(jsonSuite['test-suite']);
134
- this.tests = this.tests.concat(resultTests);
134
+ debug('Raw tests extracted from NUnit XML:', resultTests.length);
135
+ debug('Raw tests:', resultTests.map(t => ({ title: t.title, example: t.example, file: t.file })));
136
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
137
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
138
+ debug('Tests after deduplication:', deduplicatedTests.length);
139
+ debug('Deduplicated tests:', deduplicatedTests.map(t => ({
140
+ title: t.title,
141
+ examples: t.examples,
142
+ example: t.example,
143
+ file: t.file,
144
+ })));
145
+ this.tests = this.tests.concat(deduplicatedTests);
135
146
  return {
136
147
  status: result?.toLowerCase(),
137
148
  create_tests: true,
@@ -139,7 +150,7 @@ class XmlReader {
139
150
  passed_count: parseInt(passed, 10),
140
151
  failed_count: parseInt(failed, 10),
141
152
  skipped_count: parseInt(inconclusive + skipped, 10),
142
- tests: resultTests,
153
+ tests: deduplicatedTests,
143
154
  };
144
155
  }
145
156
  processTRX(jsonSuite) {
@@ -275,6 +286,171 @@ class XmlReader {
275
286
  tests,
276
287
  };
277
288
  }
289
+ deduplicateTestsByFQN(tests) {
290
+ const fqnMap = new Map();
291
+ tests.forEach(test => {
292
+ const fqn = this.generateNormalizedFQN(test);
293
+ if (fqnMap.has(fqn)) {
294
+ const existingTest = fqnMap.get(fqn);
295
+ // For parameterized tests, merge as Examples
296
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
297
+ // Initialize examples array if it doesn't exist
298
+ if (!existingTest.examples) {
299
+ existingTest.examples = [];
300
+ // Add the existing test's example as the first item if it has parameters
301
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
302
+ existingTest.examples.push({
303
+ parameters: existingTest.example,
304
+ status: existingTest.status,
305
+ run_time: existingTest.run_time,
306
+ message: existingTest.message,
307
+ stack: existingTest.stack,
308
+ });
309
+ // Clear the main test's example since it's now in examples array
310
+ delete existingTest.example;
311
+ }
312
+ }
313
+ // Add this test's execution as an example
314
+ existingTest.examples.push({
315
+ parameters: test.example,
316
+ status: test.status,
317
+ run_time: test.run_time,
318
+ message: test.message,
319
+ stack: test.stack,
320
+ });
321
+ // Update the main test status to reflect the worst status
322
+ if (test.status === 'failed' || existingTest.status === 'failed') {
323
+ existingTest.status = 'failed';
324
+ }
325
+ else if (test.status === 'skipped' && existingTest.status !== 'failed') {
326
+ existingTest.status = 'skipped';
327
+ }
328
+ // Update total run time
329
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
330
+ }
331
+ else {
332
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
333
+ if (test.test_id && !existingTest.test_id) {
334
+ existingTest.test_id = test.test_id;
335
+ }
336
+ // Keep the most complete test data
337
+ if (test.stack && !existingTest.stack) {
338
+ existingTest.stack = test.stack;
339
+ }
340
+ if (test.message && !existingTest.message) {
341
+ existingTest.message = test.message;
342
+ }
343
+ }
344
+ // Prefer Test Explorer structure (longer, more complete suite_title)
345
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
346
+ existingTest.suite_title = test.suite_title;
347
+ }
348
+ // Always use the source file path if available
349
+ if (test.file && test.file.endsWith('.cs')) {
350
+ existingTest.file = test.file;
351
+ }
352
+ else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
353
+ existingTest.file = this.extractCsFileFromPath(test);
354
+ }
355
+ }
356
+ else {
357
+ // Fix file path to use proper .cs file names from source paths
358
+ if (!test.file || !test.file.endsWith('.cs')) {
359
+ test.file = this.extractCsFileFromPath(test);
360
+ }
361
+ fqnMap.set(fqn, test);
362
+ }
363
+ });
364
+ return Array.from(fqnMap.values());
365
+ }
366
+ generateFQN(test) {
367
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
368
+ // Don't include assembly as it can vary between different test structures
369
+ const namespace = this.extractNamespace(test);
370
+ const className = this.extractClassName(test);
371
+ const methodName = test.title;
372
+ // Use the most complete namespace.class structure available
373
+ if (test.suite_title && test.suite_title.includes('.')) {
374
+ return `${test.suite_title}.${methodName}`;
375
+ }
376
+ return `${namespace}.${className}.${methodName}`;
377
+ }
378
+ generateNormalizedFQN(test) {
379
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
380
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
381
+ const fullClassName = test.suite_title || '';
382
+ const methodName = test.title;
383
+ // Extract the most specific namespace.class pattern
384
+ if (fullClassName.includes('.')) {
385
+ const parts = fullClassName.split('.');
386
+ if (parts.length >= 2) {
387
+ const className = parts[parts.length - 1];
388
+ // Look for common .NET namespace patterns and normalize them:
389
+ // TestProject.Tests.MyClass -> Tests.MyClass
390
+ // Tests.MyClass -> Tests.MyClass
391
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
392
+ let normalizedNamespace = '';
393
+ for (let i = parts.length - 2; i >= 0; i--) {
394
+ const part = parts[i];
395
+ // Build namespace from right to left, excluding project names
396
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
397
+ // Found a test namespace, use it as the normalized namespace
398
+ normalizedNamespace = part;
399
+ break;
400
+ }
401
+ else if (i === parts.length - 2) {
402
+ // If no test namespace found, use the immediate parent as namespace
403
+ normalizedNamespace = part;
404
+ }
405
+ }
406
+ return `${normalizedNamespace}.${className}.${methodName}`;
407
+ }
408
+ }
409
+ // Fallback for simple class names
410
+ return `${fullClassName}.${methodName}`;
411
+ }
412
+ extractAssemblyName(test) {
413
+ // Extract assembly name from file path or use default
414
+ if (test.file) {
415
+ const parts = test.file.split(/[/\\]/);
416
+ return parts[0] || 'DefaultAssembly';
417
+ }
418
+ return 'DefaultAssembly';
419
+ }
420
+ extractNamespace(test) {
421
+ // Extract namespace from suite_title or classname
422
+ if (test.suite_title && test.suite_title.includes('.')) {
423
+ const parts = test.suite_title.split('.');
424
+ return parts.slice(0, -1).join('.');
425
+ }
426
+ return test.suite_title || 'DefaultNamespace';
427
+ }
428
+ extractClassName(test) {
429
+ // Extract class name from suite_title
430
+ if (test.suite_title && test.suite_title.includes('.')) {
431
+ const parts = test.suite_title.split('.');
432
+ return parts[parts.length - 1];
433
+ }
434
+ return test.suite_title || 'DefaultClass';
435
+ }
436
+ extractCsFileFromPath(test) {
437
+ // Extract .cs file name from source file path, not namespace
438
+ if (test.file) {
439
+ // Look for actual .cs file path patterns
440
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
441
+ if (csFileMatch) {
442
+ return test.file;
443
+ }
444
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
445
+ const className = this.extractClassName(test);
446
+ const pathParts = test.file.split(/[/\\]/);
447
+ pathParts[pathParts.length - 1] = `${className}.cs`;
448
+ return pathParts.join('/');
449
+ }
450
+ // Fallback to class name
451
+ const className = this.extractClassName(test);
452
+ return `${className}.cs`;
453
+ }
278
454
  calculateStats() {
279
455
  this.stats = {
280
456
  ...this.stats,
@@ -320,11 +496,13 @@ class XmlReader {
320
496
  this.stats.language = 'csharp';
321
497
  }
322
498
  if (!fs_1.default.existsSync(file)) {
323
- debug('Failed to open file with the source code', file);
499
+ debug('Failed to open file with the source code: %s', file);
324
500
  return;
325
501
  }
326
502
  const contents = fs_1.default.readFileSync(file).toString();
327
- t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, lang: this.stats.language });
503
+ // Use original test name for source code lookup, not humanized title
504
+ const originalTitle = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
505
+ t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, title: originalTitle, lang: this.stats.language });
328
506
  if (t.code)
329
507
  debug('Fetched code for test %s', t.title);
330
508
  t.test_id = (0, utils_js_1.fetchIdFromCode)(t.code, { lang: this.stats.language });
@@ -430,7 +608,8 @@ function reduceTestCases(prev, item) {
430
608
  testCases
431
609
  .filter(t => !!t)
432
610
  .forEach(testCaseItem => {
433
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
611
+ // Use consistent Test Explorer structure: prioritize fullname for file path
612
+ const file = extractSourceFilePath(testCaseItem, item);
434
613
  let stack = '';
435
614
  let message = '';
436
615
  if (testCaseItem.error)
@@ -450,17 +629,33 @@ function reduceTestCases(prev, item) {
450
629
  if (!message)
451
630
  message = stack.trim().split('\n')[0];
452
631
  const isParametrized = item.type === 'ParameterizedMethod';
453
- const preferClassname = reduceOptions.preferClassname || isParametrized;
454
632
  // SpecFlow config
455
633
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
456
634
  let example = null;
457
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
635
+ // Use consistent Test Explorer structure for suite title
636
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
458
637
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
459
638
  tags ||= [];
460
- const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
461
- if (exampleMatches) {
462
- example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
463
- title = title.replace(/\(.*?\)/, '').trim();
639
+ // Store original test name for parameter extraction
640
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
641
+ // Handle NUnit-style arguments from <arguments> element
642
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
643
+ const args = Array.isArray(testCaseItem.arguments.arg)
644
+ ? testCaseItem.arguments.arg
645
+ : [testCaseItem.arguments.arg];
646
+ example = args; // Store as array instead of object
647
+ // Remove parameters from title for NUnit tests
648
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
649
+ }
650
+ else {
651
+ // Fallback to parsing parameters from test name (SpecFlow, etc.)
652
+ const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
653
+ if (exampleMatches) {
654
+ // Extract and store parameters as Examples
655
+ const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
656
+ example = parameterValues;
657
+ title = title.replace(/\(.*?\)/, '').trim();
658
+ }
464
659
  }
465
660
  stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
466
661
  if (!testId)
@@ -508,6 +703,7 @@ function reduceTestCases(prev, item) {
508
703
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
509
704
  status,
510
705
  title,
706
+ originalTestName, // Store original name for parameter-aware FQN generation
511
707
  root_suite_id: TESTOMATIO_SUITE,
512
708
  suite_title: suiteTitle,
513
709
  files,
@@ -516,6 +712,102 @@ function reduceTestCases(prev, item) {
516
712
  });
517
713
  return prev;
518
714
  }
715
+ function extractSourceFilePath(testCaseItem, item) {
716
+ // Priority order for file path extraction to match Test Explorer structure:
717
+ // 1. filepath attribute (direct .cs file path from NUnit)
718
+ // 2. fullname (contains full project path)
719
+ // 3. file attribute from test case
720
+ // 4. package (fallback)
721
+ // NUnit provides filepath attribute with actual .cs file path - use this first
722
+ if (item.filepath) {
723
+ // Clean up Windows/Unix path separators and ensure proper format
724
+ let filePath = item.filepath.replace(/\\/g, '/');
725
+ // Make relative to current working directory if absolute
726
+ if (path_1.default.isAbsolute(item.filepath)) {
727
+ const cwd = process.cwd().replace(/\\/g, '/');
728
+ if (filePath.startsWith(cwd)) {
729
+ filePath = path_1.default.relative(cwd, item.filepath).replace(/\\/g, '/');
730
+ }
731
+ else {
732
+ // Try to extract relative path from common patterns
733
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
734
+ for (const pattern of commonPatterns) {
735
+ const index = filePath.lastIndexOf(pattern);
736
+ if (index !== -1) {
737
+ filePath = filePath.substring(index + 1);
738
+ break;
739
+ }
740
+ }
741
+ }
742
+ }
743
+ return filePath;
744
+ }
745
+ if (testCaseItem.file) {
746
+ let filePath = testCaseItem.file.replace(/\\/g, '/');
747
+ // Make relative to current working directory if absolute
748
+ if (path_1.default.isAbsolute(testCaseItem.file)) {
749
+ const cwd = process.cwd().replace(/\\/g, '/');
750
+ if (filePath.startsWith(cwd)) {
751
+ filePath = path_1.default.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
752
+ }
753
+ else {
754
+ // Try to extract relative path from common patterns
755
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
756
+ for (const pattern of commonPatterns) {
757
+ const index = filePath.lastIndexOf(pattern);
758
+ if (index !== -1) {
759
+ filePath = filePath.substring(index + 1);
760
+ break;
761
+ }
762
+ }
763
+ }
764
+ }
765
+ return filePath;
766
+ }
767
+ if (item.fullname) {
768
+ // Extract actual file path from fullname if it contains path separators
769
+ const fullnameParts = item.fullname.split('.');
770
+ if (fullnameParts.length > 2) {
771
+ // For ParameterizedMethod, get the class name (not method name)
772
+ // Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
773
+ let namespaceParts, className;
774
+ if (item.type === 'ParameterizedMethod') {
775
+ // For parameterized methods, the last part is the method name, second-to-last is class
776
+ namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
777
+ className = fullnameParts[fullnameParts.length - 2]; // Get class name
778
+ }
779
+ else {
780
+ // For regular classes/fixtures
781
+ namespaceParts = fullnameParts.slice(1, -1); // Skip project name
782
+ className = fullnameParts[fullnameParts.length - 1];
783
+ }
784
+ return `${namespaceParts.join('/')}/${className}.cs`;
785
+ }
786
+ }
787
+ if (item.package)
788
+ return item.package.replace(/\\/g, '/');
789
+ // Fallback: construct from classname
790
+ if (testCaseItem.classname) {
791
+ const parts = testCaseItem.classname.split('.');
792
+ const className = parts[parts.length - 1];
793
+ const namespacePath = parts.slice(0, -1).join('/');
794
+ return `${namespacePath}/${className}.cs`;
795
+ }
796
+ return '';
797
+ }
798
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
799
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
800
+ // Priority: fullname > classname > name
801
+ if (item.fullname) {
802
+ // Use fullname to maintain Test Explorer structure
803
+ return item.fullname;
804
+ }
805
+ if (testCaseItem.classname) {
806
+ return testCaseItem.classname;
807
+ }
808
+ // Fallback to item name but prefer classname structure
809
+ return item.name || testCaseItem.classname || 'UnknownClass';
810
+ }
519
811
  function processTestSuite(testsuite) {
520
812
  if (!testsuite)
521
813
  return [];
@@ -527,8 +819,20 @@ function processTestSuite(testsuite) {
527
819
  if (!Array.isArray(testsuite)) {
528
820
  suites = [testsuite];
529
821
  }
530
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
531
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
822
+ let allResults = [];
823
+ for (const suite of suites) {
824
+ // Process child test suites recursively (TestFixture, ParameterizedMethod, etc.)
825
+ if (suite['test-suite']) {
826
+ const childResults = processTestSuite(suite['test-suite']);
827
+ allResults = allResults.concat(childResults);
828
+ }
829
+ // Process direct test cases in this suite
830
+ if (suite['test-case'] || suite.testcase) {
831
+ const leafResults = reduceTestCases([], suite);
832
+ allResults = allResults.concat(leafResults);
833
+ }
834
+ }
835
+ return allResults;
532
836
  }
533
837
  function fetchProperties(item) {
534
838
  const tags = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.3.0",
3
+ "version": "2.3.2-beta.3-xml-import",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -3,18 +3,45 @@ import Adapter from './adapter.js';
3
3
 
4
4
  class CSharpAdapter extends Adapter {
5
5
  formatTest(t) {
6
- const title = t.title.replace(/\(.*?\)/, '').trim();
7
- const example = t.title.match(/\((.*?)\)/);
8
- if (example) t.example = { ...example[1].split(',') };
6
+ // Don't override example if it already exists from NUnit XML processing
7
+ // The xmlReader.js already extracts parameters correctly from <arguments>
8
+ if (!t.example) {
9
+ const title = t.title.replace(/\(.*?\)/, '').trim();
10
+ const exampleMatch = t.title.match(/\((.*?)\)/);
11
+ if (exampleMatch) {
12
+ // Keep as array for consistency with NUnit XML processing
13
+ t.example = exampleMatch[1].split(',').map(param => param.trim());
14
+ }
15
+ t.title = title.trim();
16
+ }
17
+
9
18
  const suite = t.suite_title.split('.');
10
19
  t.suite_title = suite.pop();
11
20
  t.file = namespaceToFileName(t.file);
12
- t.title = title.trim();
13
21
  return t;
14
22
  }
15
23
 
16
24
  getFilePath(t) {
17
- const fileName = namespaceToFileName(t.file);
25
+ if (!t.file) return null;
26
+
27
+ // Normalize path separators for cross-platform compatibility
28
+ let filePath = t.file.replace(/\\/g, '/');
29
+
30
+ // If file already has .cs extension, use it directly
31
+ if (filePath.endsWith('.cs')) {
32
+ // Make relative path if it's absolute
33
+ if (path.isAbsolute(filePath)) {
34
+ // Try to find project-relative path
35
+ const cwd = process.cwd().replace(/\\/g, '/');
36
+ if (filePath.startsWith(cwd)) {
37
+ filePath = path.relative(cwd, filePath).replace(/\\/g, '/');
38
+ }
39
+ }
40
+ return filePath;
41
+ }
42
+
43
+ // Convert namespace path to file path
44
+ const fileName = namespaceToFileName(filePath);
18
45
  return fileName;
19
46
  }
20
47
  }
@@ -22,7 +49,14 @@ class CSharpAdapter extends Adapter {
22
49
  export default CSharpAdapter;
23
50
 
24
51
  function namespaceToFileName(fileName) {
52
+ if (!fileName) return '';
53
+
54
+ // If already a .cs file path, clean it up
55
+ if (fileName.endsWith('.cs')) {
56
+ return fileName.replace(/\\/g, '/');
57
+ }
58
+
25
59
  const fileParts = fileName.split('.');
26
60
  fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
27
- return `${fileParts.join(path.sep)}.cs`;
61
+ return `${fileParts.join('/')}.cs`;
28
62
  }
@@ -139,6 +139,8 @@ export const TEST_ID_REGEX = /@T([\w\d]{8})/;
139
139
  export const SUITE_ID_REGEX = /@S([\w\d]{8})/;
140
140
 
141
141
  const fetchIdFromCode = (code, opts = {}) => {
142
+ if (!code) return null;
143
+
142
144
  const comments = code
143
145
  .split('\n')
144
146
  .map(l => l.trim())
@@ -180,8 +182,32 @@ const fetchSourceCode = (contents, opts = {}) => {
180
182
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
181
183
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
182
184
  } 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}(`));
185
+ // Enhanced C# method detection for NUnit tests
186
+ lineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
187
+
188
+ if (lineIndex === -1) {
189
+ lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
190
+ }
191
+
192
+ if (lineIndex === -1) {
193
+ lineIndex = lines.findIndex(l => l.includes(`${title}(`));
194
+ }
195
+
196
+ // Look for TestCase or Test attributes above the method
197
+ if (lineIndex === -1) {
198
+ const testAttributeIndex = lines.findIndex((l, index) => {
199
+ if (l.includes('[TestCase') || l.includes('[Test')) {
200
+ // Check next few lines for the method
201
+ const nextLines = lines.slice(index, Math.min(lines.length, index + 5));
202
+ const hasMethod = nextLines.some(nextLine => nextLine.includes(`${title}(`));
203
+ return hasMethod;
204
+ }
205
+ return false;
206
+ });
207
+ if (testAttributeIndex !== -1) {
208
+ lineIndex = testAttributeIndex;
209
+ }
210
+ }
185
211
  } else {
186
212
  lineIndex = lines.findIndex(l => l.includes(title));
187
213
  }
@@ -191,7 +217,7 @@ const fetchSourceCode = (contents, opts = {}) => {
191
217
  lineIndex -= opts.prepend;
192
218
  }
193
219
 
194
- if (lineIndex) {
220
+ if (lineIndex !== -1 && lineIndex !== undefined) {
195
221
  const result = [];
196
222
  for (let i = lineIndex; i < lineIndex + limit; i++) {
197
223
  if (lines[i] === undefined) continue;
@@ -216,6 +242,10 @@ const fetchSourceCode = (contents, opts = {}) => {
216
242
  if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
217
243
  if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
218
244
  if (opts.lang === 'java' && lines[i].includes(' class ')) break;
245
+ if (opts.lang === 'csharp' && lines[i].trim().match(/^\[Test/)) break;
246
+ if (opts.lang === 'csharp' && lines[i].includes(' public void ')) break;
247
+ if (opts.lang === 'csharp' && lines[i].includes(' public async Task ')) break;
248
+ if (opts.lang === 'csharp' && lines[i].includes(' class ') && lines[i].includes('public')) break;
219
249
  }
220
250
  result.push(lines[i]);
221
251
  }
@@ -361,7 +391,7 @@ function readLatestRunId() {
361
391
  try {
362
392
  const filePath = path.join(os.tmpdir(), `testomatio.latest.run`);
363
393
  if (!fs.existsSync(filePath)) return null;
364
-
394
+
365
395
  const stats = fs.statSync(filePath);
366
396
  const diff = +new Date() - +stats.mtime;
367
397
  const diffHours = diff / 1000 / 60 / 60;
package/src/xmlReader.js CHANGED
@@ -162,7 +162,27 @@ class XmlReader {
162
162
  reduceOptions.preferClassname = this.stats.language === 'python';
163
163
  const resultTests = processTestSuite(jsonSuite['test-suite']);
164
164
 
165
- this.tests = this.tests.concat(resultTests);
165
+ debug('Raw tests extracted from NUnit XML:', resultTests.length);
166
+ debug(
167
+ 'Raw tests:',
168
+ resultTests.map(t => ({ title: t.title, example: t.example, file: t.file })),
169
+ );
170
+
171
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
172
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
173
+
174
+ debug('Tests after deduplication:', deduplicatedTests.length);
175
+ debug(
176
+ 'Deduplicated tests:',
177
+ deduplicatedTests.map(t => ({
178
+ title: t.title,
179
+ examples: t.examples,
180
+ example: t.example,
181
+ file: t.file,
182
+ })),
183
+ );
184
+
185
+ this.tests = this.tests.concat(deduplicatedTests);
166
186
 
167
187
  return {
168
188
  status: result?.toLowerCase(),
@@ -171,7 +191,7 @@ class XmlReader {
171
191
  passed_count: parseInt(passed, 10),
172
192
  failed_count: parseInt(failed, 10),
173
193
  skipped_count: parseInt(inconclusive + skipped, 10),
174
- tests: resultTests,
194
+ tests: deduplicatedTests,
175
195
  };
176
196
  }
177
197
 
@@ -319,6 +339,194 @@ class XmlReader {
319
339
  };
320
340
  }
321
341
 
342
+ deduplicateTestsByFQN(tests) {
343
+ const fqnMap = new Map();
344
+
345
+ tests.forEach(test => {
346
+ const fqn = this.generateNormalizedFQN(test);
347
+
348
+ if (fqnMap.has(fqn)) {
349
+ const existingTest = fqnMap.get(fqn);
350
+
351
+ // For parameterized tests, merge as Examples
352
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
353
+ // Initialize examples array if it doesn't exist
354
+ if (!existingTest.examples) {
355
+ existingTest.examples = [];
356
+ // Add the existing test's example as the first item if it has parameters
357
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
358
+ existingTest.examples.push({
359
+ parameters: existingTest.example,
360
+ status: existingTest.status,
361
+ run_time: existingTest.run_time,
362
+ message: existingTest.message,
363
+ stack: existingTest.stack,
364
+ });
365
+ // Clear the main test's example since it's now in examples array
366
+ delete existingTest.example;
367
+ }
368
+ }
369
+
370
+ // Add this test's execution as an example
371
+ existingTest.examples.push({
372
+ parameters: test.example,
373
+ status: test.status,
374
+ run_time: test.run_time,
375
+ message: test.message,
376
+ stack: test.stack,
377
+ });
378
+
379
+ // Update the main test status to reflect the worst status
380
+ if (test.status === 'failed' || existingTest.status === 'failed') {
381
+ existingTest.status = 'failed';
382
+ } else if (test.status === 'skipped' && existingTest.status !== 'failed') {
383
+ existingTest.status = 'skipped';
384
+ }
385
+
386
+ // Update total run time
387
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
388
+ } else {
389
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
390
+ if (test.test_id && !existingTest.test_id) {
391
+ existingTest.test_id = test.test_id;
392
+ }
393
+ // Keep the most complete test data
394
+ if (test.stack && !existingTest.stack) {
395
+ existingTest.stack = test.stack;
396
+ }
397
+ if (test.message && !existingTest.message) {
398
+ existingTest.message = test.message;
399
+ }
400
+ }
401
+
402
+ // Prefer Test Explorer structure (longer, more complete suite_title)
403
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
404
+ existingTest.suite_title = test.suite_title;
405
+ }
406
+
407
+ // Always use the source file path if available
408
+ if (test.file && test.file.endsWith('.cs')) {
409
+ existingTest.file = test.file;
410
+ } else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
411
+ existingTest.file = this.extractCsFileFromPath(test);
412
+ }
413
+ } else {
414
+ // Fix file path to use proper .cs file names from source paths
415
+ if (!test.file || !test.file.endsWith('.cs')) {
416
+ test.file = this.extractCsFileFromPath(test);
417
+ }
418
+ fqnMap.set(fqn, test);
419
+ }
420
+ });
421
+
422
+ return Array.from(fqnMap.values());
423
+ }
424
+
425
+ generateFQN(test) {
426
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
427
+ // Don't include assembly as it can vary between different test structures
428
+ const namespace = this.extractNamespace(test);
429
+ const className = this.extractClassName(test);
430
+ const methodName = test.title;
431
+
432
+ // Use the most complete namespace.class structure available
433
+ if (test.suite_title && test.suite_title.includes('.')) {
434
+ return `${test.suite_title}.${methodName}`;
435
+ }
436
+
437
+ return `${namespace}.${className}.${methodName}`;
438
+ }
439
+
440
+ generateNormalizedFQN(test) {
441
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
442
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
443
+
444
+ const fullClassName = test.suite_title || '';
445
+ const methodName = test.title;
446
+
447
+ // Extract the most specific namespace.class pattern
448
+ if (fullClassName.includes('.')) {
449
+ const parts = fullClassName.split('.');
450
+
451
+ if (parts.length >= 2) {
452
+ const className = parts[parts.length - 1];
453
+
454
+ // Look for common .NET namespace patterns and normalize them:
455
+ // TestProject.Tests.MyClass -> Tests.MyClass
456
+ // Tests.MyClass -> Tests.MyClass
457
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
458
+
459
+ let normalizedNamespace = '';
460
+ for (let i = parts.length - 2; i >= 0; i--) {
461
+ const part = parts[i];
462
+
463
+ // Build namespace from right to left, excluding project names
464
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
465
+ // Found a test namespace, use it as the normalized namespace
466
+ normalizedNamespace = part;
467
+ break;
468
+ } else if (i === parts.length - 2) {
469
+ // If no test namespace found, use the immediate parent as namespace
470
+ normalizedNamespace = part;
471
+ }
472
+ }
473
+
474
+ return `${normalizedNamespace}.${className}.${methodName}`;
475
+ }
476
+ }
477
+
478
+ // Fallback for simple class names
479
+ return `${fullClassName}.${methodName}`;
480
+ }
481
+
482
+ extractAssemblyName(test) {
483
+ // Extract assembly name from file path or use default
484
+ if (test.file) {
485
+ const parts = test.file.split(/[/\\]/);
486
+ return parts[0] || 'DefaultAssembly';
487
+ }
488
+ return 'DefaultAssembly';
489
+ }
490
+
491
+ extractNamespace(test) {
492
+ // Extract namespace from suite_title or classname
493
+ if (test.suite_title && test.suite_title.includes('.')) {
494
+ const parts = test.suite_title.split('.');
495
+ return parts.slice(0, -1).join('.');
496
+ }
497
+ return test.suite_title || 'DefaultNamespace';
498
+ }
499
+
500
+ extractClassName(test) {
501
+ // Extract class name from suite_title
502
+ if (test.suite_title && test.suite_title.includes('.')) {
503
+ const parts = test.suite_title.split('.');
504
+ return parts[parts.length - 1];
505
+ }
506
+ return test.suite_title || 'DefaultClass';
507
+ }
508
+
509
+ extractCsFileFromPath(test) {
510
+ // Extract .cs file name from source file path, not namespace
511
+ if (test.file) {
512
+ // Look for actual .cs file path patterns
513
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
514
+ if (csFileMatch) {
515
+ return test.file;
516
+ }
517
+
518
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
519
+ const className = this.extractClassName(test);
520
+ const pathParts = test.file.split(/[/\\]/);
521
+ pathParts[pathParts.length - 1] = `${className}.cs`;
522
+ return pathParts.join('/');
523
+ }
524
+
525
+ // Fallback to class name
526
+ const className = this.extractClassName(test);
527
+ return `${className}.cs`;
528
+ }
529
+
322
530
  calculateStats() {
323
531
  this.stats = {
324
532
  ...this.stats,
@@ -357,12 +565,18 @@ class XmlReader {
357
565
  }
358
566
 
359
567
  if (!fs.existsSync(file)) {
360
- debug('Failed to open file with the source code', file);
568
+ debug('Failed to open file with the source code: %s', file);
361
569
  return;
362
570
  }
571
+
363
572
  const contents = fs.readFileSync(file).toString();
364
- t.code = fetchSourceCode(contents, { ...t, lang: this.stats.language });
573
+
574
+ // Use original test name for source code lookup, not humanized title
575
+ const originalTitle = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
576
+
577
+ t.code = fetchSourceCode(contents, { ...t, title: originalTitle, lang: this.stats.language });
365
578
  if (t.code) debug('Fetched code for test %s', t.title);
579
+
366
580
  t.test_id = fetchIdFromCode(t.code, { lang: this.stats.language });
367
581
  if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title);
368
582
  } catch (err) {
@@ -485,7 +699,8 @@ function reduceTestCases(prev, item) {
485
699
  testCases
486
700
  .filter(t => !!t)
487
701
  .forEach(testCaseItem => {
488
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
702
+ // Use consistent Test Explorer structure: prioritize fullname for file path
703
+ const file = extractSourceFilePath(testCaseItem, item);
489
704
 
490
705
  let stack = '';
491
706
  let message = '';
@@ -500,20 +715,37 @@ function reduceTestCases(prev, item) {
500
715
  if (!message) message = stack.trim().split('\n')[0];
501
716
 
502
717
  const isParametrized = item.type === 'ParameterizedMethod';
503
- const preferClassname = reduceOptions.preferClassname || isParametrized;
504
718
 
505
719
  // SpecFlow config
506
720
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
507
721
  let example = null;
508
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
722
+
723
+ // Use consistent Test Explorer structure for suite title
724
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
509
725
 
510
726
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
511
727
  tags ||= [];
512
728
 
513
- const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
514
- if (exampleMatches) {
515
- example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
516
- title = title.replace(/\(.*?\)/, '').trim();
729
+ // Store original test name for parameter extraction
730
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
731
+
732
+ // Handle NUnit-style arguments from <arguments> element
733
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
734
+ const args = Array.isArray(testCaseItem.arguments.arg)
735
+ ? testCaseItem.arguments.arg
736
+ : [testCaseItem.arguments.arg];
737
+ example = args; // Store as array instead of object
738
+ // Remove parameters from title for NUnit tests
739
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
740
+ } else {
741
+ // Fallback to parsing parameters from test name (SpecFlow, etc.)
742
+ const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
743
+ if (exampleMatches) {
744
+ // Extract and store parameters as Examples
745
+ const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
746
+ example = parameterValues;
747
+ title = title.replace(/\(.*?\)/, '').trim();
748
+ }
517
749
  }
518
750
 
519
751
  stack = `${
@@ -568,6 +800,7 @@ function reduceTestCases(prev, item) {
568
800
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
569
801
  status,
570
802
  title,
803
+ originalTestName, // Store original name for parameter-aware FQN generation
571
804
  root_suite_id: TESTOMATIO_SUITE,
572
805
  suite_title: suiteTitle,
573
806
  files,
@@ -577,6 +810,113 @@ function reduceTestCases(prev, item) {
577
810
  return prev;
578
811
  }
579
812
 
813
+ function extractSourceFilePath(testCaseItem, item) {
814
+ // Priority order for file path extraction to match Test Explorer structure:
815
+ // 1. filepath attribute (direct .cs file path from NUnit)
816
+ // 2. fullname (contains full project path)
817
+ // 3. file attribute from test case
818
+ // 4. package (fallback)
819
+
820
+ // NUnit provides filepath attribute with actual .cs file path - use this first
821
+ if (item.filepath) {
822
+ // Clean up Windows/Unix path separators and ensure proper format
823
+ let filePath = item.filepath.replace(/\\/g, '/');
824
+
825
+ // Make relative to current working directory if absolute
826
+ if (path.isAbsolute(item.filepath)) {
827
+ const cwd = process.cwd().replace(/\\/g, '/');
828
+ if (filePath.startsWith(cwd)) {
829
+ filePath = path.relative(cwd, item.filepath).replace(/\\/g, '/');
830
+ } else {
831
+ // Try to extract relative path from common patterns
832
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
833
+ for (const pattern of commonPatterns) {
834
+ const index = filePath.lastIndexOf(pattern);
835
+ if (index !== -1) {
836
+ filePath = filePath.substring(index + 1);
837
+ break;
838
+ }
839
+ }
840
+ }
841
+ }
842
+ return filePath;
843
+ }
844
+
845
+ if (testCaseItem.file) {
846
+ let filePath = testCaseItem.file.replace(/\\/g, '/');
847
+
848
+ // Make relative to current working directory if absolute
849
+ if (path.isAbsolute(testCaseItem.file)) {
850
+ const cwd = process.cwd().replace(/\\/g, '/');
851
+ if (filePath.startsWith(cwd)) {
852
+ filePath = path.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
853
+ } else {
854
+ // Try to extract relative path from common patterns
855
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
856
+ for (const pattern of commonPatterns) {
857
+ const index = filePath.lastIndexOf(pattern);
858
+ if (index !== -1) {
859
+ filePath = filePath.substring(index + 1);
860
+ break;
861
+ }
862
+ }
863
+ }
864
+ }
865
+ return filePath;
866
+ }
867
+
868
+ if (item.fullname) {
869
+ // Extract actual file path from fullname if it contains path separators
870
+ const fullnameParts = item.fullname.split('.');
871
+ if (fullnameParts.length > 2) {
872
+ // For ParameterizedMethod, get the class name (not method name)
873
+ // Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
874
+ let namespaceParts, className;
875
+
876
+ if (item.type === 'ParameterizedMethod') {
877
+ // For parameterized methods, the last part is the method name, second-to-last is class
878
+ namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
879
+ className = fullnameParts[fullnameParts.length - 2]; // Get class name
880
+ } else {
881
+ // For regular classes/fixtures
882
+ namespaceParts = fullnameParts.slice(1, -1); // Skip project name
883
+ className = fullnameParts[fullnameParts.length - 1];
884
+ }
885
+
886
+ return `${namespaceParts.join('/')}/${className}.cs`;
887
+ }
888
+ }
889
+
890
+ if (item.package) return item.package.replace(/\\/g, '/');
891
+
892
+ // Fallback: construct from classname
893
+ if (testCaseItem.classname) {
894
+ const parts = testCaseItem.classname.split('.');
895
+ const className = parts[parts.length - 1];
896
+ const namespacePath = parts.slice(0, -1).join('/');
897
+ return `${namespacePath}/${className}.cs`;
898
+ }
899
+
900
+ return '';
901
+ }
902
+
903
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
904
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
905
+ // Priority: fullname > classname > name
906
+
907
+ if (item.fullname) {
908
+ // Use fullname to maintain Test Explorer structure
909
+ return item.fullname;
910
+ }
911
+
912
+ if (testCaseItem.classname) {
913
+ return testCaseItem.classname;
914
+ }
915
+
916
+ // Fallback to item name but prefer classname structure
917
+ return item.name || testCaseItem.classname || 'UnknownClass';
918
+ }
919
+
580
920
  function processTestSuite(testsuite) {
581
921
  if (!testsuite) return [];
582
922
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -587,9 +927,23 @@ function processTestSuite(testsuite) {
587
927
  suites = [testsuite];
588
928
  }
589
929
 
590
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
930
+ let allResults = [];
931
+
932
+ for (const suite of suites) {
933
+ // Process child test suites recursively (TestFixture, ParameterizedMethod, etc.)
934
+ if (suite['test-suite']) {
935
+ const childResults = processTestSuite(suite['test-suite']);
936
+ allResults = allResults.concat(childResults);
937
+ }
938
+
939
+ // Process direct test cases in this suite
940
+ if (suite['test-case'] || suite.testcase) {
941
+ const leafResults = reduceTestCases([], suite);
942
+ allResults = allResults.concat(leafResults);
943
+ }
944
+ }
591
945
 
592
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
946
+ return allResults;
593
947
  }
594
948
 
595
949
  function fetchProperties(item) {