@testomatio/reporter 2.3.6 → 2.3.7-beta.2-xml-import

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/uploader.js CHANGED
@@ -194,6 +194,11 @@ export class S3Uploader {
194
194
  filePath = path.join(process.cwd(), filePath);
195
195
  }
196
196
 
197
+ // Normalize path separators for cross-platform compatibility
198
+ if (typeof filePath === 'string') {
199
+ filePath = filePath.replace(/\\/g, '/');
200
+ }
201
+
197
202
  const data = { rid, file: filePath, uploaded };
198
203
  const jsonLine = `${JSON.stringify(data)}\n`;
199
204
  fs.appendFileSync(tempFilePath, jsonLine);
@@ -83,12 +83,8 @@ const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
83
83
  .map(f => f[1].trim())
84
84
  .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
85
85
  .map(f => {
86
- // Convert Windows paths to Linux paths for testing purposes
87
- if (f.match(/^[A-Za-z]:[\\\/]/)) {
88
- // Convert Windows path to Linux equivalent for test scenarios
89
- return f.replace(/^[A-Za-z]:[\\\/]/, '/').replace(/\\/g, '/');
90
- }
91
- return f;
86
+ // Normalize path separators for cross-platform compatibility
87
+ return f.replace(/\\/g, '/');
92
88
  });
93
89
 
94
90
  debug('Found files in stack trace: ', files);
@@ -139,6 +135,8 @@ export const TEST_ID_REGEX = /@T([\w\d]{8})/;
139
135
  export const SUITE_ID_REGEX = /@S([\w\d]{8})/;
140
136
 
141
137
  const fetchIdFromCode = (code, opts = {}) => {
138
+ if (!code) return null;
139
+
142
140
  const comments = code
143
141
  .split('\n')
144
142
  .map(l => l.trim())
@@ -180,8 +178,65 @@ const fetchSourceCode = (contents, opts = {}) => {
180
178
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
181
179
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
182
180
  } else if (opts.lang === 'csharp') {
183
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
184
- if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
181
+ // Find the method declaration line
182
+ let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
183
+
184
+ if (methodLineIndex === -1) {
185
+ methodLineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
186
+ }
187
+
188
+ if (methodLineIndex === -1) {
189
+ methodLineIndex = lines.findIndex(l => l.includes(`${title}(`));
190
+ }
191
+
192
+ // If found, scan upwards to find [TestCase], [Test] attributes and XML comments
193
+ if (methodLineIndex !== -1) {
194
+ lineIndex = methodLineIndex;
195
+
196
+ // Scan upwards to find the start of attributes and comments
197
+ for (let i = methodLineIndex - 1; i >= 0; i--) {
198
+ const trimmedLine = lines[i].trim();
199
+
200
+ // Include [TestCase], [Test], and other attributes
201
+ if (trimmedLine.startsWith('[')) {
202
+ lineIndex = i;
203
+ continue;
204
+ }
205
+
206
+ // Include XML documentation comments
207
+ if (trimmedLine.startsWith('///')) {
208
+ lineIndex = i;
209
+ continue;
210
+ }
211
+
212
+ // Stop at empty lines (with some tolerance)
213
+ if (trimmedLine === '') {
214
+ // Check if next non-empty line is an attribute or comment
215
+ let hasMoreAttributes = false;
216
+ for (let j = i - 1; j >= 0; j--) {
217
+ const nextTrimmed = lines[j].trim();
218
+ if (nextTrimmed === '') continue;
219
+ if (nextTrimmed.startsWith('[') || nextTrimmed.startsWith('///')) {
220
+ hasMoreAttributes = true;
221
+ lineIndex = j;
222
+ }
223
+ break;
224
+ }
225
+ if (!hasMoreAttributes) break;
226
+ continue;
227
+ }
228
+
229
+ // Stop at other method declarations or class-level elements
230
+ if (
231
+ trimmedLine.includes('public ') ||
232
+ trimmedLine.includes('private ') ||
233
+ trimmedLine.includes('protected ') ||
234
+ trimmedLine.includes('internal ')
235
+ ) {
236
+ if (!trimmedLine.startsWith('[')) break;
237
+ }
238
+ }
239
+ }
185
240
  } else {
186
241
  lineIndex = lines.findIndex(l => l.includes(title));
187
242
  }
@@ -191,11 +246,31 @@ const fetchSourceCode = (contents, opts = {}) => {
191
246
  lineIndex -= opts.prepend;
192
247
  }
193
248
 
194
- if (lineIndex) {
249
+ if (lineIndex !== -1 && lineIndex !== undefined) {
195
250
  const result = [];
251
+ let braceDepth = 0; // Track brace depth for C# methods
252
+ let methodStartFound = false; // Flag to indicate we've found the method opening brace
253
+
196
254
  for (let i = lineIndex; i < lineIndex + limit; i++) {
197
255
  if (lines[i] === undefined) continue;
198
256
 
257
+ // Track brace depth for C# to stop after method closes
258
+ if (opts.lang === 'csharp') {
259
+ const line = lines[i];
260
+ // Count opening and closing braces
261
+ const openBraces = (line.match(/\{/g) || []).length;
262
+ const closeBraces = (line.match(/\}/g) || []).length;
263
+
264
+ if (openBraces > 0) methodStartFound = true;
265
+ braceDepth += openBraces - closeBraces;
266
+
267
+ // If we've started the method and depth returns to 0, method is complete
268
+ if (methodStartFound && braceDepth === 0 && closeBraces > 0) {
269
+ result.push(lines[i]);
270
+ break;
271
+ }
272
+ }
273
+
199
274
  if (i > lineIndex + 2 && !opts.prepend) {
200
275
  // annotation
201
276
  if (opts.lang === 'php' && lines[i].trim().startsWith('#[')) break;
@@ -216,6 +291,18 @@ const fetchSourceCode = (contents, opts = {}) => {
216
291
  if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
217
292
  if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
218
293
  if (opts.lang === 'java' && lines[i].includes(' class ')) break;
294
+ // For C#, additional checks if brace tracking didn't stop us
295
+ if (opts.lang === 'csharp') {
296
+ const trimmed = lines[i].trim();
297
+ // Stop at attribute that marks beginning of next test
298
+ if (trimmed.match(/^\[(Test|TestCase|Theory|Fact)/)) break;
299
+ // Stop at XML documentation comments that belong to next method
300
+ if (trimmed.startsWith('///')) break;
301
+ // Stop at another method declaration
302
+ if (trimmed.match(/^\s*(public|private|protected|internal)\s+(\w+|async\s+\w+)\s+\w+\s*\(/)) break;
303
+ // Stop at class declaration
304
+ if (trimmed.includes(' class ') && trimmed.includes('public')) break;
305
+ }
219
306
  }
220
307
  result.push(lines[i]);
221
308
  }
package/src/xmlReader.js CHANGED
@@ -6,6 +6,7 @@ import { XMLParser } from 'fast-xml-parser';
6
6
  import { APP_PREFIX, STATUS } from './constants.js';
7
7
  import { randomUUID } from 'crypto';
8
8
  import { fileURLToPath } from 'url';
9
+ import { NUnitXmlParser } from './junit-adapter/nunit-parser.js';
9
10
  import {
10
11
  fetchFilesFromStackTrace,
11
12
  fetchIdFromOutput,
@@ -75,6 +76,10 @@ class XmlReader {
75
76
  this.stats.language = opts.lang?.toLowerCase();
76
77
  this.uploader = new S3Uploader();
77
78
 
79
+ // Enhanced NUnit parsing - enabled by default for NUnit XML
80
+ this.enhancedNunit = opts.enhancedNunit !== false; // Default true, can be disabled
81
+ this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled
82
+
78
83
  // @ts-ignore
79
84
  const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
80
85
  this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version;
@@ -126,7 +131,8 @@ class XmlReader {
126
131
  }
127
132
 
128
133
  processJUnit(jsonSuite) {
129
- const { testsuite, name, tests, failures, errors } = jsonSuite;
134
+ const { testsuite, name, failures, errors } = jsonSuite;
135
+ const tests = testsuite?.tests || jsonSuite.tests;
130
136
 
131
137
  reduceOptions.preferClassname = this.stats.language === 'python';
132
138
  const resultTests = processTestSuite(testsuite);
@@ -157,6 +163,14 @@ class XmlReader {
157
163
  }
158
164
 
159
165
  processNUnit(jsonSuite) {
166
+ // Use enhanced NUnit parser if enabled and this is actually NUnit XML
167
+ if (this.enhancedNunit && this.isNUnitXml(jsonSuite)) {
168
+ debug('Using enhanced NUnit parser');
169
+ return this.processNUnitEnhanced(jsonSuite);
170
+ }
171
+
172
+ // Fallback to legacy parser for backward compatibility
173
+ debug('Using legacy NUnit parser');
160
174
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
161
175
 
162
176
  reduceOptions.preferClassname = this.stats.language === 'python';
@@ -175,63 +189,71 @@ class XmlReader {
175
189
  };
176
190
  }
177
191
 
192
+ /**
193
+ * Check if the XML is actually NUnit format (has test-suite hierarchy)
194
+ * @param {Object} jsonSuite - Parsed XML suite object
195
+ * @returns {boolean} - True if this is NUnit XML format
196
+ */
197
+ isNUnitXml(jsonSuite) {
198
+ // NUnit XML has test-suite elements with type attributes
199
+ if (jsonSuite['test-suite']) {
200
+ const testSuite = Array.isArray(jsonSuite['test-suite']) ? jsonSuite['test-suite'][0] : jsonSuite['test-suite'];
201
+
202
+ // Check for NUnit-specific test-suite types
203
+ return (
204
+ testSuite &&
205
+ testSuite.type &&
206
+ ['Assembly', 'TestSuite', 'TestFixture', 'ParameterizedMethod'].includes(testSuite.type)
207
+ );
208
+ }
209
+ return false;
210
+ }
211
+
212
+ processNUnitEnhanced(jsonSuite) {
213
+ debug('Processing NUnit XML with enhanced parser');
214
+
215
+ try {
216
+ const nunitParser = new NUnitXmlParser({
217
+ groupParameterized: this.groupParameterized,
218
+ ...this.opts,
219
+ });
220
+
221
+ const result = nunitParser.parseTestRun(jsonSuite);
222
+
223
+ // Add parsed tests to our collection
224
+ this.tests = this.tests.concat(result.tests);
225
+
226
+ debug(`Enhanced NUnit parser processed ${result.tests.length} tests`);
227
+
228
+ return result;
229
+ } catch (error) {
230
+ debug('Enhanced NUnit parser failed, falling back to legacy parser:', error.message);
231
+ console.warn(`${APP_PREFIX} Enhanced NUnit parsing failed, using legacy parser: ${error.message}`);
232
+
233
+ // Fallback to legacy parser
234
+ this.enhancedNunit = false;
235
+ return this.processNUnit(jsonSuite);
236
+ }
237
+ }
238
+
178
239
  processTRX(jsonSuite) {
179
240
  let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest;
180
241
  if (!Array.isArray(defs)) defs = [defs].filter(d => !!d);
181
242
 
182
- const tests =
183
- defs.map(td => {
184
- const title = td.name.replace(/\(.*?\)/, '').trim();
185
- let example = td.name.match(/\((.*?)\)/);
186
- if (example) example = { ...example[1].split(',') };
187
- const suite = td.TestMethod.className.split(', ')[0].split('.');
188
- const suite_title = suite.pop();
189
- return {
190
- title,
191
- example,
192
- file: suite.join('/'),
193
- description: td.Description,
194
- suite_title,
195
- id: td.Execution.id,
196
- };
197
- }) || [];
243
+ // Parse test definitions
244
+ const tests = defs.map(td => this._parseTRXTestDefinition(td));
198
245
 
246
+ // Parse test results
199
247
  let result = jsonSuite?.TestRun?.Results?.UnitTestResult;
200
248
  if (!Array.isArray(result)) result = [result].filter(d => !!d);
201
249
 
202
- const results = result.map(td => ({
203
- id: td.executionId,
204
- // seconds are used in junit reports, but ms are used by testomatio
205
- run_time: parseFloat(td.duration) * 1000,
206
- status: td.outcome,
207
- stack: td.Output.StdOut,
208
- files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
209
- }));
210
-
211
- results.forEach(r => {
212
- const test = tests.find(t => t.id === r.id) || {};
213
- r.suite_title = test.suite_title;
214
- r.title = test.title?.trim();
215
- if (test.code) r.code = test.code;
216
- if (test.description) r.description = test.description;
217
- if (test.example) r.example = test.example;
218
- if (test.file) r.file = test.file;
219
- r.create = true;
220
- r.overwrite = true;
221
- if (r.status === 'Passed') r.status = STATUS.PASSED;
222
- if (r.status === 'Failed') r.status = STATUS.FAILED;
223
- if (r.status === 'Skipped') r.status = STATUS.SKIPPED;
224
- delete r.id;
225
- });
250
+ const results = result.map(td => this._parseTRXTestResult(td, tests));
226
251
 
227
252
  debug(results);
228
253
 
229
254
  const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {};
230
-
231
255
  const failed_count = parseInt(counters.failed, 10) + parseInt(counters.error, 10);
232
-
233
- let status = STATUS.PASSED.toString();
234
- if (failed_count > 0) status = STATUS.FAILED;
256
+ const status = failed_count > 0 ? STATUS.FAILED : STATUS.PASSED.toString();
235
257
 
236
258
  this.tests = results.filter(t => !!t.title);
237
259
 
@@ -246,6 +268,67 @@ class XmlReader {
246
268
  };
247
269
  }
248
270
 
271
+ _parseTRXTestDefinition(td) {
272
+ const title = td.name.replace(/\(.*?\)/, '').trim();
273
+ const exampleMatch = td.name.match(/\((.*?)\)/);
274
+ const example = exampleMatch ? { ...exampleMatch[1].split(',') } : null;
275
+
276
+ const suite = td.TestMethod.className.split(', ')[0].split('.');
277
+ const suite_title = suite.pop();
278
+
279
+ // Convert namespace to file path for C#
280
+ const file = `${suite.join('/')}.cs`;
281
+
282
+ return {
283
+ title, // Base name without parameters for test import
284
+ example, // Parameters object for parameterized tests
285
+ file, // File path with .cs extension
286
+ description: td.Description,
287
+ suite_title,
288
+ id: td.Execution.id,
289
+ };
290
+ }
291
+
292
+ _parseTRXTestResult(td, tests) {
293
+ const test = tests.find(t => t.id === td.executionId) || {};
294
+
295
+ const result = {
296
+ suite_title: test.suite_title,
297
+ title: test.title?.trim(),
298
+ file: test.file,
299
+ description: test.description,
300
+ code: test.code,
301
+ run_time: parseFloat(td.duration) * 1000,
302
+ stack: td.Output?.StdOut || '',
303
+ files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
304
+ create: true,
305
+ overwrite: true,
306
+ };
307
+
308
+ // Add example for parameterized tests
309
+ if (test.example) {
310
+ result.example = test.example;
311
+ }
312
+
313
+ // Map TRX status to Testomat.io status
314
+ result.status = this._mapTRXStatus(td.outcome);
315
+
316
+ return result;
317
+ }
318
+
319
+ _mapTRXStatus(outcome) {
320
+ switch (outcome) {
321
+ case 'Passed':
322
+ return STATUS.PASSED;
323
+ case 'Failed':
324
+ return STATUS.FAILED;
325
+ case 'Skipped':
326
+ return STATUS.SKIPPED;
327
+ default:
328
+ return STATUS.PASSED;
329
+ }
330
+ }
331
+
249
332
  processXUnit(assemblies) {
250
333
  const tests = [];
251
334