@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.
@@ -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;
@@ -1,4 +1,5 @@
1
1
  export default CSharpAdapter;
2
2
  declare class CSharpAdapter extends Adapter {
3
+ getFilePath(t: any): string;
3
4
  }
4
5
  import Adapter from './adapter.js';
@@ -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
- // 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
- }
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
- 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);
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('/')}.cs`;
29
+ return `${fileParts.join(path_1.default.sep)}.cs`;
59
30
  }
@@ -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(): void;
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): void;
50
+ export function storeRunId(runId: any): any;
51
51
  export namespace testRunnerHelper {
52
52
  function getNameOfCurrentlyRunningTest(): any;
53
53
  }
@@ -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
- // 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) {
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 !== -1 && lineIndex !== undefined) {
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
- fs_1.default.writeFileSync(filePath, runId);
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
  }
@@ -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
- 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);
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: deduplicatedTests,
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: %s', file);
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
- // 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 });
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
- // Use consistent Test Explorer structure: prioritize fullname for file path
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
- // Use consistent Test Explorer structure for suite title
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
- // 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
- }
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
- 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;
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-beta.3-xml-import",
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",
@@ -283,3 +283,4 @@ function getTestContextName(test) {
283
283
  }
284
284
 
285
285
  export default PlaywrightReporter;
286
+ export { extractTags };
@@ -3,45 +3,18 @@ import Adapter from './adapter.js';
3
3
 
4
4
  class CSharpAdapter extends Adapter {
5
5
  formatTest(t) {
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
-
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
- 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);
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('/')}.cs`;
27
+ return `${fileParts.join(path.sep)}.cs`;
62
28
  }
@@ -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
- // 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
- }
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 !== -1 && lineIndex !== undefined) {
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
- fs.writeFileSync(filePath, runId);
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
- debug('Raw tests extracted from NUnit XML:', resultTests.length);
166
- debug(
167
- 'Raw tests:',
168
- resultTests.map(t => ({ title: t.title, example: t.example, file: t.file })),
169
- );
170
-
171
- // Deduplicate tests based on FQN (Assembly + Namespace + Class + Method)
172
- const deduplicatedTests = this.deduplicateTestsByFQN(resultTests);
173
-
174
- debug('Tests after deduplication:', deduplicatedTests.length);
175
- debug(
176
- 'Deduplicated tests:',
177
- deduplicatedTests.map(t => ({
178
- title: t.title,
179
- examples: t.examples,
180
- example: t.example,
181
- file: t.file,
182
- })),
183
- );
184
-
185
- this.tests = this.tests.concat(deduplicatedTests);
165
+ this.tests = this.tests.concat(resultTests);
186
166
 
187
167
  return {
188
168
  status: result?.toLowerCase(),
@@ -191,7 +171,7 @@ class XmlReader {
191
171
  passed_count: parseInt(passed, 10),
192
172
  failed_count: parseInt(failed, 10),
193
173
  skipped_count: parseInt(inconclusive + skipped, 10),
194
- tests: deduplicatedTests,
174
+ tests: resultTests,
195
175
  };
196
176
  }
197
177
 
@@ -339,194 +319,6 @@ class XmlReader {
339
319
  };
340
320
  }
341
321
 
342
- deduplicateTestsByFQN(tests) {
343
- const fqnMap = new Map();
344
-
345
- tests.forEach(test => {
346
- const fqn = this.generateNormalizedFQN(test);
347
-
348
- if (fqnMap.has(fqn)) {
349
- const existingTest = fqnMap.get(fqn);
350
-
351
- // For parameterized tests, merge as Examples
352
- if (test.example && Array.isArray(test.example) && test.example.length > 0) {
353
- // Initialize examples array if it doesn't exist
354
- if (!existingTest.examples) {
355
- existingTest.examples = [];
356
- // Add the existing test's example as the first item if it has parameters
357
- if (existingTest.example && Array.isArray(existingTest.example) && existingTest.example.length > 0) {
358
- existingTest.examples.push({
359
- parameters: existingTest.example,
360
- status: existingTest.status,
361
- run_time: existingTest.run_time,
362
- message: existingTest.message,
363
- stack: existingTest.stack,
364
- });
365
- // Clear the main test's example since it's now in examples array
366
- delete existingTest.example;
367
- }
368
- }
369
-
370
- // Add this test's execution as an example
371
- existingTest.examples.push({
372
- parameters: test.example,
373
- status: test.status,
374
- run_time: test.run_time,
375
- message: test.message,
376
- stack: test.stack,
377
- });
378
-
379
- // Update the main test status to reflect the worst status
380
- if (test.status === 'failed' || existingTest.status === 'failed') {
381
- existingTest.status = 'failed';
382
- } else if (test.status === 'skipped' && existingTest.status !== 'failed') {
383
- existingTest.status = 'skipped';
384
- }
385
-
386
- // Update total run time
387
- existingTest.run_time = (existingTest.run_time || 0) + (test.run_time || 0);
388
- } else {
389
- // Merge test properties for non-parameterized tests, prioritizing Test Explorer structure
390
- if (test.test_id && !existingTest.test_id) {
391
- existingTest.test_id = test.test_id;
392
- }
393
- // Keep the most complete test data
394
- if (test.stack && !existingTest.stack) {
395
- existingTest.stack = test.stack;
396
- }
397
- if (test.message && !existingTest.message) {
398
- existingTest.message = test.message;
399
- }
400
- }
401
-
402
- // Prefer Test Explorer structure (longer, more complete suite_title)
403
- if (test.suite_title && test.suite_title.length > existingTest.suite_title.length) {
404
- existingTest.suite_title = test.suite_title;
405
- }
406
-
407
- // Always use the source file path if available
408
- if (test.file && test.file.endsWith('.cs')) {
409
- existingTest.file = test.file;
410
- } else if (!existingTest.file || !existingTest.file.endsWith('.cs')) {
411
- existingTest.file = this.extractCsFileFromPath(test);
412
- }
413
- } else {
414
- // Fix file path to use proper .cs file names from source paths
415
- if (!test.file || !test.file.endsWith('.cs')) {
416
- test.file = this.extractCsFileFromPath(test);
417
- }
418
- fqnMap.set(fqn, test);
419
- }
420
- });
421
-
422
- return Array.from(fqnMap.values());
423
- }
424
-
425
- generateFQN(test) {
426
- // Generate Fully Qualified Name: Namespace + Class + Method (standard .NET FQN)
427
- // Don't include assembly as it can vary between different test structures
428
- const namespace = this.extractNamespace(test);
429
- const className = this.extractClassName(test);
430
- const methodName = test.title;
431
-
432
- // Use the most complete namespace.class structure available
433
- if (test.suite_title && test.suite_title.includes('.')) {
434
- return `${test.suite_title}.${methodName}`;
435
- }
436
-
437
- return `${namespace}.${className}.${methodName}`;
438
- }
439
-
440
- generateNormalizedFQN(test) {
441
- // Generate normalized FQN for deduplication by extracting the core namespace.class.method
442
- // For parameterized tests, we want the SAME FQN so they merge into one test with multiple Examples
443
-
444
- const fullClassName = test.suite_title || '';
445
- const methodName = test.title;
446
-
447
- // Extract the most specific namespace.class pattern
448
- if (fullClassName.includes('.')) {
449
- const parts = fullClassName.split('.');
450
-
451
- if (parts.length >= 2) {
452
- const className = parts[parts.length - 1];
453
-
454
- // Look for common .NET namespace patterns and normalize them:
455
- // TestProject.Tests.MyClass -> Tests.MyClass
456
- // Tests.MyClass -> Tests.MyClass
457
- // MyProject.SubNamespace.Tests.MyClass -> Tests.MyClass
458
-
459
- let normalizedNamespace = '';
460
- for (let i = parts.length - 2; i >= 0; i--) {
461
- const part = parts[i];
462
-
463
- // Build namespace from right to left, excluding project names
464
- if (part === 'Tests' || part.endsWith('Tests') || part.includes('Test')) {
465
- // Found a test namespace, use it as the normalized namespace
466
- normalizedNamespace = part;
467
- break;
468
- } else if (i === parts.length - 2) {
469
- // If no test namespace found, use the immediate parent as namespace
470
- normalizedNamespace = part;
471
- }
472
- }
473
-
474
- return `${normalizedNamespace}.${className}.${methodName}`;
475
- }
476
- }
477
-
478
- // Fallback for simple class names
479
- return `${fullClassName}.${methodName}`;
480
- }
481
-
482
- extractAssemblyName(test) {
483
- // Extract assembly name from file path or use default
484
- if (test.file) {
485
- const parts = test.file.split(/[/\\]/);
486
- return parts[0] || 'DefaultAssembly';
487
- }
488
- return 'DefaultAssembly';
489
- }
490
-
491
- extractNamespace(test) {
492
- // Extract namespace from suite_title or classname
493
- if (test.suite_title && test.suite_title.includes('.')) {
494
- const parts = test.suite_title.split('.');
495
- return parts.slice(0, -1).join('.');
496
- }
497
- return test.suite_title || 'DefaultNamespace';
498
- }
499
-
500
- extractClassName(test) {
501
- // Extract class name from suite_title
502
- if (test.suite_title && test.suite_title.includes('.')) {
503
- const parts = test.suite_title.split('.');
504
- return parts[parts.length - 1];
505
- }
506
- return test.suite_title || 'DefaultClass';
507
- }
508
-
509
- extractCsFileFromPath(test) {
510
- // Extract .cs file name from source file path, not namespace
511
- if (test.file) {
512
- // Look for actual .cs file path patterns
513
- const csFileMatch = test.file.match(/([^/\\]+\.cs)$/);
514
- if (csFileMatch) {
515
- return test.file;
516
- }
517
-
518
- // If no .cs extension, assume it's a namespace path and convert to likely file name
519
- const className = this.extractClassName(test);
520
- const pathParts = test.file.split(/[/\\]/);
521
- pathParts[pathParts.length - 1] = `${className}.cs`;
522
- return pathParts.join('/');
523
- }
524
-
525
- // Fallback to class name
526
- const className = this.extractClassName(test);
527
- return `${className}.cs`;
528
- }
529
-
530
322
  calculateStats() {
531
323
  this.stats = {
532
324
  ...this.stats,
@@ -565,18 +357,12 @@ class XmlReader {
565
357
  }
566
358
 
567
359
  if (!fs.existsSync(file)) {
568
- debug('Failed to open file with the source code: %s', file);
360
+ debug('Failed to open file with the source code', file);
569
361
  return;
570
362
  }
571
-
572
363
  const contents = fs.readFileSync(file).toString();
573
-
574
- // Use original test name for source code lookup, not humanized title
575
- const originalTitle = t.originalTestName ? t.originalTestName.replace(/\(.*?\)/, '').trim() : t.title;
576
-
577
- t.code = fetchSourceCode(contents, { ...t, title: originalTitle, lang: this.stats.language });
364
+ t.code = fetchSourceCode(contents, { ...t, lang: this.stats.language });
578
365
  if (t.code) debug('Fetched code for test %s', t.title);
579
-
580
366
  t.test_id = fetchIdFromCode(t.code, { lang: this.stats.language });
581
367
  if (t.test_id) debug('Fetched test id %s for test %s', t.test_id, t.title);
582
368
  } catch (err) {
@@ -699,8 +485,7 @@ function reduceTestCases(prev, item) {
699
485
  testCases
700
486
  .filter(t => !!t)
701
487
  .forEach(testCaseItem => {
702
- // Use consistent Test Explorer structure: prioritize fullname for file path
703
- const file = extractSourceFilePath(testCaseItem, item);
488
+ const file = testCaseItem.file || item.filepath || item.fullname || item.package || '';
704
489
 
705
490
  let stack = '';
706
491
  let message = '';
@@ -715,37 +500,20 @@ function reduceTestCases(prev, item) {
715
500
  if (!message) message = stack.trim().split('\n')[0];
716
501
 
717
502
  const isParametrized = item.type === 'ParameterizedMethod';
503
+ const preferClassname = reduceOptions.preferClassname || isParametrized;
718
504
 
719
505
  // SpecFlow config
720
506
  let { title, tags, testId } = fetchProperties(isParametrized ? item : testCaseItem);
721
507
  let example = null;
722
-
723
- // Use consistent Test Explorer structure for suite title
724
- const suiteTitle = extractTestExplorerSuiteTitle(testCaseItem, item);
508
+ const suiteTitle = preferClassname ? testCaseItem.classname : item.name || testCaseItem.classname;
725
509
 
726
510
  title ||= testCaseItem.name || testCaseItem.methodname || testCaseItem.classname;
727
511
  tags ||= [];
728
512
 
729
- // Store original test name for parameter extraction
730
- const originalTestName = testCaseItem.name || testCaseItem.methodname;
731
-
732
- // Handle NUnit-style arguments from <arguments> element
733
- if (testCaseItem.arguments && testCaseItem.arguments.arg) {
734
- const args = Array.isArray(testCaseItem.arguments.arg)
735
- ? testCaseItem.arguments.arg
736
- : [testCaseItem.arguments.arg];
737
- example = args; // Store as array instead of object
738
- // Remove parameters from title for NUnit tests
739
- title = (testCaseItem.methodname || title).replace(/\(.*?\)/, '').trim();
740
- } else {
741
- // Fallback to parsing parameters from test name (SpecFlow, etc.)
742
- const exampleMatches = originalTestName?.match(/\((.*?)\)$/);
743
- if (exampleMatches) {
744
- // Extract and store parameters as Examples
745
- const parameterValues = exampleMatches[1].split(',').map(v => v.trim().replace(/['"]/g, ''));
746
- example = parameterValues;
747
- title = title.replace(/\(.*?\)/, '').trim();
748
- }
513
+ const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
514
+ if (exampleMatches) {
515
+ example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
516
+ title = title.replace(/\(.*?\)/, '').trim();
749
517
  }
750
518
 
751
519
  stack = `${
@@ -800,7 +568,6 @@ function reduceTestCases(prev, item) {
800
568
  run_time: parseFloat(testCaseItem.time || testCaseItem.duration) * 1000,
801
569
  status,
802
570
  title,
803
- originalTestName, // Store original name for parameter-aware FQN generation
804
571
  root_suite_id: TESTOMATIO_SUITE,
805
572
  suite_title: suiteTitle,
806
573
  files,
@@ -810,113 +577,6 @@ function reduceTestCases(prev, item) {
810
577
  return prev;
811
578
  }
812
579
 
813
- function extractSourceFilePath(testCaseItem, item) {
814
- // Priority order for file path extraction to match Test Explorer structure:
815
- // 1. filepath attribute (direct .cs file path from NUnit)
816
- // 2. fullname (contains full project path)
817
- // 3. file attribute from test case
818
- // 4. package (fallback)
819
-
820
- // NUnit provides filepath attribute with actual .cs file path - use this first
821
- if (item.filepath) {
822
- // Clean up Windows/Unix path separators and ensure proper format
823
- let filePath = item.filepath.replace(/\\/g, '/');
824
-
825
- // Make relative to current working directory if absolute
826
- if (path.isAbsolute(item.filepath)) {
827
- const cwd = process.cwd().replace(/\\/g, '/');
828
- if (filePath.startsWith(cwd)) {
829
- filePath = path.relative(cwd, item.filepath).replace(/\\/g, '/');
830
- } else {
831
- // Try to extract relative path from common patterns
832
- const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
833
- for (const pattern of commonPatterns) {
834
- const index = filePath.lastIndexOf(pattern);
835
- if (index !== -1) {
836
- filePath = filePath.substring(index + 1);
837
- break;
838
- }
839
- }
840
- }
841
- }
842
- return filePath;
843
- }
844
-
845
- if (testCaseItem.file) {
846
- let filePath = testCaseItem.file.replace(/\\/g, '/');
847
-
848
- // Make relative to current working directory if absolute
849
- if (path.isAbsolute(testCaseItem.file)) {
850
- const cwd = process.cwd().replace(/\\/g, '/');
851
- if (filePath.startsWith(cwd)) {
852
- filePath = path.relative(cwd, testCaseItem.file).replace(/\\/g, '/');
853
- } else {
854
- // Try to extract relative path from common patterns
855
- const commonPatterns = ['/Tests/', '/test/', '/src/', '/Test/'];
856
- for (const pattern of commonPatterns) {
857
- const index = filePath.lastIndexOf(pattern);
858
- if (index !== -1) {
859
- filePath = filePath.substring(index + 1);
860
- break;
861
- }
862
- }
863
- }
864
- }
865
- return filePath;
866
- }
867
-
868
- if (item.fullname) {
869
- // Extract actual file path from fullname if it contains path separators
870
- const fullnameParts = item.fullname.split('.');
871
- if (fullnameParts.length > 2) {
872
- // For ParameterizedMethod, get the class name (not method name)
873
- // Example: "NUnit_sample_test.Tests.SampleTests.TestBooleanValue" -> "Tests/SampleTests.cs"
874
- let namespaceParts, className;
875
-
876
- if (item.type === 'ParameterizedMethod') {
877
- // For parameterized methods, the last part is the method name, second-to-last is class
878
- namespaceParts = fullnameParts.slice(1, -2); // Skip project name and method name
879
- className = fullnameParts[fullnameParts.length - 2]; // Get class name
880
- } else {
881
- // For regular classes/fixtures
882
- namespaceParts = fullnameParts.slice(1, -1); // Skip project name
883
- className = fullnameParts[fullnameParts.length - 1];
884
- }
885
-
886
- return `${namespaceParts.join('/')}/${className}.cs`;
887
- }
888
- }
889
-
890
- if (item.package) return item.package.replace(/\\/g, '/');
891
-
892
- // Fallback: construct from classname
893
- if (testCaseItem.classname) {
894
- const parts = testCaseItem.classname.split('.');
895
- const className = parts[parts.length - 1];
896
- const namespacePath = parts.slice(0, -1).join('/');
897
- return `${namespacePath}/${className}.cs`;
898
- }
899
-
900
- return '';
901
- }
902
-
903
- function extractTestExplorerSuiteTitle(testCaseItem, item) {
904
- // Extract suite title to match Test Explorer structure (Project/Namespace hierarchy)
905
- // Priority: fullname > classname > name
906
-
907
- if (item.fullname) {
908
- // Use fullname to maintain Test Explorer structure
909
- return item.fullname;
910
- }
911
-
912
- if (testCaseItem.classname) {
913
- return testCaseItem.classname;
914
- }
915
-
916
- // Fallback to item name but prefer classname structure
917
- return item.name || testCaseItem.classname || 'UnknownClass';
918
- }
919
-
920
580
  function processTestSuite(testsuite) {
921
581
  if (!testsuite) return [];
922
582
  if (testsuite.testsuite) return processTestSuite(testsuite.testsuite);
@@ -927,23 +587,9 @@ function processTestSuite(testsuite) {
927
587
  suites = [testsuite];
928
588
  }
929
589
 
930
- let allResults = [];
931
-
932
- for (const suite of suites) {
933
- // Process child test suites recursively (TestFixture, ParameterizedMethod, etc.)
934
- if (suite['test-suite']) {
935
- const childResults = processTestSuite(suite['test-suite']);
936
- allResults = allResults.concat(childResults);
937
- }
938
-
939
- // Process direct test cases in this suite
940
- if (suite['test-case'] || suite.testcase) {
941
- const leafResults = reduceTestCases([], suite);
942
- allResults = allResults.concat(leafResults);
943
- }
944
- }
590
+ const subSuites = suites.filter(s => s['test-suite'] && !testsuite['test-case']);
945
591
 
946
- return allResults;
592
+ return [...subSuites.map(s => processTestSuite(s['test-suite'])), ...suites.reduce(reduceTestCases, [])].flat();
947
593
  }
948
594
 
949
595
  function fetchProperties(item) {