@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.
- package/lib/xmlReader.d.ts +7 -0
- package/lib/xmlReader.js +250 -11
- package/package.json +1 -1
- package/src/xmlReader.js +287 -11
package/lib/xmlReader.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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 [...
|
|
868
|
+
return [...childResults, ...leafResults];
|
|
593
869
|
}
|
|
594
870
|
|
|
595
871
|
function fetchProperties(item) {
|