@testomatio/reporter 2.3.2-beta.3-xml-import → 2.3.2
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/adapter/playwright.d.ts +6 -0
- package/lib/adapter/playwright.js +3 -0
- package/lib/junit-adapter/csharp.d.ts +1 -0
- package/lib/junit-adapter/csharp.js +7 -36
- package/lib/utils/utils.d.ts +2 -2
- package/lib/utils/utils.js +14 -35
- package/lib/xmlReader.d.ts +1 -8
- package/lib/xmlReader.js +13 -317
- package/package.json +2 -2
- package/src/adapter/playwright.js +1 -0
- package/src/junit-adapter/csharp.js +6 -40
- package/src/utils/utils.js +10 -35
- package/src/xmlReader.js +13 -367
|
@@ -11,4 +11,10 @@ declare class PlaywrightReporter {
|
|
|
11
11
|
onEnd(result: any): Promise<void>;
|
|
12
12
|
#private;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Extracts and normalizes tags from test title, test options, and suite level
|
|
16
|
+
* @param {*} test - testInfo object from Playwright
|
|
17
|
+
* @returns {string[]} - array of normalized tags
|
|
18
|
+
*/
|
|
19
|
+
export function extractTags(test: any): string[];
|
|
14
20
|
import TestomatioClient from '../client.js';
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.extractTags = extractTags;
|
|
6
7
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
7
8
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
9
|
const os_1 = __importDefault(require("os"));
|
|
@@ -253,3 +254,5 @@ function getTestContextName(test) {
|
|
|
253
254
|
return `${test._requireFile || ''}_${test.title}`;
|
|
254
255
|
}
|
|
255
256
|
module.exports = PlaywrightReporter;
|
|
257
|
+
|
|
258
|
+
module.exports.extractTags = extractTags;
|
|
@@ -7,53 +7,24 @@ 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 (
|
|
13
|
-
|
|
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
|
-
}
|
|
10
|
+
const title = t.title.replace(/\(.*?\)/, '').trim();
|
|
11
|
+
const example = t.title.match(/\((.*?)\)/);
|
|
12
|
+
if (example)
|
|
13
|
+
t.example = { ...example[1].split(',') };
|
|
21
14
|
const suite = t.suite_title.split('.');
|
|
22
15
|
t.suite_title = suite.pop();
|
|
23
16
|
t.file = namespaceToFileName(t.file);
|
|
17
|
+
t.title = title.trim();
|
|
24
18
|
return t;
|
|
25
19
|
}
|
|
26
20
|
getFilePath(t) {
|
|
27
|
-
|
|
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);
|
|
21
|
+
const fileName = namespaceToFileName(t.file);
|
|
45
22
|
return fileName;
|
|
46
23
|
}
|
|
47
24
|
}
|
|
48
25
|
module.exports = CSharpAdapter;
|
|
49
26
|
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
|
-
}
|
|
56
27
|
const fileParts = fileName.split('.');
|
|
57
28
|
fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
|
|
58
|
-
return `${fileParts.join(
|
|
29
|
+
return `${fileParts.join(path_1.default.sep)}.cs`;
|
|
59
30
|
}
|
package/lib/utils/utils.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export function getPackageVersion(): any;
|
|
|
2
2
|
export const TEST_ID_REGEX: RegExp;
|
|
3
3
|
export const SUITE_ID_REGEX: RegExp;
|
|
4
4
|
export function ansiRegExp(): RegExp;
|
|
5
|
-
export function cleanLatestRunId():
|
|
5
|
+
export function cleanLatestRunId(): any;
|
|
6
6
|
export function isSameTest(test: any, t: any): boolean;
|
|
7
7
|
export function fetchSourceCode(contents: any, opts?: {}): string;
|
|
8
8
|
export function fetchSourceCodeFromStackTrace(stack?: string): string;
|
|
@@ -47,7 +47,7 @@ export function removeColorCodes(input: any): any;
|
|
|
47
47
|
* @returns {String|null} testInfo as one string
|
|
48
48
|
*/
|
|
49
49
|
export function specificTestInfo(test: any): string | null;
|
|
50
|
-
export function storeRunId(runId: any):
|
|
50
|
+
export function storeRunId(runId: any): any;
|
|
51
51
|
export namespace testRunnerHelper {
|
|
52
52
|
function getNameOfCurrentlyRunningTest(): any;
|
|
53
53
|
}
|
package/lib/utils/utils.js
CHANGED
|
@@ -172,8 +172,6 @@ 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;
|
|
177
175
|
const comments = code
|
|
178
176
|
.split('\n')
|
|
179
177
|
.map(l => l.trim())
|
|
@@ -216,29 +214,10 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
216
214
|
lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
217
215
|
}
|
|
218
216
|
else if (opts.lang === 'csharp') {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (lineIndex === -1)
|
|
222
|
-
lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
|
|
223
|
-
}
|
|
224
|
-
if (lineIndex === -1) {
|
|
217
|
+
if (lineIndex === -1)
|
|
218
|
+
lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
219
|
+
if (lineIndex === -1)
|
|
225
220
|
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
|
-
}
|
|
242
221
|
}
|
|
243
222
|
else {
|
|
244
223
|
lineIndex = lines.findIndex(l => l.includes(title));
|
|
@@ -247,7 +226,7 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
247
226
|
if (opts.prepend) {
|
|
248
227
|
lineIndex -= opts.prepend;
|
|
249
228
|
}
|
|
250
|
-
if (lineIndex
|
|
229
|
+
if (lineIndex) {
|
|
251
230
|
const result = [];
|
|
252
231
|
for (let i = lineIndex; i < lineIndex + limit; i++) {
|
|
253
232
|
if (lines[i] === undefined)
|
|
@@ -290,14 +269,6 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
290
269
|
break;
|
|
291
270
|
if (opts.lang === 'java' && lines[i].includes(' class '))
|
|
292
271
|
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;
|
|
301
272
|
}
|
|
302
273
|
result.push(lines[i]);
|
|
303
274
|
}
|
|
@@ -419,7 +390,14 @@ function storeRunId(runId) {
|
|
|
419
390
|
if (!runId || runId === 'undefined')
|
|
420
391
|
return;
|
|
421
392
|
const filePath = path_1.default.join(os_1.default.tmpdir(), `testomatio.latest.run`);
|
|
422
|
-
|
|
393
|
+
try {
|
|
394
|
+
fs_1.default.writeFileSync(filePath, runId);
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
if (e.code === 'ENOENT')
|
|
398
|
+
return null;
|
|
399
|
+
debug('Could not store latest run ID file: ', e.message);
|
|
400
|
+
}
|
|
423
401
|
}
|
|
424
402
|
/**
|
|
425
403
|
*
|
|
@@ -438,7 +416,6 @@ function readLatestRunId() {
|
|
|
438
416
|
return fs_1.default.readFileSync(filePath)?.toString()?.trim() ?? null;
|
|
439
417
|
}
|
|
440
418
|
catch (e) {
|
|
441
|
-
console.warn('Could not read latest run ID from file: ', e);
|
|
442
419
|
return null;
|
|
443
420
|
}
|
|
444
421
|
}
|
|
@@ -452,6 +429,8 @@ function cleanLatestRunId() {
|
|
|
452
429
|
debug(`Cleaned latest run ID (${runId}) file`, filePath);
|
|
453
430
|
}
|
|
454
431
|
catch (e) {
|
|
432
|
+
if (e.code === 'ENOENT')
|
|
433
|
+
return null;
|
|
455
434
|
console.warn('Could not clean latest run ID file: ', e);
|
|
456
435
|
}
|
|
457
436
|
}
|
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,13 +77,6 @@ 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;
|
|
87
80
|
calculateStats(): {};
|
|
88
81
|
fetchSourceCode(): void;
|
|
89
82
|
formatTests(): void;
|
package/lib/xmlReader.js
CHANGED
|
@@ -131,18 +131,7 @@ 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
|
-
|
|
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);
|
|
134
|
+
this.tests = this.tests.concat(resultTests);
|
|
146
135
|
return {
|
|
147
136
|
status: result?.toLowerCase(),
|
|
148
137
|
create_tests: true,
|
|
@@ -150,7 +139,7 @@ class XmlReader {
|
|
|
150
139
|
passed_count: parseInt(passed, 10),
|
|
151
140
|
failed_count: parseInt(failed, 10),
|
|
152
141
|
skipped_count: parseInt(inconclusive + skipped, 10),
|
|
153
|
-
tests:
|
|
142
|
+
tests: resultTests,
|
|
154
143
|
};
|
|
155
144
|
}
|
|
156
145
|
processTRX(jsonSuite) {
|
|
@@ -286,171 +275,6 @@ class XmlReader {
|
|
|
286
275
|
tests,
|
|
287
276
|
};
|
|
288
277
|
}
|
|
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
|
-
}
|
|
454
278
|
calculateStats() {
|
|
455
279
|
this.stats = {
|
|
456
280
|
...this.stats,
|
|
@@ -496,13 +320,11 @@ class XmlReader {
|
|
|
496
320
|
this.stats.language = 'csharp';
|
|
497
321
|
}
|
|
498
322
|
if (!fs_1.default.existsSync(file)) {
|
|
499
|
-
debug('Failed to open file with the source code
|
|
323
|
+
debug('Failed to open file with the source code', file);
|
|
500
324
|
return;
|
|
501
325
|
}
|
|
502
326
|
const contents = fs_1.default.readFileSync(file).toString();
|
|
503
|
-
|
|
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 });
|
|
327
|
+
t.code = (0, utils_js_1.fetchSourceCode)(contents, { ...t, lang: this.stats.language });
|
|
506
328
|
if (t.code)
|
|
507
329
|
debug('Fetched code for test %s', t.title);
|
|
508
330
|
t.test_id = (0, utils_js_1.fetchIdFromCode)(t.code, { lang: this.stats.language });
|
|
@@ -608,8 +430,7 @@ function reduceTestCases(prev, item) {
|
|
|
608
430
|
testCases
|
|
609
431
|
.filter(t => !!t)
|
|
610
432
|
.forEach(testCaseItem => {
|
|
611
|
-
|
|
612
|
-
const file = extractSourceFilePath(testCaseItem, item);
|
|
433
|
+
const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
|
|
613
434
|
let stack = '';
|
|
614
435
|
let message = '';
|
|
615
436
|
if (testCaseItem.error)
|
|
@@ -629,33 +450,17 @@ function reduceTestCases(prev, item) {
|
|
|
629
450
|
if (!message)
|
|
630
451
|
message = stack.trim().split('\n')[0];
|
|
631
452
|
const isParametrized = item.type === 'ParameterizedMethod';
|
|
453
|
+
const preferClassname = reduceOptions.preferClassname || isParametrized;
|
|
632
454
|
// SpecFlow config
|
|
633
455
|
let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
|
|
634
456
|
let example = null;
|
|
635
|
-
|
|
636
|
-
const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
|
|
457
|
+
const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
|
|
637
458
|
title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
|
|
638
459
|
tags ||= [];
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
}
|
|
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();
|
|
659
464
|
}
|
|
660
465
|
stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
|
|
661
466
|
if (!testId)
|
|
@@ -703,7 +508,6 @@ function reduceTestCases(prev, item) {
|
|
|
703
508
|
run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
|
|
704
509
|
status,
|
|
705
510
|
title,
|
|
706
|
-
originalTestName, // Store original name for parameter-aware FQN generation
|
|
707
511
|
root_suite_id: TESTOMATIO_SUITE,
|
|
708
512
|
suite_title: suiteTitle,
|
|
709
513
|
files,
|
|
@@ -712,102 +516,6 @@ function reduceTestCases(prev, item) {
|
|
|
712
516
|
});
|
|
713
517
|
return prev;
|
|
714
518
|
}
|
|
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
|
-
}
|
|
811
519
|
function processTestSuite(testsuite) {
|
|
812
520
|
if (!testsuite)
|
|
813
521
|
return [];
|
|
@@ -819,20 +527,8 @@ function processTestSuite(testsuite) {
|
|
|
819
527
|
if (!Array.isArray(testsuite)) {
|
|
820
528
|
suites = [testsuite];
|
|
821
529
|
}
|
|
822
|
-
|
|
823
|
-
|
|
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;
|
|
530
|
+
const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
|
|
531
|
+
return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
|
|
836
532
|
}
|
|
837
533
|
function fetchProperties(item) {
|
|
838
534
|
const tags = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testomatio/reporter",
|
|
3
|
-
"version": "2.3.2
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "Testomatio Reporter Client",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"lint": "eslint src",
|
|
55
55
|
"lint:fix": "eslint src --fix",
|
|
56
56
|
"format": "npm run lint:fix && npm run pretty:fix",
|
|
57
|
-
"test": "mocha tests/unit/**/*_test.js",
|
|
57
|
+
"test": "mocha 'tests/unit/**/*_test.js'",
|
|
58
58
|
"test:playwright": "mocha tests/adapter/playwright.test.js",
|
|
59
59
|
"test:codecept": "mocha tests/adapter/codecept.test.js tests/adapter/codecept_comprehensive.test.js tests/adapter/codecept_steps_sections.test.js",
|
|
60
60
|
"test:frameworks": "npm run test:playwright && npm run test:codecept",
|
|
@@ -3,45 +3,18 @@ import Adapter from './adapter.js';
|
|
|
3
3
|
|
|
4
4
|
class CSharpAdapter extends Adapter {
|
|
5
5
|
formatTest(t) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (
|
|
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
|
-
|
|
6
|
+
const title = t.title.replace(/\(.*?\)/, '').trim();
|
|
7
|
+
const example = t.title.match(/\((.*?)\)/);
|
|
8
|
+
if (example) t.example = { ...example[1].split(',') };
|
|
18
9
|
const suite = t.suite_title.split('.');
|
|
19
10
|
t.suite_title = suite.pop();
|
|
20
11
|
t.file = namespaceToFileName(t.file);
|
|
12
|
+
t.title = title.trim();
|
|
21
13
|
return t;
|
|
22
14
|
}
|
|
23
15
|
|
|
24
16
|
getFilePath(t) {
|
|
25
|
-
|
|
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);
|
|
17
|
+
const fileName = namespaceToFileName(t.file);
|
|
45
18
|
return fileName;
|
|
46
19
|
}
|
|
47
20
|
}
|
|
@@ -49,14 +22,7 @@ class CSharpAdapter extends Adapter {
|
|
|
49
22
|
export default CSharpAdapter;
|
|
50
23
|
|
|
51
24
|
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
|
-
|
|
59
25
|
const fileParts = fileName.split('.');
|
|
60
26
|
fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
|
|
61
|
-
return `${fileParts.join(
|
|
27
|
+
return `${fileParts.join(path.sep)}.cs`;
|
|
62
28
|
}
|
package/src/utils/utils.js
CHANGED
|
@@ -139,8 +139,6 @@ 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
|
-
|
|
144
142
|
const comments = code
|
|
145
143
|
.split('\n')
|
|
146
144
|
.map(l => l.trim())
|
|
@@ -182,32 +180,8 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
182
180
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
183
181
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
184
182
|
} else if (opts.lang === 'csharp') {
|
|
185
|
-
|
|
186
|
-
lineIndex = lines.findIndex(l => l.includes(
|
|
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
|
-
}
|
|
183
|
+
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
184
|
+
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
211
185
|
} else {
|
|
212
186
|
lineIndex = lines.findIndex(l => l.includes(title));
|
|
213
187
|
}
|
|
@@ -217,7 +191,7 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
217
191
|
lineIndex -= opts.prepend;
|
|
218
192
|
}
|
|
219
193
|
|
|
220
|
-
if (lineIndex
|
|
194
|
+
if (lineIndex) {
|
|
221
195
|
const result = [];
|
|
222
196
|
for (let i = lineIndex; i < lineIndex + limit; i++) {
|
|
223
197
|
if (lines[i] === undefined) continue;
|
|
@@ -242,10 +216,6 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
242
216
|
if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
|
|
243
217
|
if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
|
|
244
218
|
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;
|
|
249
219
|
}
|
|
250
220
|
result.push(lines[i]);
|
|
251
221
|
}
|
|
@@ -380,7 +350,12 @@ const testRunnerHelper = {
|
|
|
380
350
|
function storeRunId(runId) {
|
|
381
351
|
if (!runId || runId === 'undefined') return;
|
|
382
352
|
const filePath = path.join(os.tmpdir(), `testomatio.latest.run`);
|
|
383
|
-
|
|
353
|
+
try {
|
|
354
|
+
fs.writeFileSync(filePath, runId);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
if (e.code === 'ENOENT') return null;
|
|
357
|
+
debug('Could not store latest run ID file: ', e.message);
|
|
358
|
+
}
|
|
384
359
|
}
|
|
385
360
|
|
|
386
361
|
/**
|
|
@@ -399,7 +374,6 @@ function readLatestRunId() {
|
|
|
399
374
|
|
|
400
375
|
return fs.readFileSync(filePath)?.toString()?.trim() ?? null;
|
|
401
376
|
} catch (e) {
|
|
402
|
-
console.warn('Could not read latest run ID from file: ', e);
|
|
403
377
|
return null;
|
|
404
378
|
}
|
|
405
379
|
}
|
|
@@ -413,6 +387,7 @@ function cleanLatestRunId() {
|
|
|
413
387
|
}
|
|
414
388
|
debug(`Cleaned latest run ID (${runId}) file`, filePath);
|
|
415
389
|
} catch (e) {
|
|
390
|
+
if (e.code === 'ENOENT') return null;
|
|
416
391
|
console.warn('Could not clean latest run ID file: ', e);
|
|
417
392
|
}
|
|
418
393
|
}
|
package/src/xmlReader.js
CHANGED
|
@@ -162,27 +162,7 @@ class XmlReader {
|
|
|
162
162
|
reduceOptions.preferClassname = this.stats.language === 'python';
|
|
163
163
|
const resultTests = processTestSuite(jsonSuite['test-suite']);
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
debug(
|
|
167
|
-
'Raw tests:',
|
|
168
|
-
resultTests.map(t => ({ title: t.title, example: t.example, file: t.file })),
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
// Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
|
|
172
|
-
const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
|
|
173
|
-
|
|
174
|
-
debug('Tests after deduplication:', deduplicatedTests.length);
|
|
175
|
-
debug(
|
|
176
|
-
'Deduplicated tests:',
|
|
177
|
-
deduplicatedTests.map(t => ({
|
|
178
|
-
title: t.title,
|
|
179
|
-
examples: t.examples,
|
|
180
|
-
example: t.example,
|
|
181
|
-
file: t.file,
|
|
182
|
-
})),
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
this.tests = this.tests.concat(deduplicatedTests);
|
|
165
|
+
this.tests = this.tests.concat(resultTests);
|
|
186
166
|
|
|
187
167
|
return {
|
|
188
168
|
status: result?.toLowerCase(),
|
|
@@ -191,7 +171,7 @@ class XmlReader {
|
|
|
191
171
|
passed_count: parseInt(passed, 10),
|
|
192
172
|
failed_count: parseInt(failed, 10),
|
|
193
173
|
skipped_count: parseInt(inconclusive + skipped, 10),
|
|
194
|
-
tests:
|
|
174
|
+
tests: resultTests,
|
|
195
175
|
};
|
|
196
176
|
}
|
|
197
177
|
|
|
@@ -339,194 +319,6 @@ class XmlReader {
|
|
|
339
319
|
};
|
|
340
320
|
}
|
|
341
321
|
|
|
342
|
-
deduplicateTestsByFQN(tests) {
|
|
343
|
-
const fqnMap = new Map();
|
|
344
|
-
|
|
345
|
-
tests.forEach(test => {
|
|
346
|
-
const fqn = this.generateNormalizedFQN(test);
|
|
347
|
-
|
|
348
|
-
if (fqnMap.has(fqn)) {
|
|
349
|
-
const existingTest = fqnMap.get(fqn);
|
|
350
|
-
|
|
351
|
-
// For parameterized tests, merge as Examples
|
|
352
|
-
if (test.example && Array.isArray(test.example) && test.example.length > 0) {
|
|
353
|
-
// Initialize examples array if it doesn't exist
|
|
354
|
-
if (!existingTest.examples) {
|
|
355
|
-
existingTest.examples = [];
|
|
356
|
-
// Add the existing test's example as the first item if it has parameters
|
|
357
|
-
if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
|
|
358
|
-
existingTest.examples.push({
|
|
359
|
-
parameters: existingTest.example,
|
|
360
|
-
status: existingTest.status,
|
|
361
|
-
run_time: existingTest.run_time,
|
|
362
|
-
message: existingTest.message,
|
|
363
|
-
stack: existingTest.stack,
|
|
364
|
-
});
|
|
365
|
-
// Clear the main test's example since it's now in examples array
|
|
366
|
-
delete existingTest.example;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Add this test's execution as an example
|
|
371
|
-
existingTest.examples.push({
|
|
372
|
-
parameters: test.example,
|
|
373
|
-
status: test.status,
|
|
374
|
-
run_time: test.run_time,
|
|
375
|
-
message: test.message,
|
|
376
|
-
stack: test.stack,
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
// Update the main test status to reflect the worst status
|
|
380
|
-
if (test.status === 'failed' || existingTest.status === 'failed') {
|
|
381
|
-
existingTest.status = 'failed';
|
|
382
|
-
} else if (test.status === 'skipped' && existingTest.status !== 'failed') {
|
|
383
|
-
existingTest.status = 'skipped';
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Update total run time
|
|
387
|
-
existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
|
|
388
|
-
} else {
|
|
389
|
-
// Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
|
|
390
|
-
if (test.test_id && !existingTest.test_id) {
|
|
391
|
-
existingTest.test_id = test.test_id;
|
|
392
|
-
}
|
|
393
|
-
// Keep the most complete test data
|
|
394
|
-
if (test.stack && !existingTest.stack) {
|
|
395
|
-
existingTest.stack = test.stack;
|
|
396
|
-
}
|
|
397
|
-
if (test.message && !existingTest.message) {
|
|
398
|
-
existingTest.message = test.message;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Prefer Test Explorer structure (longer, more complete suite_title)
|
|
403
|
-
if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
|
|
404
|
-
existingTest.suite_title = test.suite_title;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Always use the source file path if available
|
|
408
|
-
if (test.file && test.file.endsWith('.cs')) {
|
|
409
|
-
existingTest.file = test.file;
|
|
410
|
-
} else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
|
|
411
|
-
existingTest.file = this.extractCsFileFromPath(test);
|
|
412
|
-
}
|
|
413
|
-
} else {
|
|
414
|
-
// Fix file path to use proper .cs file names from source paths
|
|
415
|
-
if (!test.file || !test.file.endsWith('.cs')) {
|
|
416
|
-
test.file = this.extractCsFileFromPath(test);
|
|
417
|
-
}
|
|
418
|
-
fqnMap.set(fqn, test);
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
return Array.from(fqnMap.values());
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
generateFQN(test) {
|
|
426
|
-
// Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
|
|
427
|
-
// Don't include assembly as it can vary between different test structures
|
|
428
|
-
const namespace = this.extractNamespace(test);
|
|
429
|
-
const className = this.extractClassName(test);
|
|
430
|
-
const methodName = test.title;
|
|
431
|
-
|
|
432
|
-
// Use the most complete namespace.class structure available
|
|
433
|
-
if (test.suite_title && test.suite_title.includes('.')) {
|
|
434
|
-
return `${test.suite_title}.${methodName}`;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return `${namespace}.${className}.${methodName}`;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
generateNormalizedFQN(test) {
|
|
441
|
-
// Generate normalized FQN for deduplication by extracting the core namespace.class.method
|
|
442
|
-
// For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
|
|
443
|
-
|
|
444
|
-
const fullClassName = test.suite_title || '';
|
|
445
|
-
const methodName = test.title;
|
|
446
|
-
|
|
447
|
-
// Extract the most specific namespace.class pattern
|
|
448
|
-
if (fullClassName.includes('.')) {
|
|
449
|
-
const parts = fullClassName.split('.');
|
|
450
|
-
|
|
451
|
-
if (parts.length >= 2) {
|
|
452
|
-
const className = parts[parts.length - 1];
|
|
453
|
-
|
|
454
|
-
// Look for common .NET namespace patterns and normalize them:
|
|
455
|
-
// TestProject.Tests.MyClass -> Tests.MyClass
|
|
456
|
-
// Tests.MyClass -> Tests.MyClass
|
|
457
|
-
// MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
|
|
458
|
-
|
|
459
|
-
let normalizedNamespace = '';
|
|
460
|
-
for (let i = parts.length - 2; i >= 0; i--) {
|
|
461
|
-
const part = parts[i];
|
|
462
|
-
|
|
463
|
-
// Build namespace from right to left, excluding project names
|
|
464
|
-
if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
|
|
465
|
-
// Found a test namespace, use it as the normalized namespace
|
|
466
|
-
normalizedNamespace = part;
|
|
467
|
-
break;
|
|
468
|
-
} else if (i === parts.length - 2) {
|
|
469
|
-
// If no test namespace found, use the immediate parent as namespace
|
|
470
|
-
normalizedNamespace = part;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return `${normalizedNamespace}.${className}.${methodName}`;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Fallback for simple class names
|
|
479
|
-
return `${fullClassName}.${methodName}`;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
extractAssemblyName(test) {
|
|
483
|
-
// Extract assembly name from file path or use default
|
|
484
|
-
if (test.file) {
|
|
485
|
-
const parts = test.file.split(/[/\\]/);
|
|
486
|
-
return parts[0] || 'DefaultAssembly';
|
|
487
|
-
}
|
|
488
|
-
return 'DefaultAssembly';
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
extractNamespace(test) {
|
|
492
|
-
// Extract namespace from suite_title or classname
|
|
493
|
-
if (test.suite_title && test.suite_title.includes('.')) {
|
|
494
|
-
const parts = test.suite_title.split('.');
|
|
495
|
-
return parts.slice(0, -1).join('.');
|
|
496
|
-
}
|
|
497
|
-
return test.suite_title || 'DefaultNamespace';
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
extractClassName(test) {
|
|
501
|
-
// Extract class name from suite_title
|
|
502
|
-
if (test.suite_title && test.suite_title.includes('.')) {
|
|
503
|
-
const parts = test.suite_title.split('.');
|
|
504
|
-
return parts[parts.length - 1];
|
|
505
|
-
}
|
|
506
|
-
return test.suite_title || 'DefaultClass';
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
extractCsFileFromPath(test) {
|
|
510
|
-
// Extract .cs file name from source file path, not namespace
|
|
511
|
-
if (test.file) {
|
|
512
|
-
// Look for actual .cs file path patterns
|
|
513
|
-
const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
|
|
514
|
-
if (csFileMatch) {
|
|
515
|
-
return test.file;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// If no .cs extension, assume it's a namespace path and convert to likely file name
|
|
519
|
-
const className = this.extractClassName(test);
|
|
520
|
-
const pathParts = test.file.split(/[/\\]/);
|
|
521
|
-
pathParts[pathParts.length - 1] = `${className}.cs`;
|
|
522
|
-
return pathParts.join('/');
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Fallback to class name
|
|
526
|
-
const className = this.extractClassName(test);
|
|
527
|
-
return `${className}.cs`;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
322
|
calculateStats() {
|
|
531
323
|
this.stats = {
|
|
532
324
|
...this.stats,
|
|
@@ -565,18 +357,12 @@ class XmlReader {
|
|
|
565
357
|
}
|
|
566
358
|
|
|
567
359
|
if (!fs.existsSync(file)) {
|
|
568
|
-
debug('Failed to open file with the source code
|
|
360
|
+
debug('Failed to open file with the source code', file);
|
|
569
361
|
return;
|
|
570
362
|
}
|
|
571
|
-
|
|
572
363
|
const contents = fs.readFileSync(file).toString();
|
|
573
|
-
|
|
574
|
-
// Use original test name for source code lookup, not humanized title
|
|
575
|
-
const originalTitle = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
|
|
576
|
-
|
|
577
|
-
t.code = fetchSourceCode(contents, { ...t, title: originalTitle, lang: this.stats.language });
|
|
364
|
+
t.code = fetchSourceCode(contents, { ...t, lang: this.stats.language });
|
|
578
365
|
if (t.code) debug('Fetched code for test %s', t.title);
|
|
579
|
-
|
|
580
366
|
t.test_id = fetchIdFromCode(t.code, { lang: this.stats.language });
|
|
581
367
|
if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title);
|
|
582
368
|
} catch (err) {
|
|
@@ -699,8 +485,7 @@ function reduceTestCases(prev, item) {
|
|
|
699
485
|
testCases
|
|
700
486
|
.filter(t => !!t)
|
|
701
487
|
.forEach(testCaseItem => {
|
|
702
|
-
|
|
703
|
-
const file = extractSourceFilePath(testCaseItem, item);
|
|
488
|
+
const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
|
|
704
489
|
|
|
705
490
|
let stack = '';
|
|
706
491
|
let message = '';
|
|
@@ -715,37 +500,20 @@ function reduceTestCases(prev, item) {
|
|
|
715
500
|
if (!message) message = stack.trim().split('\n')[0];
|
|
716
501
|
|
|
717
502
|
const isParametrized = item.type === 'ParameterizedMethod';
|
|
503
|
+
const preferClassname = reduceOptions.preferClassname || isParametrized;
|
|
718
504
|
|
|
719
505
|
// SpecFlow config
|
|
720
506
|
let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
|
|
721
507
|
let example = null;
|
|
722
|
-
|
|
723
|
-
// Use consistent Test Explorer structure for suite title
|
|
724
|
-
const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
|
|
508
|
+
const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
|
|
725
509
|
|
|
726
510
|
title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
|
|
727
511
|
tags ||= [];
|
|
728
512
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (testCaseItem.arguments && testCaseItem.arguments.arg) {
|
|
734
|
-
const args = Array.isArray(testCaseItem.arguments.arg)
|
|
735
|
-
? testCaseItem.arguments.arg
|
|
736
|
-
: [testCaseItem.arguments.arg];
|
|
737
|
-
example = args; // Store as array instead of object
|
|
738
|
-
// Remove parameters from title for NUnit tests
|
|
739
|
-
title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
|
|
740
|
-
} else {
|
|
741
|
-
// Fallback to parsing parameters from test name (SpecFlow, etc.)
|
|
742
|
-
const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
|
|
743
|
-
if (exampleMatches) {
|
|
744
|
-
// Extract and store parameters as Examples
|
|
745
|
-
const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
|
|
746
|
-
example = parameterValues;
|
|
747
|
-
title = title.replace(/\(.*?\)/, '').trim();
|
|
748
|
-
}
|
|
513
|
+
const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
|
|
514
|
+
if (exampleMatches) {
|
|
515
|
+
example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
|
|
516
|
+
title = title.replace(/\(.*?\)/, '').trim();
|
|
749
517
|
}
|
|
750
518
|
|
|
751
519
|
stack = `${
|
|
@@ -800,7 +568,6 @@ function reduceTestCases(prev, item) {
|
|
|
800
568
|
run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
|
|
801
569
|
status,
|
|
802
570
|
title,
|
|
803
|
-
originalTestName, // Store original name for parameter-aware FQN generation
|
|
804
571
|
root_suite_id: TESTOMATIO_SUITE,
|
|
805
572
|
suite_title: suiteTitle,
|
|
806
573
|
files,
|
|
@@ -810,113 +577,6 @@ function reduceTestCases(prev, item) {
|
|
|
810
577
|
return prev;
|
|
811
578
|
}
|
|
812
579
|
|
|
813
|
-
function extractSourceFilePath(testCaseItem, item) {
|
|
814
|
-
// Priority order for file path extraction to match Test Explorer structure:
|
|
815
|
-
// 1. filepath attribute (direct .cs file path from NUnit)
|
|
816
|
-
// 2. fullname (contains full project path)
|
|
817
|
-
// 3. file attribute from test case
|
|
818
|
-
// 4. package (fallback)
|
|
819
|
-
|
|
820
|
-
// NUnit provides filepath attribute with actual .cs file path - use this first
|
|
821
|
-
if (item.filepath) {
|
|
822
|
-
// Clean up Windows/Unix path separators and ensure proper format
|
|
823
|
-
let filePath = item.filepath.replace(/\\/g, '/');
|
|
824
|
-
|
|
825
|
-
// Make relative to current working directory if absolute
|
|
826
|
-
if (path.isAbsolute(item.filepath)) {
|
|
827
|
-
const cwd = process.cwd().replace(/\\/g, '/');
|
|
828
|
-
if (filePath.startsWith(cwd)) {
|
|
829
|
-
filePath = path.relative(cwd, item.filepath).replace(/\\/g, '/');
|
|
830
|
-
} else {
|
|
831
|
-
// Try to extract relative path from common patterns
|
|
832
|
-
const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
|
|
833
|
-
for (const pattern of commonPatterns) {
|
|
834
|
-
const index = filePath.lastIndexOf(pattern);
|
|
835
|
-
if (index !== -1) {
|
|
836
|
-
filePath = filePath.substring(index + 1);
|
|
837
|
-
break;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return filePath;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
if (testCaseItem.file) {
|
|
846
|
-
let filePath = testCaseItem.file.replace(/\\/g, '/');
|
|
847
|
-
|
|
848
|
-
// Make relative to current working directory if absolute
|
|
849
|
-
if (path.isAbsolute(testCaseItem.file)) {
|
|
850
|
-
const cwd = process.cwd().replace(/\\/g, '/');
|
|
851
|
-
if (filePath.startsWith(cwd)) {
|
|
852
|
-
filePath = path.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
|
|
853
|
-
} else {
|
|
854
|
-
// Try to extract relative path from common patterns
|
|
855
|
-
const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
|
|
856
|
-
for (const pattern of commonPatterns) {
|
|
857
|
-
const index = filePath.lastIndexOf(pattern);
|
|
858
|
-
if (index !== -1) {
|
|
859
|
-
filePath = filePath.substring(index + 1);
|
|
860
|
-
break;
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
return filePath;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
if (item.fullname) {
|
|
869
|
-
// Extract actual file path from fullname if it contains path separators
|
|
870
|
-
const fullnameParts = item.fullname.split('.');
|
|
871
|
-
if (fullnameParts.length > 2) {
|
|
872
|
-
// For ParameterizedMethod, get the class name (not method name)
|
|
873
|
-
// Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
|
|
874
|
-
let namespaceParts, className;
|
|
875
|
-
|
|
876
|
-
if (item.type === 'ParameterizedMethod') {
|
|
877
|
-
// For parameterized methods, the last part is the method name, second-to-last is class
|
|
878
|
-
namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
|
|
879
|
-
className = fullnameParts[fullnameParts.length - 2]; // Get class name
|
|
880
|
-
} else {
|
|
881
|
-
// For regular classes/fixtures
|
|
882
|
-
namespaceParts = fullnameParts.slice(1, -1); // Skip project name
|
|
883
|
-
className = fullnameParts[fullnameParts.length - 1];
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
return `${namespaceParts.join('/')}/${className}.cs`;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
if (item.package) return item.package.replace(/\\/g, '/');
|
|
891
|
-
|
|
892
|
-
// Fallback: construct from classname
|
|
893
|
-
if (testCaseItem.classname) {
|
|
894
|
-
const parts = testCaseItem.classname.split('.');
|
|
895
|
-
const className = parts[parts.length - 1];
|
|
896
|
-
const namespacePath = parts.slice(0, -1).join('/');
|
|
897
|
-
return `${namespacePath}/${className}.cs`;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
return '';
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function extractTestExplorerSuiteTitle(testCaseItem, item) {
|
|
904
|
-
// Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
|
|
905
|
-
// Priority: fullname > classname > name
|
|
906
|
-
|
|
907
|
-
if (item.fullname) {
|
|
908
|
-
// Use fullname to maintain Test Explorer structure
|
|
909
|
-
return item.fullname;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
if (testCaseItem.classname) {
|
|
913
|
-
return testCaseItem.classname;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// Fallback to item name but prefer classname structure
|
|
917
|
-
return item.name || testCaseItem.classname || 'UnknownClass';
|
|
918
|
-
}
|
|
919
|
-
|
|
920
580
|
function processTestSuite(testsuite) {
|
|
921
581
|
if (!testsuite) return [];
|
|
922
582
|
if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
|
|
@@ -927,23 +587,9 @@ function processTestSuite(testsuite) {
|
|
|
927
587
|
suites = [testsuite];
|
|
928
588
|
}
|
|
929
589
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
for (const suite of suites) {
|
|
933
|
-
// Process child test suites recursively (TestFixture, ParameterizedMethod, etc.)
|
|
934
|
-
if (suite['test-suite']) {
|
|
935
|
-
const childResults = processTestSuite(suite['test-suite']);
|
|
936
|
-
allResults = allResults.concat(childResults);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// Process direct test cases in this suite
|
|
940
|
-
if (suite['test-case'] || suite.testcase) {
|
|
941
|
-
const leafResults = reduceTestCases([], suite);
|
|
942
|
-
allResults = allResults.concat(leafResults);
|
|
943
|
-
}
|
|
944
|
-
}
|
|
590
|
+
const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
|
|
945
591
|
|
|
946
|
-
return
|
|
592
|
+
return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
|
|
947
593
|
}
|
|
948
594
|
|
|
949
595
|
function fetchProperties(item) {
|