@testomatio/reporter 2.3.4 → 2.3.5-beta-6-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
@@ -35,6 +35,7 @@ const {
35
35
  TESTOMATIO_ENV,
36
36
  TESTOMATIO_RUN,
37
37
  TESTOMATIO_MARK_DETACHED,
38
+ TESTOMATIO_DISABLE_SOURCE_CODE,
38
39
  } = process.env;
39
40
 
40
41
  const options = {
@@ -66,6 +67,10 @@ class XmlReader {
66
67
  if (!this.adapter) throw new Error('XML adapter for this format not found');
67
68
 
68
69
  this.opts = opts || {};
70
+ // Check if source code fetching should be disabled
71
+ this.disableSourceCodeFetching = opts.disableSourceCodeFetching || TESTOMATIO_DISABLE_SOURCE_CODE;
72
+ // Control suite organization strategy: 'classname' (default) or 'fullpath'
73
+ this.suiteOrganization = opts.suiteOrganization || process.env.TESTOMATIO_SUITE_ORGANIZATION || 'classname';
69
74
  this.store = {};
70
75
  this.pipesPromise = pipesFactory(opts, this.store);
71
76
 
@@ -79,6 +84,16 @@ class XmlReader {
79
84
  const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
80
85
  this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version;
81
86
  console.log(APP_PREFIX, `Testomatio Reporter v${this.version}`);
87
+
88
+ if (this.disableSourceCodeFetching) {
89
+ console.log(APP_PREFIX, '🚫 Source code fetching is disabled');
90
+ }
91
+
92
+ if (this.suiteOrganization === 'fullpath') {
93
+ console.log(APP_PREFIX, '📁 Using fullpath suite organization (may create nested structure)');
94
+ } else {
95
+ console.log(APP_PREFIX, '📋 Using classname suite organization (avoids duplicates)');
96
+ }
82
97
  }
83
98
 
84
99
  connectAdapter() {
@@ -160,9 +175,32 @@ class XmlReader {
160
175
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
161
176
 
162
177
  reduceOptions.preferClassname = this.stats.language === 'python';
178
+ reduceOptions.suiteOrganization = this.suiteOrganization;
163
179
  const resultTests = processTestSuite(jsonSuite['test-suite']);
164
180
 
165
- this.tests = this.tests.concat(resultTests);
181
+ debug('Raw tests extracted from NUnit XML:', resultTests.length);
182
+ debug(
183
+ 'Raw tests:',
184
+ resultTests.map(t => ({ title: t.title, example: t.example, file: t.file })),
185
+ );
186
+
187
+ // Optional deduplication for complex NUnit scenarios - can be enabled via options
188
+ let finalTests = resultTests;
189
+ if (this.opts.enableNUnitDeduplication) {
190
+ finalTests = this.deduplicateTestsByFQN(resultTests);
191
+ debug('Tests after deduplication:', finalTests.length);
192
+ debug(
193
+ 'Deduplicated tests:',
194
+ finalTests.map(t => ({
195
+ title: t.title,
196
+ examples: t.examples,
197
+ example: t.example,
198
+ file: t.file,
199
+ })),
200
+ );
201
+ }
202
+
203
+ this.tests = this.tests.concat(finalTests);
166
204
 
167
205
  return {
168
206
  status: result?.toLowerCase(),
@@ -171,7 +209,7 @@ class XmlReader {
171
209
  passed_count: parseInt(passed, 10),
172
210
  failed_count: parseInt(failed, 10),
173
211
  skipped_count: parseInt(inconclusive + skipped, 10),
174
- tests: resultTests,
212
+ tests: finalTests,
175
213
  };
176
214
  }
177
215
 
@@ -319,6 +357,194 @@ class XmlReader {
319
357
  };
320
358
  }
321
359
 
360
+ deduplicateTestsByFQN(tests) {
361
+ const fqnMap = new Map();
362
+
363
+ tests.forEach(test => {
364
+ const fqn = this.generateNormalizedFQN(test);
365
+
366
+ if (fqnMap.has(fqn)) {
367
+ const existingTest = fqnMap.get(fqn);
368
+
369
+ // For parameterized tests, merge as Examples
370
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
371
+ // Initialize examples array if it doesn't exist
372
+ if (!existingTest.examples) {
373
+ existingTest.examples = [];
374
+ // Add the existing test's example as the first item if it has parameters
375
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
376
+ existingTest.examples.push({
377
+ parameters: existingTest.example,
378
+ status: existingTest.status,
379
+ run_time: existingTest.run_time,
380
+ message: existingTest.message,
381
+ stack: existingTest.stack,
382
+ });
383
+ // Clear the main test's example since it's now in examples array
384
+ delete existingTest.example;
385
+ }
386
+ }
387
+
388
+ // Add this test's execution as an example
389
+ existingTest.examples.push({
390
+ parameters: test.example,
391
+ status: test.status,
392
+ run_time: test.run_time,
393
+ message: test.message,
394
+ stack: test.stack,
395
+ });
396
+
397
+ // Update the main test status to reflect the worst status
398
+ if (test.status === 'failed' || existingTest.status === 'failed') {
399
+ existingTest.status = 'failed';
400
+ } else if (test.status === 'skipped' && existingTest.status !== 'failed') {
401
+ existingTest.status = 'skipped';
402
+ }
403
+
404
+ // Update total run time
405
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
406
+ } else {
407
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
408
+ if (test.test_id && !existingTest.test_id) {
409
+ existingTest.test_id = test.test_id;
410
+ }
411
+ // Keep the most complete test data
412
+ if (test.stack && !existingTest.stack) {
413
+ existingTest.stack = test.stack;
414
+ }
415
+ if (test.message && !existingTest.message) {
416
+ existingTest.message = test.message;
417
+ }
418
+ }
419
+
420
+ // Prefer Test Explorer structure (longer, more complete suite_title)
421
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
422
+ existingTest.suite_title = test.suite_title;
423
+ }
424
+
425
+ // Always use the source file path if available
426
+ if (test.file && test.file.endsWith('.cs')) {
427
+ existingTest.file = test.file;
428
+ } else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
429
+ existingTest.file = this.extractCsFileFromPath(test);
430
+ }
431
+ } else {
432
+ // Fix file path to use proper .cs file names from source paths
433
+ if (!test.file || !test.file.endsWith('.cs')) {
434
+ test.file = this.extractCsFileFromPath(test);
435
+ }
436
+ fqnMap.set(fqn, test);
437
+ }
438
+ });
439
+
440
+ return Array.from(fqnMap.values());
441
+ }
442
+
443
+ generateFQN(test) {
444
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
445
+ // Don't include assembly as it can vary between different test structures
446
+ const namespace = this.extractNamespace(test);
447
+ const className = this.extractClassName(test);
448
+ const methodName = test.title;
449
+
450
+ // Use the most complete namespace.class structure available
451
+ if (test.suite_title && test.suite_title.includes('.')) {
452
+ return `${test.suite_title}.${methodName}`;
453
+ }
454
+
455
+ return `${namespace}.${className}.${methodName}`;
456
+ }
457
+
458
+ generateNormalizedFQN(test) {
459
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
460
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
461
+
462
+ const fullClassName = test.suite_title || '';
463
+ const methodName = test.title;
464
+
465
+ // Extract the most specific namespace.class pattern
466
+ if (fullClassName.includes('.')) {
467
+ const parts = fullClassName.split('.');
468
+
469
+ if (parts.length >= 2) {
470
+ const className = parts[parts.length - 1];
471
+
472
+ // Look for common .NET namespace patterns and normalize them:
473
+ // TestProject.Tests.MyClass -> Tests.MyClass
474
+ // Tests.MyClass -> Tests.MyClass
475
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
476
+
477
+ let normalizedNamespace = '';
478
+ for (let i = parts.length - 2; i >= 0; i--) {
479
+ const part = parts[i];
480
+
481
+ // Build namespace from right to left, excluding project names
482
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
483
+ // Found a test namespace, use it as the normalized namespace
484
+ normalizedNamespace = part;
485
+ break;
486
+ } else if (i === parts.length - 2) {
487
+ // If no test namespace found, use the immediate parent as namespace
488
+ normalizedNamespace = part;
489
+ }
490
+ }
491
+
492
+ return `${normalizedNamespace}.${className}.${methodName}`;
493
+ }
494
+ }
495
+
496
+ // Fallback for simple class names
497
+ return `${fullClassName}.${methodName}`;
498
+ }
499
+
500
+ extractAssemblyName(test) {
501
+ // Extract assembly name from file path or use default
502
+ if (test.file) {
503
+ const parts = test.file.split(/[/\\]/);
504
+ return parts[0] || 'DefaultAssembly';
505
+ }
506
+ return 'DefaultAssembly';
507
+ }
508
+
509
+ extractNamespace(test) {
510
+ // Extract namespace from suite_title or classname
511
+ if (test.suite_title && test.suite_title.includes('.')) {
512
+ const parts = test.suite_title.split('.');
513
+ return parts.slice(0, -1).join('.');
514
+ }
515
+ return test.suite_title || 'DefaultNamespace';
516
+ }
517
+
518
+ extractClassName(test) {
519
+ // Extract class name from suite_title
520
+ if (test.suite_title && test.suite_title.includes('.')) {
521
+ const parts = test.suite_title.split('.');
522
+ return parts[parts.length - 1];
523
+ }
524
+ return test.suite_title || 'DefaultClass';
525
+ }
526
+
527
+ extractCsFileFromPath(test) {
528
+ // Extract .cs file name from source file path, not namespace
529
+ if (test.file) {
530
+ // Look for actual .cs file path patterns
531
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
532
+ if (csFileMatch) {
533
+ return test.file;
534
+ }
535
+
536
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
537
+ const className = this.extractClassName(test);
538
+ const pathParts = test.file.split(/[/\\]/);
539
+ pathParts[pathParts.length - 1] = `${className}.cs`;
540
+ return pathParts.join('/');
541
+ }
542
+
543
+ // Fallback to class name
544
+ const className = this.extractClassName(test);
545
+ return `${className}.cs`;
546
+ }
547
+
322
548
  calculateStats() {
323
549
  this.stats = {
324
550
  ...this.stats,
@@ -341,6 +567,12 @@ class XmlReader {
341
567
  }
342
568
 
343
569
  fetchSourceCode() {
570
+ // Skip source code fetching if disabled
571
+ if (this.disableSourceCodeFetching) {
572
+ debug('Source code fetching is disabled');
573
+ return;
574
+ }
575
+
344
576
  this.tests.forEach(t => {
345
577
  try {
346
578
  const file = this.adapter.getFilePath(t);
@@ -360,9 +592,15 @@ class XmlReader {
360
592
  debug('Failed to open file with the source code', file);
361
593
  return;
362
594
  }
595
+
363
596
  const contents = fs.readFileSync(file).toString();
364
- t.code = fetchSourceCode(contents, { ...t, lang: this.stats.language });
597
+
598
+ // Try original test name first (for parameterized tests), fallback to regular title
599
+ const titleForLookup = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
600
+
601
+ t.code = fetchSourceCode(contents, { ...t, title: titleForLookup, lang: this.stats.language });
365
602
  if (t.code) debug('Fetched code for test %s', t.title);
603
+
366
604
  t.test_id = fetchIdFromCode(t.code, { lang: this.stats.language });
367
605
  if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title);
368
606
  } catch (err) {
@@ -485,7 +723,13 @@ function reduceTestCases(prev, item) {
485
723
  testCases
486
724
  .filter(t => !!t)
487
725
  .forEach(testCaseItem => {
488
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
726
+ // Simple file extraction (version 2.1.1 approach) with fallback to enhanced extraction
727
+ let file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
728
+
729
+ // If no file found with simple approach and we have enhanced extraction enabled, use it
730
+ if (!file && item.filepath) {
731
+ file = extractSourceFilePath(testCaseItem, item);
732
+ }
489
733
 
490
734
  let stack = '';
491
735
  let message = '';
@@ -505,15 +749,31 @@ function reduceTestCases(prev, item) {
505
749
  // SpecFlow config
506
750
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
507
751
  let example = null;
508
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
752
+
753
+ // Smart suite title extraction to avoid duplicates
754
+ const suiteTitle = getSuiteTitle(testCaseItem, item, isParametrized, reduceOptions.suiteOrganization);
509
755
 
510
756
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
511
757
  tags ||= [];
512
758
 
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();
759
+ // Store original test name for enhanced parameter extraction
760
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
761
+
762
+ // Enhanced NUnit-style arguments from <arguments> element
763
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
764
+ const args = Array.isArray(testCaseItem.arguments.arg)
765
+ ? testCaseItem.arguments.arg
766
+ : [testCaseItem.arguments.arg];
767
+ example = args; // Store as array instead of object
768
+ // Remove parameters from title for NUnit tests
769
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
770
+ } else {
771
+ // Simple parameter extraction (version 2.1.1 approach)
772
+ const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
773
+ if (exampleMatches) {
774
+ example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
775
+ title = title.replace(/\(.*?\)/, '').trim();
776
+ }
517
777
  }
518
778
 
519
779
  stack = `${
@@ -568,6 +828,7 @@ function reduceTestCases(prev, item) {
568
828
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
569
829
  status,
570
830
  title,
831
+ originalTestName, // Store original name for enhanced features
571
832
  root_suite_id: TESTOMATIO_SUITE,
572
833
  suite_title: suiteTitle,
573
834
  files,
@@ -577,6 +838,113 @@ function reduceTestCases(prev, item) {
577
838
  return prev;
578
839
  }
579
840
 
841
+ function extractSourceFilePath(testCaseItem, item) {
842
+ // Priority order for file path extraction to match Test Explorer structure:
843
+ // 1. filepath attribute (direct .cs file path from NUnit)
844
+ // 2. fullname (contains full project path)
845
+ // 3. file attribute from test case
846
+ // 4. package (fallback)
847
+
848
+ // NUnit provides filepath attribute with actual .cs file path - use this first
849
+ if (item.filepath) {
850
+ // Clean up Windows/Unix path separators and ensure proper format
851
+ let filePath = item.filepath.replace(/\\/g, '/');
852
+
853
+ // Make relative to current working directory if absolute
854
+ if (path.isAbsolute(item.filepath)) {
855
+ const cwd = process.cwd().replace(/\\/g, '/');
856
+ if (filePath.startsWith(cwd)) {
857
+ filePath = path.relative(cwd, item.filepath).replace(/\\/g, '/');
858
+ } else {
859
+ // Try to extract relative path from common patterns
860
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
861
+ for (const pattern of commonPatterns) {
862
+ const index = filePath.lastIndexOf(pattern);
863
+ if (index !== -1) {
864
+ filePath = filePath.substring(index + 1);
865
+ break;
866
+ }
867
+ }
868
+ }
869
+ }
870
+ return filePath;
871
+ }
872
+
873
+ if (testCaseItem.file) {
874
+ let filePath = testCaseItem.file.replace(/\\/g, '/');
875
+
876
+ // Make relative to current working directory if absolute
877
+ if (path.isAbsolute(testCaseItem.file)) {
878
+ const cwd = process.cwd().replace(/\\/g, '/');
879
+ if (filePath.startsWith(cwd)) {
880
+ filePath = path.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
881
+ } else {
882
+ // Try to extract relative path from common patterns
883
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
884
+ for (const pattern of commonPatterns) {
885
+ const index = filePath.lastIndexOf(pattern);
886
+ if (index !== -1) {
887
+ filePath = filePath.substring(index + 1);
888
+ break;
889
+ }
890
+ }
891
+ }
892
+ }
893
+ return filePath;
894
+ }
895
+
896
+ if (item.fullname) {
897
+ // Extract actual file path from fullname if it contains path separators
898
+ const fullnameParts = item.fullname.split('.');
899
+ if (fullnameParts.length > 2) {
900
+ // For ParameterizedMethod, get the class name (not method name)
901
+ // Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
902
+ let namespaceParts, className;
903
+
904
+ if (item.type === 'ParameterizedMethod') {
905
+ // For parameterized methods, the last part is the method name, second-to-last is class
906
+ namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
907
+ className = fullnameParts[fullnameParts.length - 2]; // Get class name
908
+ } else {
909
+ // For regular classes/fixtures
910
+ namespaceParts = fullnameParts.slice(1, -1); // Skip project name
911
+ className = fullnameParts[fullnameParts.length - 1];
912
+ }
913
+
914
+ return `${namespaceParts.join('/')}/${className}.cs`;
915
+ }
916
+ }
917
+
918
+ if (item.package) return item.package.replace(/\\/g, '/');
919
+
920
+ // Fallback: construct from classname
921
+ if (testCaseItem.classname) {
922
+ const parts = testCaseItem.classname.split('.');
923
+ const className = parts[parts.length - 1];
924
+ const namespacePath = parts.slice(0, -1).join('/');
925
+ return `${namespacePath}/${className}.cs`;
926
+ }
927
+
928
+ return '';
929
+ }
930
+
931
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
932
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
933
+ // Priority: fullname > classname > name
934
+
935
+ if (item.fullname) {
936
+ // Use fullname to maintain Test Explorer structure
937
+ return item.fullname;
938
+ }
939
+
940
+ if (testCaseItem.classname) {
941
+ return testCaseItem.classname;
942
+ }
943
+
944
+ // Fallback to item name but prefer classname structure
945
+ return item.name || testCaseItem.classname || 'UnknownClass';
946
+ }
947
+
580
948
  function processTestSuite(testsuite) {
581
949
  if (!testsuite) return [];
582
950
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -587,11 +955,42 @@ function processTestSuite(testsuite) {
587
955
  suites = [testsuite];
588
956
  }
589
957
 
590
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
958
+ // Simple approach from version 2.1.1 with enhanced processing for complex scenarios
959
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
591
960
 
592
961
  return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
593
962
  }
594
963
 
964
+ function getSuiteTitle(testCaseItem, item, isParametrized, suiteOrganization = 'classname') {
965
+ let suiteTitle;
966
+
967
+ if (suiteOrganization === 'fullpath') {
968
+ // Use full namespace path (old behavior that creates detailed structure)
969
+ if (item.fullname) {
970
+ return item.fullname;
971
+ }
972
+ suiteTitle = testCaseItem.classname || item.name;
973
+ } else {
974
+ // Use classname approach (default - avoids duplicates)
975
+ if (isParametrized) {
976
+ // For parameterized tests, use the class name to group them
977
+ suiteTitle = item.name || testCaseItem.classname;
978
+ } else {
979
+ // For regular tests, prefer classname over fullname to avoid long paths
980
+ suiteTitle = testCaseItem.classname || item.name;
981
+ }
982
+
983
+ // If still no suite title and we have fullname, extract just the class name
984
+ if (!suiteTitle && item.fullname) {
985
+ const fullnameParts = item.fullname.split('.');
986
+ suiteTitle = fullnameParts[fullnameParts.length - 1]; // Just the class name
987
+ }
988
+ }
989
+
990
+ // Fallback
991
+ return suiteTitle || 'UnknownClass';
992
+ }
993
+
595
994
  function fetchProperties(item) {
596
995
  const tags = [];
597
996
  let title = '';