@testomatio/reporter 2.3.4 → 2.3.5-beta-5-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/lib/xmlReader.js CHANGED
@@ -131,7 +131,21 @@ 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
+ // Optional deduplication for complex NUnit scenarios - can be enabled via options
137
+ let finalTests = resultTests;
138
+ if (this.opts.enableNUnitDeduplication) {
139
+ finalTests = this.deduplicateTestsByFQN(resultTests);
140
+ debug('Tests after deduplication:', finalTests.length);
141
+ debug('Deduplicated tests:', finalTests.map(t => ({
142
+ title: t.title,
143
+ examples: t.examples,
144
+ example: t.example,
145
+ file: t.file,
146
+ })));
147
+ }
148
+ this.tests = this.tests.concat(finalTests);
135
149
  return {
136
150
  status: result?.toLowerCase(),
137
151
  create_tests: true,
@@ -139,7 +153,7 @@ class XmlReader {
139
153
  passed_count: parseInt(passed, 10),
140
154
  failed_count: parseInt(failed, 10),
141
155
  skipped_count: parseInt(inconclusive + skipped, 10),
142
- tests: resultTests,
156
+ tests: finalTests,
143
157
  };
144
158
  }
145
159
  processTRX(jsonSuite) {
@@ -275,6 +289,171 @@ class XmlReader {
275
289
  tests,
276
290
  };
277
291
  }
292
+ deduplicateTestsByFQN(tests) {
293
+ const fqnMap = new Map();
294
+ tests.forEach(test => {
295
+ const fqn = this.generateNormalizedFQN(test);
296
+ if (fqnMap.has(fqn)) {
297
+ const existingTest = fqnMap.get(fqn);
298
+ // For parameterized tests, merge as Examples
299
+ if (test.example && Array.isArray(test.example) && test.example.length > 0) {
300
+ // Initialize examples array if it doesn't exist
301
+ if (!existingTest.examples) {
302
+ existingTest.examples = [];
303
+ // Add the existing test's example as the first item if it has parameters
304
+ if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
305
+ existingTest.examples.push({
306
+ parameters: existingTest.example,
307
+ status: existingTest.status,
308
+ run_time: existingTest.run_time,
309
+ message: existingTest.message,
310
+ stack: existingTest.stack,
311
+ });
312
+ // Clear the main test's example since it's now in examples array
313
+ delete existingTest.example;
314
+ }
315
+ }
316
+ // Add this test's execution as an example
317
+ existingTest.examples.push({
318
+ parameters: test.example,
319
+ status: test.status,
320
+ run_time: test.run_time,
321
+ message: test.message,
322
+ stack: test.stack,
323
+ });
324
+ // Update the main test status to reflect the worst status
325
+ if (test.status === 'failed' || existingTest.status === 'failed') {
326
+ existingTest.status = 'failed';
327
+ }
328
+ else if (test.status === 'skipped' && existingTest.status !== 'failed') {
329
+ existingTest.status = 'skipped';
330
+ }
331
+ // Update total run time
332
+ existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
333
+ }
334
+ else {
335
+ // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
336
+ if (test.test_id && !existingTest.test_id) {
337
+ existingTest.test_id = test.test_id;
338
+ }
339
+ // Keep the most complete test data
340
+ if (test.stack && !existingTest.stack) {
341
+ existingTest.stack = test.stack;
342
+ }
343
+ if (test.message && !existingTest.message) {
344
+ existingTest.message = test.message;
345
+ }
346
+ }
347
+ // Prefer Test Explorer structure (longer, more complete suite_title)
348
+ if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
349
+ existingTest.suite_title = test.suite_title;
350
+ }
351
+ // Always use the source file path if available
352
+ if (test.file && test.file.endsWith('.cs')) {
353
+ existingTest.file = test.file;
354
+ }
355
+ else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
356
+ existingTest.file = this.extractCsFileFromPath(test);
357
+ }
358
+ }
359
+ else {
360
+ // Fix file path to use proper .cs file names from source paths
361
+ if (!test.file || !test.file.endsWith('.cs')) {
362
+ test.file = this.extractCsFileFromPath(test);
363
+ }
364
+ fqnMap.set(fqn, test);
365
+ }
366
+ });
367
+ return Array.from(fqnMap.values());
368
+ }
369
+ generateFQN(test) {
370
+ // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
371
+ // Don't include assembly as it can vary between different test structures
372
+ const namespace = this.extractNamespace(test);
373
+ const className = this.extractClassName(test);
374
+ const methodName = test.title;
375
+ // Use the most complete namespace.class structure available
376
+ if (test.suite_title && test.suite_title.includes('.')) {
377
+ return `${test.suite_title}.${methodName}`;
378
+ }
379
+ return `${namespace}.${className}.${methodName}`;
380
+ }
381
+ generateNormalizedFQN(test) {
382
+ // Generate normalized FQN for deduplication by extracting the core namespace.class.method
383
+ // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
384
+ const fullClassName = test.suite_title || '';
385
+ const methodName = test.title;
386
+ // Extract the most specific namespace.class pattern
387
+ if (fullClassName.includes('.')) {
388
+ const parts = fullClassName.split('.');
389
+ if (parts.length >= 2) {
390
+ const className = parts[parts.length - 1];
391
+ // Look for common .NET namespace patterns and normalize them:
392
+ // TestProject.Tests.MyClass -> Tests.MyClass
393
+ // Tests.MyClass -> Tests.MyClass
394
+ // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
395
+ let normalizedNamespace = '';
396
+ for (let i = parts.length - 2; i >= 0; i--) {
397
+ const part = parts[i];
398
+ // Build namespace from right to left, excluding project names
399
+ if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
400
+ // Found a test namespace, use it as the normalized namespace
401
+ normalizedNamespace = part;
402
+ break;
403
+ }
404
+ else if (i === parts.length - 2) {
405
+ // If no test namespace found, use the immediate parent as namespace
406
+ normalizedNamespace = part;
407
+ }
408
+ }
409
+ return `${normalizedNamespace}.${className}.${methodName}`;
410
+ }
411
+ }
412
+ // Fallback for simple class names
413
+ return `${fullClassName}.${methodName}`;
414
+ }
415
+ extractAssemblyName(test) {
416
+ // Extract assembly name from file path or use default
417
+ if (test.file) {
418
+ const parts = test.file.split(/[/\\]/);
419
+ return parts[0] || 'DefaultAssembly';
420
+ }
421
+ return 'DefaultAssembly';
422
+ }
423
+ extractNamespace(test) {
424
+ // Extract namespace from suite_title or classname
425
+ if (test.suite_title && test.suite_title.includes('.')) {
426
+ const parts = test.suite_title.split('.');
427
+ return parts.slice(0, -1).join('.');
428
+ }
429
+ return test.suite_title || 'DefaultNamespace';
430
+ }
431
+ extractClassName(test) {
432
+ // Extract class name from suite_title
433
+ if (test.suite_title && test.suite_title.includes('.')) {
434
+ const parts = test.suite_title.split('.');
435
+ return parts[parts.length - 1];
436
+ }
437
+ return test.suite_title || 'DefaultClass';
438
+ }
439
+ extractCsFileFromPath(test) {
440
+ // Extract .cs file name from source file path, not namespace
441
+ if (test.file) {
442
+ // Look for actual .cs file path patterns
443
+ const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
444
+ if (csFileMatch) {
445
+ return test.file;
446
+ }
447
+ // If no .cs extension, assume it's a namespace path and convert to likely file name
448
+ const className = this.extractClassName(test);
449
+ const pathParts = test.file.split(/[/\\]/);
450
+ pathParts[pathParts.length - 1] = `${className}.cs`;
451
+ return pathParts.join('/');
452
+ }
453
+ // Fallback to class name
454
+ const className = this.extractClassName(test);
455
+ return `${className}.cs`;
456
+ }
278
457
  calculateStats() {
279
458
  this.stats = {
280
459
  ...this.stats,
@@ -324,7 +503,9 @@ class XmlReader {
324
503
  return;
325
504
  }
326
505
  const contents = fs_1.default.readFileSync(file).toString();
327
- t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, lang: this.stats.language });
506
+ // Try original test name first (for parameterized tests), fallback to regular title
507
+ const titleForLookup = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
508
+ t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, title: titleForLookup, lang: this.stats.language });
328
509
  if (t.code)
329
510
  debug('Fetched code for test %s', t.title);
330
511
  t.test_id = (0, utils_js_1.fetchIdFromCode)(t.code, { lang: this.stats.language });
@@ -430,7 +611,12 @@ function reduceTestCases(prev, item) {
430
611
  testCases
431
612
  .filter(t => !!t)
432
613
  .forEach(testCaseItem => {
433
- const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
614
+ // Simple file extraction (version 2.1.1 approach) with fallback to enhanced extraction
615
+ let file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
616
+ // If no file found with simple approach and we have enhanced extraction enabled, use it
617
+ if (!file && item.filepath) {
618
+ file = extractSourceFilePath(testCaseItem, item);
619
+ }
434
620
  let stack = '';
435
621
  let message = '';
436
622
  if (testCaseItem.error)
@@ -454,13 +640,31 @@ function reduceTestCases(prev, item) {
454
640
  // SpecFlow config
455
641
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
456
642
  let example = null;
457
- const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
643
+ // Simple suite title extraction (version 2.1.1 approach) with fallback to enhanced
644
+ let suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
645
+ if (!suiteTitle && item.fullname) {
646
+ suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
647
+ }
458
648
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
459
649
  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();
650
+ // Store original test name for enhanced parameter extraction
651
+ const originalTestName = testCaseItem.name || testCaseItem.methodname;
652
+ // Enhanced NUnit-style arguments from <arguments> element
653
+ if (testCaseItem.arguments && testCaseItem.arguments.arg) {
654
+ const args = Array.isArray(testCaseItem.arguments.arg)
655
+ ? testCaseItem.arguments.arg
656
+ : [testCaseItem.arguments.arg];
657
+ example = args; // Store as array instead of object
658
+ // Remove parameters from title for NUnit tests
659
+ title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
660
+ }
661
+ else {
662
+ // Simple parameter extraction (version 2.1.1 approach)
663
+ const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
664
+ if (exampleMatches) {
665
+ example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
666
+ title = title.replace(/\(.*?\)/, '').trim();
667
+ }
464
668
  }
465
669
  stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
466
670
  if (!testId)
@@ -508,6 +712,7 @@ function reduceTestCases(prev, item) {
508
712
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
509
713
  status,
510
714
  title,
715
+ originalTestName, // Store original name for enhanced features
511
716
  root_suite_id: TESTOMATIO_SUITE,
512
717
  suite_title: suiteTitle,
513
718
  files,
@@ -516,6 +721,102 @@ function reduceTestCases(prev, item) {
516
721
  });
517
722
  return prev;
518
723
  }
724
+ function extractSourceFilePath(testCaseItem, item) {
725
+ // Priority order for file path extraction to match Test Explorer structure:
726
+ // 1. filepath attribute (direct .cs file path from NUnit)
727
+ // 2. fullname (contains full project path)
728
+ // 3. file attribute from test case
729
+ // 4. package (fallback)
730
+ // NUnit provides filepath attribute with actual .cs file path - use this first
731
+ if (item.filepath) {
732
+ // Clean up Windows/Unix path separators and ensure proper format
733
+ let filePath = item.filepath.replace(/\\/g, '/');
734
+ // Make relative to current working directory if absolute
735
+ if (path_1.default.isAbsolute(item.filepath)) {
736
+ const cwd = process.cwd().replace(/\\/g, '/');
737
+ if (filePath.startsWith(cwd)) {
738
+ filePath = path_1.default.relative(cwd, item.filepath).replace(/\\/g, '/');
739
+ }
740
+ else {
741
+ // Try to extract relative path from common patterns
742
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
743
+ for (const pattern of commonPatterns) {
744
+ const index = filePath.lastIndexOf(pattern);
745
+ if (index !== -1) {
746
+ filePath = filePath.substring(index + 1);
747
+ break;
748
+ }
749
+ }
750
+ }
751
+ }
752
+ return filePath;
753
+ }
754
+ if (testCaseItem.file) {
755
+ let filePath = testCaseItem.file.replace(/\\/g, '/');
756
+ // Make relative to current working directory if absolute
757
+ if (path_1.default.isAbsolute(testCaseItem.file)) {
758
+ const cwd = process.cwd().replace(/\\/g, '/');
759
+ if (filePath.startsWith(cwd)) {
760
+ filePath = path_1.default.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
761
+ }
762
+ else {
763
+ // Try to extract relative path from common patterns
764
+ const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
765
+ for (const pattern of commonPatterns) {
766
+ const index = filePath.lastIndexOf(pattern);
767
+ if (index !== -1) {
768
+ filePath = filePath.substring(index + 1);
769
+ break;
770
+ }
771
+ }
772
+ }
773
+ }
774
+ return filePath;
775
+ }
776
+ if (item.fullname) {
777
+ // Extract actual file path from fullname if it contains path separators
778
+ const fullnameParts = item.fullname.split('.');
779
+ if (fullnameParts.length > 2) {
780
+ // For ParameterizedMethod, get the class name (not method name)
781
+ // Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
782
+ let namespaceParts, className;
783
+ if (item.type === 'ParameterizedMethod') {
784
+ // For parameterized methods, the last part is the method name, second-to-last is class
785
+ namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
786
+ className = fullnameParts[fullnameParts.length - 2]; // Get class name
787
+ }
788
+ else {
789
+ // For regular classes/fixtures
790
+ namespaceParts = fullnameParts.slice(1, -1); // Skip project name
791
+ className = fullnameParts[fullnameParts.length - 1];
792
+ }
793
+ return `${namespaceParts.join('/')}/${className}.cs`;
794
+ }
795
+ }
796
+ if (item.package)
797
+ return item.package.replace(/\\/g, '/');
798
+ // Fallback: construct from classname
799
+ if (testCaseItem.classname) {
800
+ const parts = testCaseItem.classname.split('.');
801
+ const className = parts[parts.length - 1];
802
+ const namespacePath = parts.slice(0, -1).join('/');
803
+ return `${namespacePath}/${className}.cs`;
804
+ }
805
+ return '';
806
+ }
807
+ function extractTestExplorerSuiteTitle(testCaseItem, item) {
808
+ // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
809
+ // Priority: fullname > classname > name
810
+ if (item.fullname) {
811
+ // Use fullname to maintain Test Explorer structure
812
+ return item.fullname;
813
+ }
814
+ if (testCaseItem.classname) {
815
+ return testCaseItem.classname;
816
+ }
817
+ // Fallback to item name but prefer classname structure
818
+ return item.name || testCaseItem.classname || 'UnknownClass';
819
+ }
519
820
  function processTestSuite(testsuite) {
520
821
  if (!testsuite)
521
822
  return [];
@@ -527,7 +828,8 @@ function processTestSuite(testsuite) {
527
828
  if (!Array.isArray(testsuite)) {
528
829
  suites = [testsuite];
529
830
  }
530
- const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
831
+ // Simple approach from version 2.1.1 with enhanced processing for complex scenarios
832
+ const subSuites = suites.filter(s => s['test-suite'] && !s['test-case']);
531
833
  return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
532
834
  }
533
835
  function fetchProperties(item) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.3.4",
3
+ "version": "2.3.5-beta-5-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
  }
package/src/pipe/debug.js CHANGED
@@ -15,7 +15,7 @@ export class DebugPipe {
15
15
  this.isEnabled = !!process.env.TESTOMATIO_DEBUG || !!process.env.DEBUG;
16
16
  if (this.isEnabled) {
17
17
  this.batch = {
18
- isEnabled: this.params.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD ?? true,
18
+ isEnabled: this.params.isBatchEnabled ?? (process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD ? false : true),
19
19
  intervalFunction: null,
20
20
  intervalTime: 5000,
21
21
  tests: [],
@@ -93,8 +93,7 @@ export class DebugPipe {
93
93
  const logData = { action: 'addTest', testId: data };
94
94
  if (this.store.runId) logData.runId = this.store.runId;
95
95
  this.logToFile(logData);
96
- }
97
- else this.batch.tests.push(data);
96
+ } else this.batch.tests.push(data);
98
97
 
99
98
  if (!this.batch.intervalFunction) await this.batchUpload();
100
99
  }