@testomatio/reporter 2.3.7-beta.1-xml-import → 2.3.7-beta.100

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.
@@ -76,17 +76,29 @@ const isValidUrl = s => {
76
76
  }
77
77
  };
78
78
 
79
- const fileMatchRegex = /file:(\/+(?:[A-Za-z]:[\\/]|\/)?[^\s]*?\.(png|avi|webm|jpg|html|txt))/gi;
79
+ const fileMatchRegex = /file:(\/*)([A-Za-z]:[\\/].*?|\/.*?)\.(png|avi|webm|jpg|html|txt)/gi;
80
80
 
81
81
  const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
82
- const files = Array.from(stack.matchAll(fileMatchRegex))
83
- .map(f => f[1].trim())
82
+ let files = Array.from(stack.matchAll(fileMatchRegex))
83
+ .map(match => {
84
+ // match[0] is full match, match[1] is slashes, match[2] is path, match[3] is extension
85
+ const slashes = match[1] || '';
86
+ const path = match[2];
87
+ const extension = match[3];
88
+ return `${slashes}${path}.${extension}`;
89
+ })
90
+ .map(f => f.trim())
84
91
  .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
85
92
  .map(f => {
86
93
  // Normalize path separators for cross-platform compatibility
87
94
  return f.replace(/\\/g, '/');
88
95
  });
89
96
 
97
+ // If we're not checking file existence, remove Windows drive letters for consistency
98
+ if (!checkExists) {
99
+ files = files.map(f => f.replace(/^([A-Za-z]):/, ''));
100
+ }
101
+
90
102
  debug('Found files in stack trace: ', files);
91
103
 
92
104
  return files.filter(f => {
@@ -101,21 +113,92 @@ const fetchSourceCodeFromStackTrace = (stack = '') => {
101
113
  const stackLines = stack
102
114
  .split('\n')
103
115
  .filter(l => l.includes(':'))
104
- // .map(l => l.match(/\[(.*?)\]/)?.[1] || l) // minitest format
105
- // .map(l => l.split(':')[0])
106
116
  .map(l => l.trim())
107
- .map(l => l.split(' ').find(p => p.includes(':')) || '')
108
- .filter(l => isValid(l?.split(':')[0]))
117
+ .map(l => {
118
+ // Remove 'at ' prefix if present
119
+ if (l.startsWith('at ')) {
120
+ return l.substring(3).trim();
121
+ }
122
+ // Find the part that looks like a file path with line number
123
+ const parts = l.split(' ');
124
+ for (const part of parts) {
125
+ // Check if this part has a colon
126
+ if (part.includes(':')) {
127
+ // For Windows paths, we need to handle drive letters (C:, D:, etc.)
128
+ // Split by colon but keep drive letter with the path
129
+ const colonParts = part.split(':');
130
+ let filePath;
131
+
132
+ // Check if first part is a Windows drive letter (single letter)
133
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
134
+ // Windows path like D:\path\file.php:24
135
+ // Reconstruct as D:\path\file.php
136
+ filePath = colonParts[0] + ':' + colonParts[1];
137
+ } else {
138
+ // Unix path like /path/file.php:24
139
+ filePath = colonParts[0];
140
+ }
141
+
142
+ // Only consider it valid if the file exists
143
+ if (fs.existsSync(filePath)) {
144
+ return part;
145
+ }
146
+ }
147
+ }
148
+ // If no valid file path found in parts, return the whole line
149
+ // It will be filtered out later if it's not a valid file path
150
+ return parts.find(p => p.includes(':')) || l;
151
+ })
152
+ .filter(l => {
153
+ // Extract file path from line (accounting for Windows drive letters)
154
+ if (!l) return false;
155
+ const colonParts = l.split(':');
156
+ let filePath;
157
+
158
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
159
+ // Windows path
160
+ filePath = colonParts[0] + ':' + colonParts[1];
161
+ } else {
162
+ // Unix path
163
+ filePath = colonParts[0];
164
+ }
165
+
166
+ return filePath && fs.existsSync(filePath);
167
+ })
109
168
 
110
169
  // // filter out 3rd party libs
111
170
  .filter(l => !l?.includes(`vendor${sep}`))
112
171
  .filter(l => !l?.includes(`node_modules${sep}`))
113
- .filter(l => fs.existsSync(l.split(':')[0]))
114
- .filter(l => fs.lstatSync(l.split(':')[0]).isFile());
172
+ .filter(l => {
173
+ // Extract file path for final check (accounting for Windows drive letters)
174
+ const colonParts = l.split(':');
175
+ let filePath;
176
+
177
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
178
+ filePath = colonParts[0] + ':' + colonParts[1];
179
+ } else {
180
+ filePath = colonParts[0];
181
+ }
182
+
183
+ return fs.lstatSync(filePath).isFile();
184
+ });
115
185
 
116
186
  if (!stackLines.length) return '';
117
187
 
118
- const [file, line] = stackLines[0].split(':');
188
+ // Extract file and line number (accounting for Windows drive letters)
189
+ const firstLine = stackLines[0];
190
+ const colonParts = firstLine.split(':');
191
+ let file, line;
192
+
193
+ if (colonParts.length >= 3 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
194
+ // Windows path like D:\path\file.php:24
195
+ file = colonParts[0] + ':' + colonParts[1];
196
+ line = colonParts[2];
197
+ } else {
198
+ // Unix path like /path/file.php:24
199
+ file = colonParts[0];
200
+ line = colonParts[1];
201
+ }
119
202
 
120
203
  const prepend = 3;
121
204
  const source = fetchSourceCode(fs.readFileSync(file).toString(), { line, prepend, limit: 7 });
@@ -178,30 +261,63 @@ const fetchSourceCode = (contents, opts = {}) => {
178
261
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
179
262
  if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
180
263
  } else if (opts.lang === 'csharp') {
181
- // Enhanced C# method detection for NUnit tests
182
- lineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
264
+ // Find the method declaration line
265
+ let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
183
266
 
184
- if (lineIndex === -1) {
185
- lineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
267
+ if (methodLineIndex === -1) {
268
+ methodLineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
186
269
  }
187
270
 
188
- if (lineIndex === -1) {
189
- lineIndex = lines.findIndex(l => l.includes(`${title}(`));
271
+ if (methodLineIndex === -1) {
272
+ methodLineIndex = lines.findIndex(l => l.includes(`${title}(`));
190
273
  }
191
274
 
192
- // Look for TestCase or Test attributes above the method
193
- if (lineIndex === -1) {
194
- const testAttributeIndex = lines.findIndex((l, index) => {
195
- if (l.includes('[TestCase') || l.includes('[Test')) {
196
- // Check next few lines for the method
197
- const nextLines = lines.slice(index, Math.min(lines.length, index + 5));
198
- const hasMethod = nextLines.some(nextLine => nextLine.includes(`${title}(`));
199
- return hasMethod;
275
+ // If found, scan upwards to find [TestCase], [Test] attributes and XML comments
276
+ if (methodLineIndex !== -1) {
277
+ lineIndex = methodLineIndex;
278
+
279
+ // Scan upwards to find the start of attributes and comments
280
+ for (let i = methodLineIndex - 1; i >= 0; i--) {
281
+ const trimmedLine = lines[i].trim();
282
+
283
+ // Include [TestCase], [Test], and other attributes
284
+ if (trimmedLine.startsWith('[')) {
285
+ lineIndex = i;
286
+ continue;
287
+ }
288
+
289
+ // Include XML documentation comments
290
+ if (trimmedLine.startsWith('///')) {
291
+ lineIndex = i;
292
+ continue;
293
+ }
294
+
295
+ // Stop at empty lines (with some tolerance)
296
+ if (trimmedLine === '') {
297
+ // Check if next non-empty line is an attribute or comment
298
+ let hasMoreAttributes = false;
299
+ for (let j = i - 1; j >= 0; j--) {
300
+ const nextTrimmed = lines[j].trim();
301
+ if (nextTrimmed === '') continue;
302
+ if (nextTrimmed.startsWith('[') || nextTrimmed.startsWith('///')) {
303
+ hasMoreAttributes = true;
304
+ lineIndex = j;
305
+ }
306
+ break;
307
+ }
308
+ if (!hasMoreAttributes) break;
309
+ continue;
310
+ }
311
+
312
+ // Stop at other method declarations or class-level elements
313
+ if (
314
+ trimmedLine.includes('public ') ||
315
+ trimmedLine.includes('private ') ||
316
+ trimmedLine.includes('protected ') ||
317
+ trimmedLine.includes('internal ')
318
+ ) {
319
+ if (!trimmedLine.startsWith('[')) break;
200
320
  }
201
- return false;
202
- });
203
- if (testAttributeIndex !== -1) {
204
- lineIndex = testAttributeIndex;
205
321
  }
206
322
  }
207
323
  } else {
@@ -215,9 +331,29 @@ const fetchSourceCode = (contents, opts = {}) => {
215
331
 
216
332
  if (lineIndex !== -1 && lineIndex !== undefined) {
217
333
  const result = [];
334
+ let braceDepth = 0; // Track brace depth for C# methods
335
+ let methodStartFound = false; // Flag to indicate we've found the method opening brace
336
+
218
337
  for (let i = lineIndex; i < lineIndex + limit; i++) {
219
338
  if (lines[i] === undefined) continue;
220
339
 
340
+ // Track brace depth for C# to stop after method closes
341
+ if (opts.lang === 'csharp') {
342
+ const line = lines[i];
343
+ // Count opening and closing braces
344
+ const openBraces = (line.match(/\{/g) || []).length;
345
+ const closeBraces = (line.match(/\}/g) || []).length;
346
+
347
+ if (openBraces > 0) methodStartFound = true;
348
+ braceDepth += openBraces - closeBraces;
349
+
350
+ // If we've started the method and depth returns to 0, method is complete
351
+ if (methodStartFound && braceDepth === 0 && closeBraces > 0) {
352
+ // Don't include the closing brace - just break
353
+ break;
354
+ }
355
+ }
356
+
221
357
  if (i > lineIndex + 2 && !opts.prepend) {
222
358
  // annotation
223
359
  if (opts.lang === 'php' && lines[i].trim().startsWith('#[')) break;
@@ -238,11 +374,36 @@ const fetchSourceCode = (contents, opts = {}) => {
238
374
  if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
239
375
  if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
240
376
  if (opts.lang === 'java' && lines[i].includes(' class ')) break;
241
- if (opts.lang === 'csharp' && lines[i].trim().match(/^\[Test/)) break;
242
- if (opts.lang === 'csharp' && lines[i].includes(' public void ')) break;
243
- if (opts.lang === 'csharp' && lines[i].includes(' public async Task ')) break;
244
- if (opts.lang === 'csharp' && lines[i].includes(' class ') && lines[i].includes('public')) break;
377
+ // For C#, additional checks if brace tracking didn't stop us
378
+ if (opts.lang === 'csharp') {
379
+ const trimmed = lines[i].trim();
380
+ // Stop at attribute that marks beginning of next test (but not if we're still in the current method)
381
+ if (trimmed.match(/^\[(Test|TestCase|Theory|Fact)/) && methodStartFound && braceDepth === 0) break;
382
+ // Stop at XML documentation comments that belong to next method
383
+ if (trimmed.startsWith('///') && methodStartFound && braceDepth === 0) break;
384
+ // Stop at another method declaration (but not if we're still in the current method)
385
+ if (
386
+ trimmed.match(/^\s*(public|private|protected|internal)\s+(\w+|async\s+\w+)\s+\w+\s*\(/) &&
387
+ methodStartFound &&
388
+ braceDepth === 0
389
+ )
390
+ break;
391
+ // Stop at class declaration
392
+ if (trimmed.includes(' class ') && trimmed.includes('public')) break;
393
+ // Stop at helper method calls (like ProcessBooleanValue, AddNumbers) - these are private methods
394
+ if (methodStartFound && trimmed.match(/^\s*\/\/\s*Helper methods for testing/)) break;
395
+ }
396
+ }
397
+
398
+ // For C# tests, stop if we encounter helper method calls in the method body
399
+ if (opts.lang === 'csharp' && methodStartFound && braceDepth > 0) {
400
+ const trimmed = lines[i].trim();
401
+ // Stop at comment indicating helper methods section
402
+ if (trimmed.match(/^\s*\/\/\s*Helper methods for testing/)) {
403
+ break;
404
+ }
245
405
  }
406
+
246
407
  result.push(lines[i]);
247
408
  }
248
409
  return result.join('\n');
@@ -454,8 +615,20 @@ function transformEnvVarToBoolean(value) {
454
615
  return Boolean(value);
455
616
  }
456
617
 
618
+ function truncate(s, size = 255) {
619
+ if (s === undefined || s === null) {
620
+ return '';
621
+ }
622
+ const str = s.toString();
623
+ if (str.trim().length < size) {
624
+ return str;
625
+ }
626
+ return `${str.substring(0, size)}...`;
627
+ }
628
+
457
629
  export {
458
630
  ansiRegExp,
631
+ truncate,
459
632
  cleanLatestRunId,
460
633
  isSameTest,
461
634
  fetchSourceCode,
package/src/xmlReader.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  fetchIdFromCode,
16
16
  humanize,
17
17
  TEST_ID_REGEX,
18
+ transformEnvVarToBoolean,
18
19
  } from './utils/utils.js';
19
20
  import { pipesFactory } from './pipe/index.js';
20
21
  import adapterFactory from './junit-adapter/index.js';
@@ -36,6 +37,7 @@ const {
36
37
  TESTOMATIO_ENV,
37
38
  TESTOMATIO_RUN,
38
39
  TESTOMATIO_MARK_DETACHED,
40
+ TESTOMATIO_LEGACY_NUNIT,
39
41
  } = process.env;
40
42
 
41
43
  const options = {
@@ -77,7 +79,8 @@ class XmlReader {
77
79
  this.uploader = new S3Uploader();
78
80
 
79
81
  // Enhanced NUnit parsing - enabled by default for NUnit XML
80
- this.enhancedNunit = opts.enhancedNunit !== false; // Default true, can be disabled
82
+ // Can be disabled via opts.enhancedNunit = false or TESTOMATIO_LEGACY_NUNIT=1
83
+ this.enhancedNunit = opts.enhancedNunit !== false && !transformEnvVarToBoolean(TESTOMATIO_LEGACY_NUNIT); // Default true, can be disabled
81
84
  this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled
82
85
 
83
86
  // @ts-ignore
@@ -240,59 +243,20 @@ class XmlReader {
240
243
  let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest;
241
244
  if (!Array.isArray(defs)) defs = [defs].filter(d => !!d);
242
245
 
243
- const tests =
244
- defs.map(td => {
245
- const title = td.name.replace(/\(.*?\)/, '').trim();
246
- let example = td.name.match(/\((.*?)\)/);
247
- if (example) example = { ...example[1].split(',') };
248
- const suite = td.TestMethod.className.split(', ')[0].split('.');
249
- const suite_title = suite.pop();
250
- return {
251
- title,
252
- example,
253
- file: suite.join('/'),
254
- description: td.Description,
255
- suite_title,
256
- id: td.Execution.id,
257
- };
258
- }) || [];
246
+ // Parse test definitions
247
+ const tests = defs.map(td => this._parseTRXTestDefinition(td));
259
248
 
249
+ // Parse test results
260
250
  let result = jsonSuite?.TestRun?.Results?.UnitTestResult;
261
251
  if (!Array.isArray(result)) result = [result].filter(d => !!d);
262
252
 
263
- const results = result.map(td => ({
264
- id: td.executionId,
265
- // seconds are used in junit reports, but ms are used by testomatio
266
- run_time: parseFloat(td.duration) * 1000,
267
- status: td.outcome,
268
- stack: td.Output.StdOut,
269
- files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
270
- }));
271
-
272
- results.forEach(r => {
273
- const test = tests.find(t => t.id === r.id) || {};
274
- r.suite_title = test.suite_title;
275
- r.title = test.title?.trim();
276
- if (test.code) r.code = test.code;
277
- if (test.description) r.description = test.description;
278
- if (test.example) r.example = test.example;
279
- if (test.file) r.file = test.file;
280
- r.create = true;
281
- r.overwrite = true;
282
- if (r.status === 'Passed') r.status = STATUS.PASSED;
283
- if (r.status === 'Failed') r.status = STATUS.FAILED;
284
- if (r.status === 'Skipped') r.status = STATUS.SKIPPED;
285
- delete r.id;
286
- });
253
+ const results = result.map(td => this._parseTRXTestResult(td, tests));
287
254
 
288
255
  debug(results);
289
256
 
290
257
  const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {};
291
-
292
258
  const failed_count = parseInt(counters.failed, 10) + parseInt(counters.error, 10);
293
-
294
- let status = STATUS.PASSED.toString();
295
- if (failed_count > 0) status = STATUS.FAILED;
259
+ const status = failed_count > 0 ? STATUS.FAILED : STATUS.PASSED.toString();
296
260
 
297
261
  this.tests = results.filter(t => !!t.title);
298
262
 
@@ -307,6 +271,67 @@ class XmlReader {
307
271
  };
308
272
  }
309
273
 
274
+ _parseTRXTestDefinition(td) {
275
+ const title = td.name.replace(/\(.*?\)/, '').trim();
276
+ const exampleMatch = td.name.match(/\((.*?)\)/);
277
+ const example = exampleMatch ? { ...exampleMatch[1].split(',') } : null;
278
+
279
+ const suite = td.TestMethod.className.split(', ')[0].split('.');
280
+ const suite_title = suite.pop();
281
+
282
+ // Convert namespace to file path for C#
283
+ const file = `${suite.join('/')}.cs`;
284
+
285
+ return {
286
+ title, // Base name without parameters for test import
287
+ example, // Parameters object for parameterized tests
288
+ file, // File path with .cs extension
289
+ description: td.Description,
290
+ suite_title,
291
+ id: td.Execution.id,
292
+ };
293
+ }
294
+
295
+ _parseTRXTestResult(td, tests) {
296
+ const test = tests.find(t => t.id === td.executionId) || {};
297
+
298
+ const result = {
299
+ suite_title: test.suite_title,
300
+ title: test.title?.trim(),
301
+ file: test.file,
302
+ description: test.description,
303
+ code: test.code,
304
+ run_time: parseFloat(td.duration) * 1000,
305
+ stack: td.Output?.StdOut || '',
306
+ files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
307
+ create: true,
308
+ overwrite: true,
309
+ };
310
+
311
+ // Add example for parameterized tests
312
+ if (test.example) {
313
+ result.example = test.example;
314
+ }
315
+
316
+ // Map TRX status to Testomat.io status
317
+ result.status = this._mapTRXStatus(td.outcome);
318
+
319
+ return result;
320
+ }
321
+
322
+ _mapTRXStatus(outcome) {
323
+ switch (outcome) {
324
+ case 'Passed':
325
+ return STATUS.PASSED;
326
+ case 'Failed':
327
+ return STATUS.FAILED;
328
+ case 'Skipped':
329
+ return STATUS.SKIPPED;
330
+ default:
331
+ return STATUS.PASSED;
332
+ }
333
+ }
334
+
310
335
  processXUnit(assemblies) {
311
336
  const tests = [];
312
337