@testomatio/reporter 2.3.0 → 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/lib/junit-adapter/csharp.d.ts +0 -1
- package/lib/junit-adapter/csharp.js +36 -7
- package/lib/utils/utils.js +33 -4
- package/lib/xmlReader.d.ts +8 -1
- package/lib/xmlReader.js +317 -13
- package/package.json +1 -1
- package/src/junit-adapter/csharp.js +40 -6
- package/src/utils/utils.js +34 -4
- package/src/xmlReader.js +367 -13
|
@@ -7,24 +7,53 @@ const path_1 = __importDefault(require("path"));
|
|
|
7
7
|
const adapter_js_1 = __importDefault(require("./adapter.js"));
|
|
8
8
|
class CSharpAdapter extends adapter_js_1.default {
|
|
9
9
|
formatTest(t) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (example)
|
|
13
|
-
|
|
10
|
+
// Don't override example if it already exists from NUnit XML processing
|
|
11
|
+
// The xmlReader.js already extracts parameters correctly from <arguments>
|
|
12
|
+
if (!t.example) {
|
|
13
|
+
const title = t.title.replace(/\(.*?\)/, '').trim();
|
|
14
|
+
const exampleMatch = t.title.match(/\((.*?)\)/);
|
|
15
|
+
if (exampleMatch) {
|
|
16
|
+
// Keep as array for consistency with NUnit XML processing
|
|
17
|
+
t.example = exampleMatch[1].split(',').map(param => param.trim());
|
|
18
|
+
}
|
|
19
|
+
t.title = title.trim();
|
|
20
|
+
}
|
|
14
21
|
const suite = t.suite_title.split('.');
|
|
15
22
|
t.suite_title = suite.pop();
|
|
16
23
|
t.file = namespaceToFileName(t.file);
|
|
17
|
-
t.title = title.trim();
|
|
18
24
|
return t;
|
|
19
25
|
}
|
|
20
26
|
getFilePath(t) {
|
|
21
|
-
|
|
27
|
+
if (!t.file)
|
|
28
|
+
return null;
|
|
29
|
+
// Normalize path separators for cross-platform compatibility
|
|
30
|
+
let filePath = t.file.replace(/\\/g, '/');
|
|
31
|
+
// If file already has .cs extension, use it directly
|
|
32
|
+
if (filePath.endsWith('.cs')) {
|
|
33
|
+
// Make relative path if it's absolute
|
|
34
|
+
if (path_1.default.isAbsolute(filePath)) {
|
|
35
|
+
// Try to find project-relative path
|
|
36
|
+
const cwd = process.cwd().replace(/\\/g, '/');
|
|
37
|
+
if (filePath.startsWith(cwd)) {
|
|
38
|
+
filePath = path_1.default.relative(cwd, filePath).replace(/\\/g, '/');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return filePath;
|
|
42
|
+
}
|
|
43
|
+
// Convert namespace path to file path
|
|
44
|
+
const fileName = namespaceToFileName(filePath);
|
|
22
45
|
return fileName;
|
|
23
46
|
}
|
|
24
47
|
}
|
|
25
48
|
module.exports = CSharpAdapter;
|
|
26
49
|
function namespaceToFileName(fileName) {
|
|
50
|
+
if (!fileName)
|
|
51
|
+
return '';
|
|
52
|
+
// If already a .cs file path, clean it up
|
|
53
|
+
if (fileName.endsWith('.cs')) {
|
|
54
|
+
return fileName.replace(/\\/g, '/');
|
|
55
|
+
}
|
|
27
56
|
const fileParts = fileName.split('.');
|
|
28
57
|
fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
|
|
29
|
-
return `${fileParts.join(
|
|
58
|
+
return `${fileParts.join('/')}.cs`;
|
|
30
59
|
}
|
package/lib/utils/utils.js
CHANGED
|
@@ -172,6 +172,8 @@ exports.fetchSourceCodeFromStackTrace = fetchSourceCodeFromStackTrace;
|
|
|
172
172
|
exports.TEST_ID_REGEX = /@T([\w\d]{8})/;
|
|
173
173
|
exports.SUITE_ID_REGEX = /@S([\w\d]{8})/;
|
|
174
174
|
const fetchIdFromCode = (code, opts = {}) => {
|
|
175
|
+
if (!code)
|
|
176
|
+
return null;
|
|
175
177
|
const comments = code
|
|
176
178
|
.split('\n')
|
|
177
179
|
.map(l => l.trim())
|
|
@@ -214,10 +216,29 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
214
216
|
lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
215
217
|
}
|
|
216
218
|
else if (opts.lang === 'csharp') {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (lineIndex === -1)
|
|
219
|
+
// Enhanced C# method detection for NUnit tests
|
|
220
|
+
lineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
|
|
221
|
+
if (lineIndex === -1) {
|
|
222
|
+
lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
|
|
223
|
+
}
|
|
224
|
+
if (lineIndex === -1) {
|
|
220
225
|
lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
226
|
+
}
|
|
227
|
+
// Look for TestCase or Test attributes above the method
|
|
228
|
+
if (lineIndex === -1) {
|
|
229
|
+
const testAttributeIndex = lines.findIndex((l, index) => {
|
|
230
|
+
if (l.includes('[TestCase') || l.includes('[Test')) {
|
|
231
|
+
// Check next few lines for the method
|
|
232
|
+
const nextLines = lines.slice(index, Math.min(lines.length, index + 5));
|
|
233
|
+
const hasMethod = nextLines.some(nextLine => nextLine.includes(`${title}(`));
|
|
234
|
+
return hasMethod;
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
});
|
|
238
|
+
if (testAttributeIndex !== -1) {
|
|
239
|
+
lineIndex = testAttributeIndex;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
221
242
|
}
|
|
222
243
|
else {
|
|
223
244
|
lineIndex = lines.findIndex(l => l.includes(title));
|
|
@@ -226,7 +247,7 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
226
247
|
if (opts.prepend) {
|
|
227
248
|
lineIndex -= opts.prepend;
|
|
228
249
|
}
|
|
229
|
-
if (lineIndex) {
|
|
250
|
+
if (lineIndex !== -1 && lineIndex !== undefined) {
|
|
230
251
|
const result = [];
|
|
231
252
|
for (let i = lineIndex; i < lineIndex + limit; i++) {
|
|
232
253
|
if (lines[i] === undefined)
|
|
@@ -269,6 +290,14 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
269
290
|
break;
|
|
270
291
|
if (opts.lang === 'java' && lines[i].includes(' class '))
|
|
271
292
|
break;
|
|
293
|
+
if (opts.lang === 'csharp' && lines[i].trim().match(/^\[Test/))
|
|
294
|
+
break;
|
|
295
|
+
if (opts.lang === 'csharp' && lines[i].includes(' public void '))
|
|
296
|
+
break;
|
|
297
|
+
if (opts.lang === 'csharp' && lines[i].includes(' public async Task '))
|
|
298
|
+
break;
|
|
299
|
+
if (opts.lang === 'csharp' && lines[i].includes(' class ') && lines[i].includes('public'))
|
|
300
|
+
break;
|
|
272
301
|
}
|
|
273
302
|
result.push(lines[i]);
|
|
274
303
|
}
|
package/lib/xmlReader.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ declare class XmlReader {
|
|
|
46
46
|
passed_count: number;
|
|
47
47
|
skipped_count: number;
|
|
48
48
|
status: string;
|
|
49
|
-
tests: any
|
|
49
|
+
tests: any;
|
|
50
50
|
tests_count: number;
|
|
51
51
|
};
|
|
52
52
|
processNUnit(jsonSuite: any): {
|
|
@@ -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,18 @@ 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
|
+
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
|
+
// Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
|
|
137
|
+
const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
|
|
138
|
+
debug('Tests after deduplication:', deduplicatedTests.length);
|
|
139
|
+
debug('Deduplicated tests:', deduplicatedTests.map(t => ({
|
|
140
|
+
title: t.title,
|
|
141
|
+
examples: t.examples,
|
|
142
|
+
example: t.example,
|
|
143
|
+
file: t.file,
|
|
144
|
+
})));
|
|
145
|
+
this.tests = this.tests.concat(deduplicatedTests);
|
|
135
146
|
return {
|
|
136
147
|
status: result?.toLowerCase(),
|
|
137
148
|
create_tests: true,
|
|
@@ -139,7 +150,7 @@ class XmlReader {
|
|
|
139
150
|
passed_count: parseInt(passed, 10),
|
|
140
151
|
failed_count: parseInt(failed, 10),
|
|
141
152
|
skipped_count: parseInt(inconclusive + skipped, 10),
|
|
142
|
-
tests:
|
|
153
|
+
tests: deduplicatedTests,
|
|
143
154
|
};
|
|
144
155
|
}
|
|
145
156
|
processTRX(jsonSuite) {
|
|
@@ -275,6 +286,171 @@ class XmlReader {
|
|
|
275
286
|
tests,
|
|
276
287
|
};
|
|
277
288
|
}
|
|
289
|
+
deduplicateTestsByFQN(tests) {
|
|
290
|
+
const fqnMap = new Map();
|
|
291
|
+
tests.forEach(test => {
|
|
292
|
+
const fqn = this.generateNormalizedFQN(test);
|
|
293
|
+
if (fqnMap.has(fqn)) {
|
|
294
|
+
const existingTest = fqnMap.get(fqn);
|
|
295
|
+
// For parameterized tests, merge as Examples
|
|
296
|
+
if (test.example && Array.isArray(test.example) && test.example.length > 0) {
|
|
297
|
+
// Initialize examples array if it doesn't exist
|
|
298
|
+
if (!existingTest.examples) {
|
|
299
|
+
existingTest.examples = [];
|
|
300
|
+
// Add the existing test's example as the first item if it has parameters
|
|
301
|
+
if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
|
|
302
|
+
existingTest.examples.push({
|
|
303
|
+
parameters: existingTest.example,
|
|
304
|
+
status: existingTest.status,
|
|
305
|
+
run_time: existingTest.run_time,
|
|
306
|
+
message: existingTest.message,
|
|
307
|
+
stack: existingTest.stack,
|
|
308
|
+
});
|
|
309
|
+
// Clear the main test's example since it's now in examples array
|
|
310
|
+
delete existingTest.example;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Add this test's execution as an example
|
|
314
|
+
existingTest.examples.push({
|
|
315
|
+
parameters: test.example,
|
|
316
|
+
status: test.status,
|
|
317
|
+
run_time: test.run_time,
|
|
318
|
+
message: test.message,
|
|
319
|
+
stack: test.stack,
|
|
320
|
+
});
|
|
321
|
+
// Update the main test status to reflect the worst status
|
|
322
|
+
if (test.status === 'failed' || existingTest.status === 'failed') {
|
|
323
|
+
existingTest.status = 'failed';
|
|
324
|
+
}
|
|
325
|
+
else if (test.status === 'skipped' && existingTest.status !== 'failed') {
|
|
326
|
+
existingTest.status = 'skipped';
|
|
327
|
+
}
|
|
328
|
+
// Update total run time
|
|
329
|
+
existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
|
|
333
|
+
if (test.test_id && !existingTest.test_id) {
|
|
334
|
+
existingTest.test_id = test.test_id;
|
|
335
|
+
}
|
|
336
|
+
// Keep the most complete test data
|
|
337
|
+
if (test.stack && !existingTest.stack) {
|
|
338
|
+
existingTest.stack = test.stack;
|
|
339
|
+
}
|
|
340
|
+
if (test.message && !existingTest.message) {
|
|
341
|
+
existingTest.message = test.message;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Prefer Test Explorer structure (longer, more complete suite_title)
|
|
345
|
+
if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
|
|
346
|
+
existingTest.suite_title = test.suite_title;
|
|
347
|
+
}
|
|
348
|
+
// Always use the source file path if available
|
|
349
|
+
if (test.file && test.file.endsWith('.cs')) {
|
|
350
|
+
existingTest.file = test.file;
|
|
351
|
+
}
|
|
352
|
+
else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
|
|
353
|
+
existingTest.file = this.extractCsFileFromPath(test);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// Fix file path to use proper .cs file names from source paths
|
|
358
|
+
if (!test.file || !test.file.endsWith('.cs')) {
|
|
359
|
+
test.file = this.extractCsFileFromPath(test);
|
|
360
|
+
}
|
|
361
|
+
fqnMap.set(fqn, test);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
return Array.from(fqnMap.values());
|
|
365
|
+
}
|
|
366
|
+
generateFQN(test) {
|
|
367
|
+
// Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
|
|
368
|
+
// Don't include assembly as it can vary between different test structures
|
|
369
|
+
const namespace = this.extractNamespace(test);
|
|
370
|
+
const className = this.extractClassName(test);
|
|
371
|
+
const methodName = test.title;
|
|
372
|
+
// Use the most complete namespace.class structure available
|
|
373
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
374
|
+
return `${test.suite_title}.${methodName}`;
|
|
375
|
+
}
|
|
376
|
+
return `${namespace}.${className}.${methodName}`;
|
|
377
|
+
}
|
|
378
|
+
generateNormalizedFQN(test) {
|
|
379
|
+
// Generate normalized FQN for deduplication by extracting the core namespace.class.method
|
|
380
|
+
// For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
|
|
381
|
+
const fullClassName = test.suite_title || '';
|
|
382
|
+
const methodName = test.title;
|
|
383
|
+
// Extract the most specific namespace.class pattern
|
|
384
|
+
if (fullClassName.includes('.')) {
|
|
385
|
+
const parts = fullClassName.split('.');
|
|
386
|
+
if (parts.length >= 2) {
|
|
387
|
+
const className = parts[parts.length - 1];
|
|
388
|
+
// Look for common .NET namespace patterns and normalize them:
|
|
389
|
+
// TestProject.Tests.MyClass -> Tests.MyClass
|
|
390
|
+
// Tests.MyClass -> Tests.MyClass
|
|
391
|
+
// MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
|
|
392
|
+
let normalizedNamespace = '';
|
|
393
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
394
|
+
const part = parts[i];
|
|
395
|
+
// Build namespace from right to left, excluding project names
|
|
396
|
+
if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
|
|
397
|
+
// Found a test namespace, use it as the normalized namespace
|
|
398
|
+
normalizedNamespace = part;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
else if (i === parts.length - 2) {
|
|
402
|
+
// If no test namespace found, use the immediate parent as namespace
|
|
403
|
+
normalizedNamespace = part;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return `${normalizedNamespace}.${className}.${methodName}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Fallback for simple class names
|
|
410
|
+
return `${fullClassName}.${methodName}`;
|
|
411
|
+
}
|
|
412
|
+
extractAssemblyName(test) {
|
|
413
|
+
// Extract assembly name from file path or use default
|
|
414
|
+
if (test.file) {
|
|
415
|
+
const parts = test.file.split(/[/\\]/);
|
|
416
|
+
return parts[0] || 'DefaultAssembly';
|
|
417
|
+
}
|
|
418
|
+
return 'DefaultAssembly';
|
|
419
|
+
}
|
|
420
|
+
extractNamespace(test) {
|
|
421
|
+
// Extract namespace from suite_title or classname
|
|
422
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
423
|
+
const parts = test.suite_title.split('.');
|
|
424
|
+
return parts.slice(0, -1).join('.');
|
|
425
|
+
}
|
|
426
|
+
return test.suite_title || 'DefaultNamespace';
|
|
427
|
+
}
|
|
428
|
+
extractClassName(test) {
|
|
429
|
+
// Extract class name from suite_title
|
|
430
|
+
if (test.suite_title && test.suite_title.includes('.')) {
|
|
431
|
+
const parts = test.suite_title.split('.');
|
|
432
|
+
return parts[parts.length - 1];
|
|
433
|
+
}
|
|
434
|
+
return test.suite_title || 'DefaultClass';
|
|
435
|
+
}
|
|
436
|
+
extractCsFileFromPath(test) {
|
|
437
|
+
// Extract .cs file name from source file path, not namespace
|
|
438
|
+
if (test.file) {
|
|
439
|
+
// Look for actual .cs file path patterns
|
|
440
|
+
const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
|
|
441
|
+
if (csFileMatch) {
|
|
442
|
+
return test.file;
|
|
443
|
+
}
|
|
444
|
+
// If no .cs extension, assume it's a namespace path and convert to likely file name
|
|
445
|
+
const className = this.extractClassName(test);
|
|
446
|
+
const pathParts = test.file.split(/[/\\]/);
|
|
447
|
+
pathParts[pathParts.length - 1] = `${className}.cs`;
|
|
448
|
+
return pathParts.join('/');
|
|
449
|
+
}
|
|
450
|
+
// Fallback to class name
|
|
451
|
+
const className = this.extractClassName(test);
|
|
452
|
+
return `${className}.cs`;
|
|
453
|
+
}
|
|
278
454
|
calculateStats() {
|
|
279
455
|
this.stats = {
|
|
280
456
|
...this.stats,
|
|
@@ -320,11 +496,13 @@ class XmlReader {
|
|
|
320
496
|
this.stats.language = 'csharp';
|
|
321
497
|
}
|
|
322
498
|
if (!fs_1.default.existsSync(file)) {
|
|
323
|
-
debug('Failed to open file with the source code', file);
|
|
499
|
+
debug('Failed to open file with the source code: %s', file);
|
|
324
500
|
return;
|
|
325
501
|
}
|
|
326
502
|
const contents = fs_1.default.readFileSync(file).toString();
|
|
327
|
-
|
|
503
|
+
// Use original test name for source code lookup, not humanized title
|
|
504
|
+
const originalTitle = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
|
|
505
|
+
t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, title: originalTitle, lang: this.stats.language });
|
|
328
506
|
if (t.code)
|
|
329
507
|
debug('Fetched code for test %s', t.title);
|
|
330
508
|
t.test_id = (0, utils_js_1.fetchIdFromCode)(t.code, { lang: this.stats.language });
|
|
@@ -430,7 +608,8 @@ function reduceTestCases(prev, item) {
|
|
|
430
608
|
testCases
|
|
431
609
|
.filter(t => !!t)
|
|
432
610
|
.forEach(testCaseItem => {
|
|
433
|
-
|
|
611
|
+
// Use consistent Test Explorer structure: prioritize fullname for file path
|
|
612
|
+
const file = extractSourceFilePath(testCaseItem, item);
|
|
434
613
|
let stack = '';
|
|
435
614
|
let message = '';
|
|
436
615
|
if (testCaseItem.error)
|
|
@@ -450,17 +629,33 @@ function reduceTestCases(prev, item) {
|
|
|
450
629
|
if (!message)
|
|
451
630
|
message = stack.trim().split('\n')[0];
|
|
452
631
|
const isParametrized = item.type === 'ParameterizedMethod';
|
|
453
|
-
const preferClassname = reduceOptions.preferClassname || isParametrized;
|
|
454
632
|
// SpecFlow config
|
|
455
633
|
let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
|
|
456
634
|
let example = null;
|
|
457
|
-
|
|
635
|
+
// Use consistent Test Explorer structure for suite title
|
|
636
|
+
const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
|
|
458
637
|
title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
|
|
459
638
|
tags ||= [];
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
639
|
+
// Store original test name for parameter extraction
|
|
640
|
+
const originalTestName = testCaseItem.name || testCaseItem.methodname;
|
|
641
|
+
// Handle NUnit-style arguments from <arguments> element
|
|
642
|
+
if (testCaseItem.arguments && testCaseItem.arguments.arg) {
|
|
643
|
+
const args = Array.isArray(testCaseItem.arguments.arg)
|
|
644
|
+
? testCaseItem.arguments.arg
|
|
645
|
+
: [testCaseItem.arguments.arg];
|
|
646
|
+
example = args; // Store as array instead of object
|
|
647
|
+
// Remove parameters from title for NUnit tests
|
|
648
|
+
title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
// Fallback to parsing parameters from test name (SpecFlow, etc.)
|
|
652
|
+
const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
|
|
653
|
+
if (exampleMatches) {
|
|
654
|
+
// Extract and store parameters as Examples
|
|
655
|
+
const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
|
|
656
|
+
example = parameterValues;
|
|
657
|
+
title = title.replace(/\(.*?\)/, '').trim();
|
|
658
|
+
}
|
|
464
659
|
}
|
|
465
660
|
stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
|
|
466
661
|
if (!testId)
|
|
@@ -508,6 +703,7 @@ function reduceTestCases(prev, item) {
|
|
|
508
703
|
run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
|
|
509
704
|
status,
|
|
510
705
|
title,
|
|
706
|
+
originalTestName, // Store original name for parameter-aware FQN generation
|
|
511
707
|
root_suite_id: TESTOMATIO_SUITE,
|
|
512
708
|
suite_title: suiteTitle,
|
|
513
709
|
files,
|
|
@@ -516,6 +712,102 @@ function reduceTestCases(prev, item) {
|
|
|
516
712
|
});
|
|
517
713
|
return prev;
|
|
518
714
|
}
|
|
715
|
+
function extractSourceFilePath(testCaseItem, item) {
|
|
716
|
+
// Priority order for file path extraction to match Test Explorer structure:
|
|
717
|
+
// 1. filepath attribute (direct .cs file path from NUnit)
|
|
718
|
+
// 2. fullname (contains full project path)
|
|
719
|
+
// 3. file attribute from test case
|
|
720
|
+
// 4. package (fallback)
|
|
721
|
+
// NUnit provides filepath attribute with actual .cs file path - use this first
|
|
722
|
+
if (item.filepath) {
|
|
723
|
+
// Clean up Windows/Unix path separators and ensure proper format
|
|
724
|
+
let filePath = item.filepath.replace(/\\/g, '/');
|
|
725
|
+
// Make relative to current working directory if absolute
|
|
726
|
+
if (path_1.default.isAbsolute(item.filepath)) {
|
|
727
|
+
const cwd = process.cwd().replace(/\\/g, '/');
|
|
728
|
+
if (filePath.startsWith(cwd)) {
|
|
729
|
+
filePath = path_1.default.relative(cwd, item.filepath).replace(/\\/g, '/');
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
// Try to extract relative path from common patterns
|
|
733
|
+
const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
|
|
734
|
+
for (const pattern of commonPatterns) {
|
|
735
|
+
const index = filePath.lastIndexOf(pattern);
|
|
736
|
+
if (index !== -1) {
|
|
737
|
+
filePath = filePath.substring(index + 1);
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return filePath;
|
|
744
|
+
}
|
|
745
|
+
if (testCaseItem.file) {
|
|
746
|
+
let filePath = testCaseItem.file.replace(/\\/g, '/');
|
|
747
|
+
// Make relative to current working directory if absolute
|
|
748
|
+
if (path_1.default.isAbsolute(testCaseItem.file)) {
|
|
749
|
+
const cwd = process.cwd().replace(/\\/g, '/');
|
|
750
|
+
if (filePath.startsWith(cwd)) {
|
|
751
|
+
filePath = path_1.default.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
// Try to extract relative path from common patterns
|
|
755
|
+
const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
|
|
756
|
+
for (const pattern of commonPatterns) {
|
|
757
|
+
const index = filePath.lastIndexOf(pattern);
|
|
758
|
+
if (index !== -1) {
|
|
759
|
+
filePath = filePath.substring(index + 1);
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return filePath;
|
|
766
|
+
}
|
|
767
|
+
if (item.fullname) {
|
|
768
|
+
// Extract actual file path from fullname if it contains path separators
|
|
769
|
+
const fullnameParts = item.fullname.split('.');
|
|
770
|
+
if (fullnameParts.length > 2) {
|
|
771
|
+
// For ParameterizedMethod, get the class name (not method name)
|
|
772
|
+
// Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
|
|
773
|
+
let namespaceParts, className;
|
|
774
|
+
if (item.type === 'ParameterizedMethod') {
|
|
775
|
+
// For parameterized methods, the last part is the method name, second-to-last is class
|
|
776
|
+
namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
|
|
777
|
+
className = fullnameParts[fullnameParts.length - 2]; // Get class name
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
// For regular classes/fixtures
|
|
781
|
+
namespaceParts = fullnameParts.slice(1, -1); // Skip project name
|
|
782
|
+
className = fullnameParts[fullnameParts.length - 1];
|
|
783
|
+
}
|
|
784
|
+
return `${namespaceParts.join('/')}/${className}.cs`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (item.package)
|
|
788
|
+
return item.package.replace(/\\/g, '/');
|
|
789
|
+
// Fallback: construct from classname
|
|
790
|
+
if (testCaseItem.classname) {
|
|
791
|
+
const parts = testCaseItem.classname.split('.');
|
|
792
|
+
const className = parts[parts.length - 1];
|
|
793
|
+
const namespacePath = parts.slice(0, -1).join('/');
|
|
794
|
+
return `${namespacePath}/${className}.cs`;
|
|
795
|
+
}
|
|
796
|
+
return '';
|
|
797
|
+
}
|
|
798
|
+
function extractTestExplorerSuiteTitle(testCaseItem, item) {
|
|
799
|
+
// Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
|
|
800
|
+
// Priority: fullname > classname > name
|
|
801
|
+
if (item.fullname) {
|
|
802
|
+
// Use fullname to maintain Test Explorer structure
|
|
803
|
+
return item.fullname;
|
|
804
|
+
}
|
|
805
|
+
if (testCaseItem.classname) {
|
|
806
|
+
return testCaseItem.classname;
|
|
807
|
+
}
|
|
808
|
+
// Fallback to item name but prefer classname structure
|
|
809
|
+
return item.name || testCaseItem.classname || 'UnknownClass';
|
|
810
|
+
}
|
|
519
811
|
function processTestSuite(testsuite) {
|
|
520
812
|
if (!testsuite)
|
|
521
813
|
return [];
|
|
@@ -527,8 +819,20 @@ function processTestSuite(testsuite) {
|
|
|
527
819
|
if (!Array.isArray(testsuite)) {
|
|
528
820
|
suites = [testsuite];
|
|
529
821
|
}
|
|
530
|
-
|
|
531
|
-
|
|
822
|
+
let allResults = [];
|
|
823
|
+
for (const suite of suites) {
|
|
824
|
+
// Process child test suites recursively (TestFixture, ParameterizedMethod, etc.)
|
|
825
|
+
if (suite['test-suite']) {
|
|
826
|
+
const childResults = processTestSuite(suite['test-suite']);
|
|
827
|
+
allResults = allResults.concat(childResults);
|
|
828
|
+
}
|
|
829
|
+
// Process direct test cases in this suite
|
|
830
|
+
if (suite['test-case'] || suite.testcase) {
|
|
831
|
+
const leafResults = reduceTestCases([], suite);
|
|
832
|
+
allResults = allResults.concat(leafResults);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return allResults;
|
|
532
836
|
}
|
|
533
837
|
function fetchProperties(item) {
|
|
534
838
|
const tags = [];
|
package/package.json
CHANGED
|
@@ -3,18 +3,45 @@ import Adapter from './adapter.js';
|
|
|
3
3
|
|
|
4
4
|
class CSharpAdapter extends Adapter {
|
|
5
5
|
formatTest(t) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (
|
|
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
|
-
|
|
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(
|
|
61
|
+
return `${fileParts.join('/')}.cs`;
|
|
28
62
|
}
|
package/src/utils/utils.js
CHANGED
|
@@ -139,6 +139,8 @@ export const TEST_ID_REGEX = /@T([\w\d]{8})/;
|
|
|
139
139
|
export const SUITE_ID_REGEX = /@S([\w\d]{8})/;
|
|
140
140
|
|
|
141
141
|
const fetchIdFromCode = (code, opts = {}) => {
|
|
142
|
+
if (!code) return null;
|
|
143
|
+
|
|
142
144
|
const comments = code
|
|
143
145
|
.split('\n')
|
|
144
146
|
.map(l => l.trim())
|
|
@@ -180,8 +182,32 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
180
182
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
181
183
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
182
184
|
} else if (opts.lang === 'csharp') {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
// Enhanced C# method detection for NUnit tests
|
|
186
|
+
lineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
|
|
187
|
+
|
|
188
|
+
if (lineIndex === -1) {
|
|
189
|
+
lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (lineIndex === -1) {
|
|
193
|
+
lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Look for TestCase or Test attributes above the method
|
|
197
|
+
if (lineIndex === -1) {
|
|
198
|
+
const testAttributeIndex = lines.findIndex((l, index) => {
|
|
199
|
+
if (l.includes('[TestCase') || l.includes('[Test')) {
|
|
200
|
+
// Check next few lines for the method
|
|
201
|
+
const nextLines = lines.slice(index, Math.min(lines.length, index + 5));
|
|
202
|
+
const hasMethod = nextLines.some(nextLine => nextLine.includes(`${title}(`));
|
|
203
|
+
return hasMethod;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
});
|
|
207
|
+
if (testAttributeIndex !== -1) {
|
|
208
|
+
lineIndex = testAttributeIndex;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
185
211
|
} else {
|
|
186
212
|
lineIndex = lines.findIndex(l => l.includes(title));
|
|
187
213
|
}
|
|
@@ -191,7 +217,7 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
191
217
|
lineIndex -= opts.prepend;
|
|
192
218
|
}
|
|
193
219
|
|
|
194
|
-
if (lineIndex) {
|
|
220
|
+
if (lineIndex !== -1 && lineIndex !== undefined) {
|
|
195
221
|
const result = [];
|
|
196
222
|
for (let i = lineIndex; i < lineIndex + limit; i++) {
|
|
197
223
|
if (lines[i] === undefined) continue;
|
|
@@ -216,6 +242,10 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
216
242
|
if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
|
|
217
243
|
if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
|
|
218
244
|
if (opts.lang === 'java' && lines[i].includes(' class ')) break;
|
|
245
|
+
if (opts.lang === 'csharp' && lines[i].trim().match(/^\[Test/)) break;
|
|
246
|
+
if (opts.lang === 'csharp' && lines[i].includes(' public void ')) break;
|
|
247
|
+
if (opts.lang === 'csharp' && lines[i].includes(' public async Task ')) break;
|
|
248
|
+
if (opts.lang === 'csharp' && lines[i].includes(' class ') && lines[i].includes('public')) break;
|
|
219
249
|
}
|
|
220
250
|
result.push(lines[i]);
|
|
221
251
|
}
|
|
@@ -361,7 +391,7 @@ function readLatestRunId() {
|
|
|
361
391
|
try {
|
|
362
392
|
const filePath = path.join(os.tmpdir(), `testomatio.latest.run`);
|
|
363
393
|
if (!fs.existsSync(filePath)) return null;
|
|
364
|
-
|
|
394
|
+
|
|
365
395
|
const stats = fs.statSync(filePath);
|
|
366
396
|
const diff = +new Date() - +stats.mtime;
|
|
367
397
|
const diffHours = diff / 1000 / 60 / 60;
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
|
946
|
+
return allResults;
|
|
593
947
|
}
|
|
594
948
|
|
|
595
949
|
function fetchProperties(item) {
|