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

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,27 +162,7 @@ class XmlReader {
162
162
  reduceOptions.preferClassname = this.stats.language === 'python';
163
163
  const resultTests = processTestSuite(jsonSuite['test-suite']);
164
164
 
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);
165
+ this.tests = this.tests.concat(resultTests);
186
166
 
187
167
  return {
188
168
  status: result?.toLowerCase(),
@@ -191,7 +171,7 @@ class XmlReader {
191
171
  passed_count: parseInt(passed, 10),
192
172
  failed_count: parseInt(failed, 10),
193
173
  skipped_count: parseInt(inconclusive + skipped, 10),
194
- tests: deduplicatedTests,
174
+ tests: resultTests,
195
175
  };
196
176
  }
197
177
 
@@ -339,194 +319,6 @@ class XmlReader {
339
319
  };
340
320
  }
341
321
 
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
-
530
322
  calculateStats() {
531
323
  this.stats = {
532
324
  ...this.stats,
@@ -565,18 +357,12 @@ class XmlReader {
565
357
  }
566
358
 
567
359
  if (!fs.existsSync(file)) {
568
- debug('Failed to open file with the source code: %s', file);
360
+ debug('Failed to open file with the source code', file);
569
361
  return;
570
362
  }
571
-
572
363
  const contents = fs.readFileSync(file).toString();
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 });
364
+ t.code = fetchSourceCode(contents, { ...t, lang: this.stats.language });
578
365
  if (t.code) debug('Fetched code for test %s', t.title);
579
-
580
366
  t.test_id = fetchIdFromCode(t.code, { lang: this.stats.language });
581
367
  if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title);
582
368
  } catch (err) {
@@ -699,8 +485,7 @@ function reduceTestCases(prev, item) {
699
485
  testCases
700
486
  .filter(t => !!t)
701
487
  .forEach(testCaseItem => {
702
- // Use consistent Test Explorer structure: prioritize fullname for file path
703
- const file = extractSourceFilePath(testCaseItem, item);
488
+ const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
704
489
 
705
490
  let stack = '';
706
491
  let message = '';
@@ -715,37 +500,20 @@ function reduceTestCases(prev, item) {
715
500
  if (!message) message = stack.trim().split('\n')[0];
716
501
 
717
502
  const isParametrized = item.type === 'ParameterizedMethod';
503
+ const preferClassname = reduceOptions.preferClassname || isParametrized;
718
504
 
719
505
  // SpecFlow config
720
506
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
721
507
  let example = null;
722
-
723
- // Use consistent Test Explorer structure for suite title
724
- const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
508
+ const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
725
509
 
726
510
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
727
511
  tags ||= [];
728
512
 
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
- }
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();
749
517
  }
750
518
 
751
519
  stack = `${
@@ -800,7 +568,6 @@ function reduceTestCases(prev, item) {
800
568
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
801
569
  status,
802
570
  title,
803
- originalTestName, // Store original name for parameter-aware FQN generation
804
571
  root_suite_id: TESTOMATIO_SUITE,
805
572
  suite_title: suiteTitle,
806
573
  files,
@@ -810,113 +577,6 @@ function reduceTestCases(prev, item) {
810
577
  return prev;
811
578
  }
812
579
 
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
-
920
580
  function processTestSuite(testsuite) {
921
581
  if (!testsuite) return [];
922
582
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -927,23 +587,9 @@ function processTestSuite(testsuite) {
927
587
  suites = [testsuite];
928
588
  }
929
589
 
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
- }
590
+ const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
945
591
 
946
- return allResults;
592
+ return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
947
593
  }
948
594
 
949
595
  function fetchProperties(item) {