@testomatio/reporter 2.3.1-beta.1-dependency → 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.
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) {