@testomatio/reporter 2.2.1 → 2.3.0-beta.2-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.
@@ -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,9 @@ 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
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
135
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
136
+ this.tests = this.tests.concat(deduplicatedTests);
135
137
  return {
136
138
  status: result?.toLowerCase(),
137
139
  create_tests: true,
@@ -139,7 +141,7 @@ class XmlReader {
139
141
  passed_count: parseInt(passed, 10),
140
142
  failed_count: parseInt(failed, 10),
141
143
  skipped_count: parseInt(inconclusive + skipped, 10),
142
- tests: resultTests,
144
+ tests: deduplicatedTests,
143
145
  };
144
146
  }
145
147
  processTRX(jsonSuite) {
@@ -275,6 +277,171 @@ class XmlReader {
275
277
  tests,
276
278
  };
277
279
  }
280
+ deduplicateTestsByFQN(tests) {
281
+ const fqnMap = new Map();
282
+ tests.forEach(test => {
283
+ const fqn = this.generateNormalizedFQN(test);
284
+ if (fqnMap.has(fqn)) {
285
+ const existingTest = fqnMap.get(fqn);
286
+ // For parameterized tests, merge as Examples
287
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
288
+ // Initialize examples array if it doesn't exist
289
+ if (!existingTest.examples) {
290
+ existingTest.examples = [];
291
+ // Add the existing test's example as the first item if it has parameters
292
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
293
+ existingTest.examples.push({
294
+ parameters: existingTest.example,
295
+ status: existingTest.status,
296
+ run_time: existingTest.run_time,
297
+ message: existingTest.message,
298
+ stack: existingTest.stack
299
+ });
300
+ // Clear the main test's example since it's now in examples array
301
+ delete existingTest.example;
302
+ }
303
+ }
304
+ // Add this test's execution as an example
305
+ existingTest.examples.push({
306
+ parameters: test.example,
307
+ status: test.status,
308
+ run_time: test.run_time,
309
+ message: test.message,
310
+ stack: test.stack
311
+ });
312
+ // Update the main test status to reflect the worst status
313
+ if (test.status === 'failed' || existingTest.status === 'failed') {
314
+ existingTest.status = 'failed';
315
+ }
316
+ else if (test.status === 'skipped' && existingTest.status !== 'failed') {
317
+ existingTest.status = 'skipped';
318
+ }
319
+ // Update total run time
320
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
321
+ }
322
+ else {
323
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
324
+ if (test.test_id && !existingTest.test_id) {
325
+ existingTest.test_id = test.test_id;
326
+ }
327
+ // Keep the most complete test data
328
+ if (test.stack && !existingTest.stack) {
329
+ existingTest.stack = test.stack;
330
+ }
331
+ if (test.message && !existingTest.message) {
332
+ existingTest.message = test.message;
333
+ }
334
+ }
335
+ // Prefer Test Explorer structure (longer, more complete suite_title)
336
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
337
+ existingTest.suite_title = test.suite_title;
338
+ }
339
+ // Always use the source file path if available
340
+ if (test.file && test.file.endsWith('.cs')) {
341
+ existingTest.file = test.file;
342
+ }
343
+ else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
344
+ existingTest.file = this.extractCsFileFromPath(test);
345
+ }
346
+ }
347
+ else {
348
+ // Fix file path to use proper .cs file names from source paths
349
+ if (!test.file || !test.file.endsWith('.cs')) {
350
+ test.file = this.extractCsFileFromPath(test);
351
+ }
352
+ fqnMap.set(fqn, test);
353
+ }
354
+ });
355
+ return Array.from(fqnMap.values());
356
+ }
357
+ generateFQN(test) {
358
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
359
+ // Don't include assembly as it can vary between different test structures
360
+ const namespace = this.extractNamespace(test);
361
+ const className = this.extractClassName(test);
362
+ const methodName = test.title;
363
+ // Use the most complete namespace.class structure available
364
+ if (test.suite_title && test.suite_title.includes('.')) {
365
+ return `${test.suite_title}.${methodName}`;
366
+ }
367
+ return `${namespace}.${className}.${methodName}`;
368
+ }
369
+ generateNormalizedFQN(test) {
370
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
371
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
372
+ const fullClassName = test.suite_title || '';
373
+ const methodName = test.title;
374
+ // Extract the most specific namespace.class pattern
375
+ if (fullClassName.includes('.')) {
376
+ const parts = fullClassName.split('.');
377
+ if (parts.length >= 2) {
378
+ const className = parts[parts.length - 1];
379
+ // Look for common .NET namespace patterns and normalize them:
380
+ // TestProject.Tests.MyClass -> Tests.MyClass
381
+ // Tests.MyClass -> Tests.MyClass
382
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
383
+ let normalizedNamespace = '';
384
+ for (let i = parts.length - 2; i >= 0; i--) {
385
+ const part = parts[i];
386
+ // Build namespace from right to left, excluding project names
387
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
388
+ // Found a test namespace, use it as the normalized namespace
389
+ normalizedNamespace = part;
390
+ break;
391
+ }
392
+ else if (i === parts.length - 2) {
393
+ // If no test namespace found, use the immediate parent as namespace
394
+ normalizedNamespace = part;
395
+ }
396
+ }
397
+ return `${normalizedNamespace}.${className}.${methodName}`;
398
+ }
399
+ }
400
+ // Fallback for simple class names
401
+ return `${fullClassName}.${methodName}`;
402
+ }
403
+ extractAssemblyName(test) {
404
+ // Extract assembly name from file path or use default
405
+ if (test.file) {
406
+ const parts = test.file.split(/[/\\]/);
407
+ return parts[0] || 'DefaultAssembly';
408
+ }
409
+ return 'DefaultAssembly';
410
+ }
411
+ extractNamespace(test) {
412
+ // Extract namespace from suite_title or classname
413
+ if (test.suite_title && test.suite_title.includes('.')) {
414
+ const parts = test.suite_title.split('.');
415
+ return parts.slice(0, -1).join('.');
416
+ }
417
+ return test.suite_title || 'DefaultNamespace';
418
+ }
419
+ extractClassName(test) {
420
+ // Extract class name from suite_title
421
+ if (test.suite_title && test.suite_title.includes('.')) {
422
+ const parts = test.suite_title.split('.');
423
+ return parts[parts.length - 1];
424
+ }
425
+ return test.suite_title || 'DefaultClass';
426
+ }
427
+ extractCsFileFromPath(test) {
428
+ // Extract .cs file name from source file path, not namespace
429
+ if (test.file) {
430
+ // Look for actual .cs file path patterns
431
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
432
+ if (csFileMatch) {
433
+ return test.file;
434
+ }
435
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
436
+ const className = this.extractClassName(test);
437
+ const pathParts = test.file.split(/[/\\]/);
438
+ pathParts[pathParts.length - 1] = `${className}.cs`;
439
+ return pathParts.join('/');
440
+ }
441
+ // Fallback to class name
442
+ const className = this.extractClassName(test);
443
+ return `${className}.cs`;
444
+ }
278
445
  calculateStats() {
279
446
  this.stats = {
280
447
  ...this.stats,
@@ -430,7 +597,8 @@ function reduceTestCases(prev, item) {
430
597
  testCases
431
598
  .filter(t => !!t)
432
599
  .forEach(testCaseItem => {
433
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
600
+ // Use consistent Test Explorer structure: prioritize fullname for file path
601
+ const file = extractSourceFilePath(testCaseItem, item);
434
602
  let stack = '';
435
603
  let message = '';
436
604
  if (testCaseItem.error)
@@ -450,17 +618,33 @@ function reduceTestCases(prev, item) {
450
618
  if (!message)
451
619
  message = stack.trim().split('\n')[0];
452
620
  const isParametrized = item.type === 'ParameterizedMethod';
453
- const preferClassname = reduceOptions.preferClassname || isParametrized;
454
621
  // SpecFlow config
455
622
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
456
623
  let example = null;
457
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
624
+ // Use consistent Test Explorer structure for suite title
625
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
458
626
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
459
627
  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();
628
+ // Store original test name for parameter extraction
629
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
630
+ // Handle NUnit-style arguments from <arguments> element
631
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
632
+ const args = Array.isArray(testCaseItem.arguments.arg)
633
+ ? testCaseItem.arguments.arg
634
+ : [testCaseItem.arguments.arg];
635
+ example = args; // Store as array instead of object
636
+ // Remove parameters from title for NUnit tests
637
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
638
+ }
639
+ else {
640
+ // Fallback to parsing parameters from test name (SpecFlow, etc.)
641
+ const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
642
+ if (exampleMatches) {
643
+ // Extract and store parameters as Examples
644
+ const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
645
+ example = parameterValues;
646
+ title = title.replace(/\(.*?\)/, '').trim();
647
+ }
464
648
  }
465
649
  stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
466
650
  if (!testId)
@@ -508,6 +692,7 @@ function reduceTestCases(prev, item) {
508
692
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
509
693
  status,
510
694
  title,
695
+ originalTestName, // Store original name for parameter-aware FQN generation
511
696
  root_suite_id: TESTOMATIO_SUITE,
512
697
  suite_title: suiteTitle,
513
698
  files,
@@ -516,6 +701,54 @@ function reduceTestCases(prev, item) {
516
701
  });
517
702
  return prev;
518
703
  }
704
+ function extractSourceFilePath(testCaseItem, item) {
705
+ // Priority order for file path extraction to match Test Explorer structure:
706
+ // 1. filepath attribute (direct .cs file path from NUnit)
707
+ // 2. fullname (contains full project path)
708
+ // 3. file attribute from test case
709
+ // 4. package (fallback)
710
+ // NUnit provides filepath attribute with actual .cs file path - use this first
711
+ if (item.filepath) {
712
+ // Clean up Windows/Unix path separators and ensure proper format
713
+ return item.filepath.replace(/\\/g, '/');
714
+ }
715
+ if (testCaseItem.file)
716
+ return testCaseItem.file.replace(/\\/g, '/');
717
+ if (item.fullname) {
718
+ // Extract actual file path from fullname if it contains path separators
719
+ const fullnameParts = item.fullname.split('.');
720
+ if (fullnameParts.length > 2) {
721
+ // Reconstruct path from project.namespace.class structure
722
+ const projectName = fullnameParts[0];
723
+ const namespaceParts = fullnameParts.slice(1, -1);
724
+ const className = fullnameParts[fullnameParts.length - 1];
725
+ return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
726
+ }
727
+ }
728
+ if (item.package)
729
+ return item.package.replace(/\\/g, '/');
730
+ // Fallback: construct from classname
731
+ if (testCaseItem.classname) {
732
+ const parts = testCaseItem.classname.split('.');
733
+ const className = parts[parts.length - 1];
734
+ const namespacePath = parts.slice(0, -1).join('/');
735
+ return `${namespacePath}/${className}.cs`;
736
+ }
737
+ return '';
738
+ }
739
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
740
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
741
+ // Priority: fullname > classname > name
742
+ if (item.fullname) {
743
+ // Use fullname to maintain Test Explorer structure
744
+ return item.fullname;
745
+ }
746
+ if (testCaseItem.classname) {
747
+ return testCaseItem.classname;
748
+ }
749
+ // Fallback to item name but prefer classname structure
750
+ return item.name || testCaseItem.classname || 'UnknownClass';
751
+ }
519
752
  function processTestSuite(testsuite) {
520
753
  if (!testsuite)
521
754
  return [];
@@ -527,8 +760,14 @@ function processTestSuite(testsuite) {
527
760
  if (!Array.isArray(testsuite)) {
528
761
  suites = [testsuite];
529
762
  }
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();
763
+ // Only process suites that have test cases OR child suites, but avoid double processing
764
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
765
+ const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
766
+ // Process child suites recursively
767
+ const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
768
+ // Process leaf suites with actual test cases
769
+ const leafResults = leafSuites.reduce(reduceTestCases, []);
770
+ return [...childResults, ...leafResults];
532
771
  }
533
772
  function fetchProperties(item) {
534
773
  const tags = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.2.1",
3
+ "version": "2.3.0-beta.2-xml-import",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
package/src/xmlReader.js CHANGED
@@ -161,8 +161,11 @@ class XmlReader {
161
161
 
162
162
  reduceOptions.preferClassname = this.stats.language === 'python';
163
163
  const resultTests = processTestSuite(jsonSuite['test-suite']);
164
+
165
+ // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
166
+ const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
164
167
 
165
- this.tests = this.tests.concat(resultTests);
168
+ this.tests = this.tests.concat(deduplicatedTests);
166
169
 
167
170
  return {
168
171
  status: result?.toLowerCase(),
@@ -171,7 +174,7 @@ class XmlReader {
171
174
  passed_count: parseInt(passed, 10),
172
175
  failed_count: parseInt(failed, 10),
173
176
  skipped_count: parseInt(inconclusive + skipped, 10),
174
- tests: resultTests,
177
+ tests: deduplicatedTests,
175
178
  };
176
179
  }
177
180
 
@@ -319,6 +322,195 @@ class XmlReader {
319
322
  };
320
323
  }
321
324
 
325
+ deduplicateTestsByFQN(tests) {
326
+ const fqnMap = new Map();
327
+
328
+ tests.forEach(test => {
329
+ const fqn = this.generateNormalizedFQN(test);
330
+
331
+ if (fqnMap.has(fqn)) {
332
+ const existingTest = fqnMap.get(fqn);
333
+
334
+ // For parameterized tests, merge as Examples
335
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
336
+ // Initialize examples array if it doesn't exist
337
+ if (!existingTest.examples) {
338
+ existingTest.examples = [];
339
+ // Add the existing test's example as the first item if it has parameters
340
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
341
+ existingTest.examples.push({
342
+ parameters: existingTest.example,
343
+ status: existingTest.status,
344
+ run_time: existingTest.run_time,
345
+ message: existingTest.message,
346
+ stack: existingTest.stack
347
+ });
348
+ // Clear the main test's example since it's now in examples array
349
+ delete existingTest.example;
350
+ }
351
+ }
352
+
353
+ // Add this test's execution as an example
354
+ existingTest.examples.push({
355
+ parameters: test.example,
356
+ status: test.status,
357
+ run_time: test.run_time,
358
+ message: test.message,
359
+ stack: test.stack
360
+ });
361
+
362
+ // Update the main test status to reflect the worst status
363
+ if (test.status === 'failed' || existingTest.status === 'failed') {
364
+ existingTest.status = 'failed';
365
+ } else if (test.status === 'skipped' && existingTest.status !== 'failed') {
366
+ existingTest.status = 'skipped';
367
+ }
368
+
369
+ // Update total run time
370
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
371
+
372
+ } else {
373
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
374
+ if (test.test_id && !existingTest.test_id) {
375
+ existingTest.test_id = test.test_id;
376
+ }
377
+ // Keep the most complete test data
378
+ if (test.stack && !existingTest.stack) {
379
+ existingTest.stack = test.stack;
380
+ }
381
+ if (test.message && !existingTest.message) {
382
+ existingTest.message = test.message;
383
+ }
384
+ }
385
+
386
+ // Prefer Test Explorer structure (longer, more complete suite_title)
387
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
388
+ existingTest.suite_title = test.suite_title;
389
+ }
390
+
391
+ // Always use the source file path if available
392
+ if (test.file && test.file.endsWith('.cs')) {
393
+ existingTest.file = test.file;
394
+ } else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
395
+ existingTest.file = this.extractCsFileFromPath(test);
396
+ }
397
+ } else {
398
+ // Fix file path to use proper .cs file names from source paths
399
+ if (!test.file || !test.file.endsWith('.cs')) {
400
+ test.file = this.extractCsFileFromPath(test);
401
+ }
402
+ fqnMap.set(fqn, test);
403
+ }
404
+ });
405
+
406
+ return Array.from(fqnMap.values());
407
+ }
408
+
409
+ generateFQN(test) {
410
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
411
+ // Don't include assembly as it can vary between different test structures
412
+ const namespace = this.extractNamespace(test);
413
+ const className = this.extractClassName(test);
414
+ const methodName = test.title;
415
+
416
+ // Use the most complete namespace.class structure available
417
+ if (test.suite_title && test.suite_title.includes('.')) {
418
+ return `${test.suite_title}.${methodName}`;
419
+ }
420
+
421
+ return `${namespace}.${className}.${methodName}`;
422
+ }
423
+
424
+ generateNormalizedFQN(test) {
425
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
426
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
427
+
428
+ const fullClassName = test.suite_title || '';
429
+ const methodName = test.title;
430
+
431
+ // Extract the most specific namespace.class pattern
432
+ if (fullClassName.includes('.')) {
433
+ const parts = fullClassName.split('.');
434
+
435
+ if (parts.length >= 2) {
436
+ const className = parts[parts.length - 1];
437
+
438
+ // Look for common .NET namespace patterns and normalize them:
439
+ // TestProject.Tests.MyClass -> Tests.MyClass
440
+ // Tests.MyClass -> Tests.MyClass
441
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
442
+
443
+ let normalizedNamespace = '';
444
+ for (let i = parts.length - 2; i >= 0; i--) {
445
+ const part = parts[i];
446
+
447
+ // Build namespace from right to left, excluding project names
448
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
449
+ // Found a test namespace, use it as the normalized namespace
450
+ normalizedNamespace = part;
451
+ break;
452
+ } else if (i === parts.length - 2) {
453
+ // If no test namespace found, use the immediate parent as namespace
454
+ normalizedNamespace = part;
455
+ }
456
+ }
457
+
458
+ return `${normalizedNamespace}.${className}.${methodName}`;
459
+ }
460
+ }
461
+
462
+ // Fallback for simple class names
463
+ return `${fullClassName}.${methodName}`;
464
+ }
465
+
466
+ extractAssemblyName(test) {
467
+ // Extract assembly name from file path or use default
468
+ if (test.file) {
469
+ const parts = test.file.split(/[/\\]/);
470
+ return parts[0] || 'DefaultAssembly';
471
+ }
472
+ return 'DefaultAssembly';
473
+ }
474
+
475
+ extractNamespace(test) {
476
+ // Extract namespace from suite_title or classname
477
+ if (test.suite_title && test.suite_title.includes('.')) {
478
+ const parts = test.suite_title.split('.');
479
+ return parts.slice(0, -1).join('.');
480
+ }
481
+ return test.suite_title || 'DefaultNamespace';
482
+ }
483
+
484
+ extractClassName(test) {
485
+ // Extract class name from suite_title
486
+ if (test.suite_title && test.suite_title.includes('.')) {
487
+ const parts = test.suite_title.split('.');
488
+ return parts[parts.length - 1];
489
+ }
490
+ return test.suite_title || 'DefaultClass';
491
+ }
492
+
493
+ extractCsFileFromPath(test) {
494
+ // Extract .cs file name from source file path, not namespace
495
+ if (test.file) {
496
+ // Look for actual .cs file path patterns
497
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
498
+ if (csFileMatch) {
499
+ return test.file;
500
+ }
501
+
502
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
503
+ const className = this.extractClassName(test);
504
+ const pathParts = test.file.split(/[/\\]/);
505
+ pathParts[pathParts.length - 1] = `${className}.cs`;
506
+ return pathParts.join('/');
507
+ }
508
+
509
+ // Fallback to class name
510
+ const className = this.extractClassName(test);
511
+ return `${className}.cs`;
512
+ }
513
+
322
514
  calculateStats() {
323
515
  this.stats = {
324
516
  ...this.stats,
@@ -485,7 +677,8 @@ function reduceTestCases(prev, item) {
485
677
  testCases
486
678
  .filter(t => !!t)
487
679
  .forEach(testCaseItem => {
488
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
680
+ // Use consistent Test Explorer structure: prioritize fullname for file path
681
+ const file = extractSourceFilePath(testCaseItem, item);
489
682
 
490
683
  let stack = '';
491
684
  let message = '';
@@ -500,20 +693,37 @@ function reduceTestCases(prev, item) {
500
693
  if (!message) message = stack.trim().split('\n')[0];
501
694
 
502
695
  const isParametrized = item.type === 'ParameterizedMethod';
503
- const preferClassname = reduceOptions.preferClassname || isParametrized;
504
696
 
505
697
  // SpecFlow config
506
698
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
507
699
  let example = null;
508
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
700
+
701
+ // Use consistent Test Explorer structure for suite title
702
+ const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
509
703
 
510
704
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
511
705
  tags ||= [];
512
706
 
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();
707
+ // Store original test name for parameter extraction
708
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
709
+
710
+ // Handle NUnit-style arguments from <arguments> element
711
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
712
+ const args = Array.isArray(testCaseItem.arguments.arg)
713
+ ? testCaseItem.arguments.arg
714
+ : [testCaseItem.arguments.arg];
715
+ example = args; // Store as array instead of object
716
+ // Remove parameters from title for NUnit tests
717
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
718
+ } else {
719
+ // Fallback to parsing parameters from test name (SpecFlow, etc.)
720
+ const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
721
+ if (exampleMatches) {
722
+ // Extract and store parameters as Examples
723
+ const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
724
+ example = parameterValues;
725
+ title = title.replace(/\(.*?\)/, '').trim();
726
+ }
517
727
  }
518
728
 
519
729
  stack = `${
@@ -568,6 +778,7 @@ function reduceTestCases(prev, item) {
568
778
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
569
779
  status,
570
780
  title,
781
+ originalTestName, // Store original name for parameter-aware FQN generation
571
782
  root_suite_id: TESTOMATIO_SUITE,
572
783
  suite_title: suiteTitle,
573
784
  files,
@@ -577,6 +788,63 @@ function reduceTestCases(prev, item) {
577
788
  return prev;
578
789
  }
579
790
 
791
+ function extractSourceFilePath(testCaseItem, item) {
792
+ // Priority order for file path extraction to match Test Explorer structure:
793
+ // 1. filepath attribute (direct .cs file path from NUnit)
794
+ // 2. fullname (contains full project path)
795
+ // 3. file attribute from test case
796
+ // 4. package (fallback)
797
+
798
+ // NUnit provides filepath attribute with actual .cs file path - use this first
799
+ if (item.filepath) {
800
+ // Clean up Windows/Unix path separators and ensure proper format
801
+ return item.filepath.replace(/\\/g, '/');
802
+ }
803
+
804
+ if (testCaseItem.file) return testCaseItem.file.replace(/\\/g, '/');
805
+
806
+ if (item.fullname) {
807
+ // Extract actual file path from fullname if it contains path separators
808
+ const fullnameParts = item.fullname.split('.');
809
+ if (fullnameParts.length > 2) {
810
+ // Reconstruct path from project.namespace.class structure
811
+ const projectName = fullnameParts[0];
812
+ const namespaceParts = fullnameParts.slice(1, -1);
813
+ const className = fullnameParts[fullnameParts.length - 1];
814
+ return `${projectName}/${namespaceParts.join('/')}/${className}.cs`;
815
+ }
816
+ }
817
+
818
+ if (item.package) return item.package.replace(/\\/g, '/');
819
+
820
+ // Fallback: construct from classname
821
+ if (testCaseItem.classname) {
822
+ const parts = testCaseItem.classname.split('.');
823
+ const className = parts[parts.length - 1];
824
+ const namespacePath = parts.slice(0, -1).join('/');
825
+ return `${namespacePath}/${className}.cs`;
826
+ }
827
+
828
+ return '';
829
+ }
830
+
831
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
832
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
833
+ // Priority: fullname > classname > name
834
+
835
+ if (item.fullname) {
836
+ // Use fullname to maintain Test Explorer structure
837
+ return item.fullname;
838
+ }
839
+
840
+ if (testCaseItem.classname) {
841
+ return testCaseItem.classname;
842
+ }
843
+
844
+ // Fallback to item name but prefer classname structure
845
+ return item.name || testCaseItem.classname || 'UnknownClass';
846
+ }
847
+
580
848
  function processTestSuite(testsuite) {
581
849
  if (!testsuite) return [];
582
850
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -587,9 +855,17 @@ function processTestSuite(testsuite) {
587
855
  suites = [testsuite];
588
856
  }
589
857
 
590
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
858
+ // Only process suites that have test cases OR child suites, but avoid double processing
859
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
860
+ const leafSuites = suites.filter(s => s['test-case'] || s.testcase);
861
+
862
+ // Process child suites recursively
863
+ const childResults = subSuites.map(s => processTestSuite(s['test-suite'])).flat();
864
+
865
+ // Process leaf suites with actual test cases
866
+ const leafResults = leafSuites.reduce(reduceTestCases, []);
591
867
 
592
- return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
868
+ return [...childResults, ...leafResults];
593
869
  }
594
870
 
595
871
  function fetchProperties(item) {