@testomatio/reporter 2.3.8-rc.1 → 2.3.8

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.
@@ -116,19 +116,26 @@ const isValidUrl = s => {
116
116
  }
117
117
  };
118
118
  exports.isValidUrl = isValidUrl;
119
- const fileMatchRegex = /file:(\/+(?:[A-Za-z]:[\\/]|\/)?[^\s]*?\.(png|avi|webm|jpg|html|txt))/gi;
119
+ const fileMatchRegex = /file:(\/*)([A-Za-z]:[\\/].*?|\/.*?)\.(png|avi|webm|jpg|html|txt)/gi;
120
120
  const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
121
- const files = Array.from(stack.matchAll(fileMatchRegex))
122
- .map(f => f[1].trim())
121
+ let files = Array.from(stack.matchAll(fileMatchRegex))
122
+ .map(match => {
123
+ // match[0] is full match, match[1] is slashes, match[2] is path, match[3] is extension
124
+ const slashes = match[1] || '';
125
+ const path = match[2];
126
+ const extension = match[3];
127
+ return `${slashes}${path}.${extension}`;
128
+ })
129
+ .map(f => f.trim())
123
130
  .map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
124
131
  .map(f => {
125
- // Convert Windows paths to Linux paths for testing purposes
126
- if (f.match(/^[A-Za-z]:[\\\/]/)) {
127
- // Convert Windows path to Linux equivalent for test scenarios
128
- return f.replace(/^[A-Za-z]:[\\\/]/, '/').replace(/\\/g, '/');
129
- }
130
- return f;
132
+ // Normalize path separators for cross-platform compatibility
133
+ return f.replace(/\\/g, '/');
131
134
  });
135
+ // If we're not checking file existence, remove Windows drive letters for consistency
136
+ if (!checkExists) {
137
+ files = files.map(f => f.replace(/^([A-Za-z]):/, ''));
138
+ }
132
139
  debug('Found files in stack trace: ', files);
133
140
  return files.filter(f => {
134
141
  if (!checkExists)
@@ -144,19 +151,88 @@ const fetchSourceCodeFromStackTrace = (stack = '') => {
144
151
  const stackLines = stack
145
152
  .split('\n')
146
153
  .filter(l => l.includes(':'))
147
- // .map(l => l.match(/\[(.*?)\]/)?.[1] || l) // minitest format
148
- // .map(l => l.split(':')[0])
149
154
  .map(l => l.trim())
150
- .map(l => l.split(' ').find(p => p.includes(':')) || '')
151
- .filter(l => (0, is_valid_path_1.default)(l?.split(':')[0]))
155
+ .map(l => {
156
+ // Remove 'at ' prefix if present
157
+ if (l.startsWith('at ')) {
158
+ return l.substring(3).trim();
159
+ }
160
+ // Find the part that looks like a file path with line number
161
+ const parts = l.split(' ');
162
+ for (const part of parts) {
163
+ // Check if this part has a colon
164
+ if (part.includes(':')) {
165
+ // For Windows paths, we need to handle drive letters (C:, D:, etc.)
166
+ // Split by colon but keep drive letter with the path
167
+ const colonParts = part.split(':');
168
+ let filePath;
169
+ // Check if first part is a Windows drive letter (single letter)
170
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
171
+ // Windows path like D:\path\file.php:24
172
+ // Reconstruct as D:\path\file.php
173
+ filePath = colonParts[0] + ':' + colonParts[1];
174
+ }
175
+ else {
176
+ // Unix path like /path/file.php:24
177
+ filePath = colonParts[0];
178
+ }
179
+ // Only consider it valid if the file exists
180
+ if (fs_1.default.existsSync(filePath)) {
181
+ return part;
182
+ }
183
+ }
184
+ }
185
+ // If no valid file path found in parts, return the whole line
186
+ // It will be filtered out later if it's not a valid file path
187
+ return parts.find(p => p.includes(':')) || l;
188
+ })
189
+ .filter(l => {
190
+ // Extract file path from line (accounting for Windows drive letters)
191
+ if (!l)
192
+ return false;
193
+ const colonParts = l.split(':');
194
+ let filePath;
195
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
196
+ // Windows path
197
+ filePath = colonParts[0] + ':' + colonParts[1];
198
+ }
199
+ else {
200
+ // Unix path
201
+ filePath = colonParts[0];
202
+ }
203
+ return filePath && fs_1.default.existsSync(filePath);
204
+ })
152
205
  // // filter out 3rd party libs
153
206
  .filter(l => !l?.includes(`vendor${path_1.sep}`))
154
207
  .filter(l => !l?.includes(`node_modules${path_1.sep}`))
155
- .filter(l => fs_1.default.existsSync(l.split(':')[0]))
156
- .filter(l => fs_1.default.lstatSync(l.split(':')[0]).isFile());
208
+ .filter(l => {
209
+ // Extract file path for final check (accounting for Windows drive letters)
210
+ const colonParts = l.split(':');
211
+ let filePath;
212
+ if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
213
+ filePath = colonParts[0] + ':' + colonParts[1];
214
+ }
215
+ else {
216
+ filePath = colonParts[0];
217
+ }
218
+ return fs_1.default.lstatSync(filePath).isFile();
219
+ });
157
220
  if (!stackLines.length)
158
221
  return '';
159
- const [file, line] = stackLines[0].split(':');
222
+ // Extract file and line number (accounting for Windows drive letters)
223
+ const firstLine = stackLines[0];
224
+ const colonParts = firstLine.split(':');
225
+ let file, line;
226
+ if (colonParts.length >= 3 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
227
+ // Windows path like D:\path\file.php:24
228
+ file = colonParts[0] + ':' + colonParts[1];
229
+ line = colonParts[2];
230
+ }
231
+ else {
232
+ // Unix path like /path/file.php:24
233
+ file = colonParts[0];
234
+ line = colonParts[1];
235
+ }
160
236
  const prepend = 3;
161
237
  const source = fetchSourceCode(fs_1.default.readFileSync(file).toString(), { line, prepend, limit: 7 });
162
238
  if (!source)
@@ -174,6 +250,8 @@ exports.fetchSourceCodeFromStackTrace = fetchSourceCodeFromStackTrace;
174
250
  exports.TEST_ID_REGEX = /@T([\w\d]{8})/;
175
251
  exports.SUITE_ID_REGEX = /@S([\w\d]{8})/;
176
252
  const fetchIdFromCode = (code, opts = {}) => {
253
+ if (!code)
254
+ return null;
177
255
  const comments = code
178
256
  .split('\n')
179
257
  .map(l => l.trim())
@@ -216,10 +294,58 @@ const fetchSourceCode = (contents, opts = {}) => {
216
294
  lineIndex = lines.findIndex(l => l.includes(`${title}(`));
217
295
  }
218
296
  else if (opts.lang === 'csharp') {
219
- if (lineIndex === -1)
220
- lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
221
- if (lineIndex === -1)
222
- lineIndex = lines.findIndex(l => l.includes(`${title}(`));
297
+ // Find the method declaration line
298
+ let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
299
+ if (methodLineIndex === -1) {
300
+ methodLineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
301
+ }
302
+ if (methodLineIndex === -1) {
303
+ methodLineIndex = lines.findIndex(l => l.includes(`${title}(`));
304
+ }
305
+ // If found, scan upwards to find [TestCase], [Test] attributes and XML comments
306
+ if (methodLineIndex !== -1) {
307
+ lineIndex = methodLineIndex;
308
+ // Scan upwards to find the start of attributes and comments
309
+ for (let i = methodLineIndex - 1; i >= 0; i--) {
310
+ const trimmedLine = lines[i].trim();
311
+ // Include [TestCase], [Test], and other attributes
312
+ if (trimmedLine.startsWith('[')) {
313
+ lineIndex = i;
314
+ continue;
315
+ }
316
+ // Include XML documentation comments
317
+ if (trimmedLine.startsWith('///')) {
318
+ lineIndex = i;
319
+ continue;
320
+ }
321
+ // Stop at empty lines (with some tolerance)
322
+ if (trimmedLine === '') {
323
+ // Check if next non-empty line is an attribute or comment
324
+ let hasMoreAttributes = false;
325
+ for (let j = i - 1; j >= 0; j--) {
326
+ const nextTrimmed = lines[j].trim();
327
+ if (nextTrimmed === '')
328
+ continue;
329
+ if (nextTrimmed.startsWith('[') || nextTrimmed.startsWith('///')) {
330
+ hasMoreAttributes = true;
331
+ lineIndex = j;
332
+ }
333
+ break;
334
+ }
335
+ if (!hasMoreAttributes)
336
+ break;
337
+ continue;
338
+ }
339
+ // Stop at other method declarations or class-level elements
340
+ if (trimmedLine.includes('public ') ||
341
+ trimmedLine.includes('private ') ||
342
+ trimmedLine.includes('protected ') ||
343
+ trimmedLine.includes('internal ')) {
344
+ if (!trimmedLine.startsWith('['))
345
+ break;
346
+ }
347
+ }
348
+ }
223
349
  }
224
350
  else {
225
351
  lineIndex = lines.findIndex(l => l.includes(title));
@@ -228,11 +354,28 @@ const fetchSourceCode = (contents, opts = {}) => {
228
354
  if (opts.prepend) {
229
355
  lineIndex -= opts.prepend;
230
356
  }
231
- if (lineIndex) {
357
+ if (lineIndex !== -1 && lineIndex !== undefined) {
232
358
  const result = [];
359
+ let braceDepth = 0; // Track brace depth for C# methods
360
+ let methodStartFound = false; // Flag to indicate we've found the method opening brace
233
361
  for (let i = lineIndex; i < lineIndex + limit; i++) {
234
362
  if (lines[i] === undefined)
235
363
  continue;
364
+ // Track brace depth for C# to stop after method closes
365
+ if (opts.lang === 'csharp') {
366
+ const line = lines[i];
367
+ // Count opening and closing braces
368
+ const openBraces = (line.match(/\{/g) || []).length;
369
+ const closeBraces = (line.match(/\}/g) || []).length;
370
+ if (openBraces > 0)
371
+ methodStartFound = true;
372
+ braceDepth += openBraces - closeBraces;
373
+ // If we've started the method and depth returns to 0, method is complete
374
+ if (methodStartFound && braceDepth === 0 && closeBraces > 0) {
375
+ // Don't include the closing brace - just break
376
+ break;
377
+ }
378
+ }
236
379
  if (i > lineIndex + 2 && !opts.prepend) {
237
380
  // annotation
238
381
  if (opts.lang === 'php' && lines[i].trim().startsWith('#['))
@@ -271,6 +414,24 @@ const fetchSourceCode = (contents, opts = {}) => {
271
414
  break;
272
415
  if (opts.lang === 'java' && lines[i].includes(' class '))
273
416
  break;
417
+ // For C#, additional checks if brace tracking didn't stop us
418
+ if (opts.lang === 'csharp') {
419
+ const trimmed = lines[i].trim();
420
+ // Stop at attribute that marks beginning of next test (but not if we're still in the current method)
421
+ if (trimmed.match(/^\[(Test|TestCase|Theory|Fact)/) && methodStartFound && braceDepth === 0)
422
+ break;
423
+ // Stop at XML documentation comments that belong to next method
424
+ if (trimmed.startsWith('///') && methodStartFound && braceDepth === 0)
425
+ break;
426
+ // Stop at another method declaration (but not if we're still in the current method)
427
+ if (trimmed.match(/^\s*(public|private|protected|internal)\s+(\w+|async\s+\w+)\s+\w+\s*\(/) &&
428
+ methodStartFound &&
429
+ braceDepth === 0)
430
+ break;
431
+ // Stop at class declaration
432
+ if (trimmed.includes(' class ') && trimmed.includes('public'))
433
+ break;
434
+ }
274
435
  }
275
436
  result.push(lines[i]);
276
437
  }
@@ -19,25 +19,11 @@ declare class XmlReader {
19
19
  tests: any[];
20
20
  stats: {};
21
21
  uploader: S3Uploader;
22
+ enhancedNunit: boolean;
23
+ groupParameterized: boolean;
22
24
  version: any;
23
25
  connectAdapter(): import("./junit-adapter/adapter.js").default;
24
- parse(fileName: any): {
25
- status: string;
26
- create_tests: boolean;
27
- tests_count: number;
28
- passed_count: number;
29
- skipped_count: number;
30
- failed_count: number;
31
- tests: any;
32
- } | {
33
- status: any;
34
- create_tests: boolean;
35
- tests_count: number;
36
- passed_count: number;
37
- failed_count: number;
38
- skipped_count: number;
39
- tests: any[];
40
- };
26
+ parse(fileName: any): any;
41
27
  processJUnit(jsonSuite: any): {
42
28
  create_tests: boolean;
43
29
  duration: number;
@@ -49,15 +35,14 @@ declare class XmlReader {
49
35
  tests: any[];
50
36
  tests_count: number;
51
37
  };
52
- processNUnit(jsonSuite: any): {
53
- status: any;
54
- create_tests: boolean;
55
- tests_count: number;
56
- passed_count: number;
57
- failed_count: number;
58
- skipped_count: number;
59
- tests: any[];
60
- };
38
+ processNUnit(jsonSuite: any): any;
39
+ /**
40
+ * Check if the XML is actually NUnit format (has test-suite hierarchy)
41
+ * @param {Object} jsonSuite - Parsed XML suite object
42
+ * @returns {boolean} - True if this is NUnit XML format
43
+ */
44
+ isNUnitXml(jsonSuite: any): boolean;
45
+ processNUnitEnhanced(jsonSuite: any): any;
61
46
  processTRX(jsonSuite: any): {
62
47
  status: string;
63
48
  create_tests: boolean;
@@ -67,6 +52,27 @@ declare class XmlReader {
67
52
  failed_count: number;
68
53
  tests: any;
69
54
  };
55
+ _parseTRXTestDefinition(td: any): {
56
+ title: any;
57
+ example: any;
58
+ file: string;
59
+ description: any;
60
+ suite_title: any;
61
+ id: any;
62
+ };
63
+ _parseTRXTestResult(td: any, tests: any): {
64
+ suite_title: any;
65
+ title: any;
66
+ file: any;
67
+ description: any;
68
+ code: any;
69
+ run_time: number;
70
+ stack: any;
71
+ files: any;
72
+ create: boolean;
73
+ overwrite: boolean;
74
+ };
75
+ _mapTRXStatus(outcome: any): string;
70
76
  processXUnit(assemblies: any): {
71
77
  status: string;
72
78
  create_tests: boolean;
package/lib/xmlReader.js CHANGED
@@ -11,6 +11,7 @@ const fast_xml_parser_1 = require("fast-xml-parser");
11
11
  const constants_js_1 = require("./constants.js");
12
12
  const crypto_1 = require("crypto");
13
13
  const url_1 = require("url");
14
+ const nunit_parser_js_1 = require("./junit-adapter/nunit-parser.js");
14
15
  const utils_js_1 = require("./utils/utils.js");
15
16
  const index_js_1 = require("./pipe/index.js");
16
17
  const index_js_2 = __importDefault(require("./junit-adapter/index.js"));
@@ -20,7 +21,7 @@ const uploader_js_1 = require("./uploader.js");
20
21
  const debug = (0, debug_1.default)('@testomatio/reporter:xml');
21
22
  const ridRunId = (0, crypto_1.randomUUID)();
22
23
  const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
23
- const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_MAX_STACK_TRACE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN, TESTOMATIO_MARK_DETACHED, } = process.env;
24
+ const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_MAX_STACK_TRACE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN, TESTOMATIO_MARK_DETACHED, TESTOMATIO_LEGACY_NUNIT, } = process.env;
24
25
  const options = {
25
26
  ignoreDeclaration: true,
26
27
  ignoreAttributes: false,
@@ -54,6 +55,10 @@ class XmlReader {
54
55
  this.stats = {};
55
56
  this.stats.language = opts.lang?.toLowerCase();
56
57
  this.uploader = new uploader_js_1.S3Uploader();
58
+ // Enhanced NUnit parsing - enabled by default for NUnit XML
59
+ // Can be disabled via opts.enhancedNunit = false or TESTOMATIO_LEGACY_NUNIT=1
60
+ this.enhancedNunit = !(0, utils_js_1.transformEnvVarToBoolean)(TESTOMATIO_LEGACY_NUNIT);
61
+ this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled
57
62
  // @ts-ignore
58
63
  const packageJsonPath = path_1.default.resolve(__dirname, '..', 'package.json');
59
64
  this.version = JSON.parse(fs_1.default.readFileSync(packageJsonPath).toString()).version;
@@ -102,7 +107,8 @@ class XmlReader {
102
107
  return this.processJUnit(jsonSuite);
103
108
  }
104
109
  processJUnit(jsonSuite) {
105
- const { testsuite, name, tests, failures, errors } = jsonSuite;
110
+ const { testsuite, name, failures, errors } = jsonSuite;
111
+ const tests = testsuite?.tests || jsonSuite.tests;
106
112
  reduceOptions.preferClassname = this.stats.language === 'python';
107
113
  const resultTests = processTestSuite(testsuite);
108
114
  const hasFailures = resultTests.filter(t => t.status === 'failed').length > 0;
@@ -128,6 +134,13 @@ class XmlReader {
128
134
  };
129
135
  }
130
136
  processNUnit(jsonSuite) {
137
+ // Use enhanced NUnit parser if enabled and this is actually NUnit XML
138
+ if (this.enhancedNunit && this.isNUnitXml(jsonSuite)) {
139
+ debug('Using enhanced NUnit parser');
140
+ return this.processNUnitEnhanced(jsonSuite);
141
+ }
142
+ // Fallback to legacy parser for backward compatibility
143
+ debug('Using legacy NUnit parser');
131
144
  const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
132
145
  reduceOptions.preferClassname = this.stats.language === 'python';
133
146
  const resultTests = processTestSuite(jsonSuite['test-suite']);
@@ -142,65 +155,58 @@ class XmlReader {
142
155
  tests: resultTests,
143
156
  };
144
157
  }
158
+ /**
159
+ * Check if the XML is actually NUnit format (has test-suite hierarchy)
160
+ * @param {Object} jsonSuite - Parsed XML suite object
161
+ * @returns {boolean} - True if this is NUnit XML format
162
+ */
163
+ isNUnitXml(jsonSuite) {
164
+ // NUnit XML has test-suite elements with type attributes
165
+ if (jsonSuite['test-suite']) {
166
+ const testSuite = Array.isArray(jsonSuite['test-suite']) ? jsonSuite['test-suite'][0] : jsonSuite['test-suite'];
167
+ // Check for NUnit-specific test-suite types
168
+ return (testSuite &&
169
+ testSuite.type &&
170
+ ['Assembly', 'TestSuite', 'TestFixture', 'ParameterizedMethod'].includes(testSuite.type));
171
+ }
172
+ return false;
173
+ }
174
+ processNUnitEnhanced(jsonSuite) {
175
+ debug('Processing NUnit XML with enhanced parser');
176
+ try {
177
+ const nunitParser = new nunit_parser_js_1.NUnitXmlParser({
178
+ groupParameterized: this.groupParameterized,
179
+ ...this.opts,
180
+ });
181
+ const result = nunitParser.parseTestRun(jsonSuite);
182
+ // Add parsed tests to our collection
183
+ this.tests = this.tests.concat(result.tests);
184
+ debug(`Enhanced NUnit parser processed ${result.tests.length} tests`);
185
+ return result;
186
+ }
187
+ catch (error) {
188
+ debug('Enhanced NUnit parser failed, falling back to legacy parser:', error.message);
189
+ console.warn(`${constants_js_1.APP_PREFIX} Enhanced NUnit parsing failed, using legacy parser: ${error.message}`);
190
+ // Fallback to legacy parser
191
+ this.enhancedNunit = false;
192
+ return this.processNUnit(jsonSuite);
193
+ }
194
+ }
145
195
  processTRX(jsonSuite) {
146
196
  let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest;
147
197
  if (!Array.isArray(defs))
148
198
  defs = [defs].filter(d => !!d);
149
- const tests = defs.map(td => {
150
- const title = td.name.replace(/\(.*?\)/, '').trim();
151
- let example = td.name.match(/\((.*?)\)/);
152
- if (example)
153
- example = { ...example[1].split(',') };
154
- const suite = td.TestMethod.className.split(', ')[0].split('.');
155
- const suite_title = suite.pop();
156
- return {
157
- title,
158
- example,
159
- file: suite.join('/'),
160
- description: td.Description,
161
- suite_title,
162
- id: td.Execution.id,
163
- };
164
- }) || [];
199
+ // Parse test definitions
200
+ const tests = defs.map(td => this._parseTRXTestDefinition(td));
201
+ // Parse test results
165
202
  let result = jsonSuite?.TestRun?.Results?.UnitTestResult;
166
203
  if (!Array.isArray(result))
167
204
  result = [result].filter(d => !!d);
168
- const results = result.map(td => ({
169
- id: td.executionId,
170
- // seconds are used in junit reports, but ms are used by testomatio
171
- run_time: parseFloat(td.duration) * 1000,
172
- status: td.outcome,
173
- stack: td.Output.StdOut,
174
- files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
175
- }));
176
- results.forEach(r => {
177
- const test = tests.find(t => t.id === r.id) || {};
178
- r.suite_title = test.suite_title;
179
- r.title = test.title?.trim();
180
- if (test.code)
181
- r.code = test.code;
182
- if (test.description)
183
- r.description = test.description;
184
- if (test.example)
185
- r.example = test.example;
186
- if (test.file)
187
- r.file = test.file;
188
- r.create = true;
189
- r.overwrite = true;
190
- if (r.status === 'Passed')
191
- r.status = constants_js_1.STATUS.PASSED;
192
- if (r.status === 'Failed')
193
- r.status = constants_js_1.STATUS.FAILED;
194
- if (r.status === 'Skipped')
195
- r.status = constants_js_1.STATUS.SKIPPED;
196
- delete r.id;
197
- });
205
+ const results = result.map(td => this._parseTRXTestResult(td, tests));
198
206
  debug(results);
199
207
  const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {};
200
208
  const failed_count = parseInt(counters.failed, 10) + parseInt(counters.error, 10);
201
- let status = constants_js_1.STATUS.PASSED.toString();
202
- if (failed_count > 0)
203
- status = constants_js_1.STATUS.FAILED;
209
+ const status = failed_count > 0 ? constants_js_1.STATUS.FAILED : constants_js_1.STATUS.PASSED.toString();
204
210
  this.tests = results.filter(t => !!t.title);
205
211
  return {
206
212
  status,
@@ -212,6 +218,59 @@ class XmlReader {
212
218
  tests: results,
213
219
  };
214
220
  }
221
+ _parseTRXTestDefinition(td) {
222
+ const title = td.name.replace(/\(.*?\)/, '').trim();
223
+ const exampleMatch = td.name.match(/\((.*?)\)/);
224
+ const example = exampleMatch ? {
225
+ ...exampleMatch[1].split(',').map(p => p.trim()).filter(p => p !== '')
226
+ } : null;
227
+ const suite = td.TestMethod.className.split(', ')[0].split('.');
228
+ const suite_title = suite.pop();
229
+ // Convert namespace to file path for C#
230
+ const file = `${suite.join('/')}.cs`;
231
+ return {
232
+ title, // Base name without parameters for test import
233
+ example, // Parameters object for parameterized tests
234
+ file, // File path with .cs extension
235
+ description: td.Description,
236
+ suite_title,
237
+ id: td.Execution.id,
238
+ };
239
+ }
240
+ _parseTRXTestResult(td, tests) {
241
+ const test = tests.find(t => t.id === td.executionId) || {};
242
+ const result = {
243
+ suite_title: test.suite_title,
244
+ title: test.title?.trim(),
245
+ file: test.file,
246
+ description: test.description,
247
+ code: test.code,
248
+ run_time: parseFloat(td.duration) * 1000,
249
+ stack: td.Output?.StdOut || '',
250
+ files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
251
+ create: true,
252
+ overwrite: true,
253
+ };
254
+ // Add example for parameterized tests
255
+ if (test.example) {
256
+ result.example = test.example;
257
+ }
258
+ // Map TRX status to Testomat.io status
259
+ result.status = this._mapTRXStatus(td.outcome);
260
+ return result;
261
+ }
262
+ _mapTRXStatus(outcome) {
263
+ switch (outcome) {
264
+ case 'Passed':
265
+ return constants_js_1.STATUS.PASSED;
266
+ case 'Failed':
267
+ return constants_js_1.STATUS.FAILED;
268
+ case 'Skipped':
269
+ return constants_js_1.STATUS.SKIPPED;
270
+ default:
271
+ return constants_js_1.STATUS.PASSED;
272
+ }
273
+ }
215
274
  processXUnit(assemblies) {
216
275
  const tests = [];
217
276
  assemblies = Array.isArray(assemblies.assembly) ? assemblies.assembly : [assemblies.assembly];
@@ -459,7 +518,7 @@ function reduceTestCases(prev, item) {
459
518
  tags ||= [];
460
519
  const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
461
520
  if (exampleMatches) {
462
- example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')) };
521
+ example = { ...exampleMatches[1].split(',').map(v => v.trim().replace(/[^\w\s-]/g, '')).filter(v => v !== '') };
463
522
  title = title.replace(/\(.*?\)/, '').trim();
464
523
  }
465
524
  stack = `${testCaseItem['system-out'] || testCaseItem.output || testCaseItem.log || ''}\n\n${stack}\n\n${suiteOutput}\n\n${suiteErr}`.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testomatio/reporter",
3
- "version": "2.3.8-rc.1",
3
+ "version": "2.3.8",
4
4
  "description": "Testomatio Reporter Client",
5
5
  "engines": {
6
6
  "node": ">=18"
package/src/bin/cli.js CHANGED
@@ -35,14 +35,20 @@ program
35
35
  program
36
36
  .command('start')
37
37
  .description('Start a new run and return its ID')
38
- .action(async () => {
38
+ .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
39
+ .action(async (opts) => {
39
40
  cleanLatestRunId();
40
41
 
41
42
  console.log('Starting a new Run on Testomat.io...');
42
43
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
43
44
  const client = new TestomatClient({ apiKey });
44
45
 
45
- client.createRun().then(() => {
46
+ const createRunParams = {};
47
+ if (opts.kind) {
48
+ createRunParams.kind = opts.kind;
49
+ }
50
+
51
+ client.createRun(createRunParams).then(() => {
46
52
  console.log(process.env.runId);
47
53
  process.exit(0);
48
54
  });
@@ -75,6 +81,7 @@ program
75
81
  .description('Run tests with the specified command')
76
82
  .argument('<command>', 'Test runner command')
77
83
  .option('--filter <filter>', 'Additional execution filter')
84
+ .option('--kind <type>', 'Specify run type: automated, manual, or mixed')
78
85
  .action(async (command, opts) => {
79
86
  const apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config.TESTOMATIO;
80
87
  const title = process.env.TESTOMATIO_TITLE;
@@ -120,8 +127,13 @@ program
120
127
  });
121
128
  };
122
129
 
130
+ const createRunParams = {};
131
+ if (opts.kind) {
132
+ createRunParams.kind = opts.kind;
133
+ }
134
+
123
135
  if (apiKey) {
124
- await client.createRun().then(runTests);
136
+ await client.createRun(createRunParams).then(runTests);
125
137
  } else {
126
138
  await runTests();
127
139
  }
package/src/client.js CHANGED
@@ -120,7 +120,7 @@ class Client {
120
120
  *
121
121
  * @returns {Promise<any>} - resolves to Run id which should be used to update / add test
122
122
  */
123
- async createRun(params) {
123
+ async createRun(params = {}) {
124
124
  if (!this.pipes || !this.pipes.length)
125
125
  this.pipes = await pipesFactory(params || this.paramsForPipesFactory || {}, this.pipeStore);
126
126
  debug('Creating run...');
@@ -128,7 +128,7 @@ class Client {
128
128
  if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve();
129
129
 
130
130
  this.queue = this.queue
131
- .then(() => Promise.all(this.pipes.map(p => p.createRun())))
131
+ .then(() => Promise.all(this.pipes.map(p => p.createRun(params))))
132
132
  .catch(err => console.log(APP_PREFIX, err))
133
133
  .then(() => {
134
134
  const runId = this.pipeStore?.runId;
@@ -8,7 +8,7 @@ class CSharpAdapter extends Adapter {
8
8
  const exampleMatch = t.title.match(/\((.*?)\)/);
9
9
  if (exampleMatch) {
10
10
  // Extract parameters as object with numeric keys for API
11
- const params = exampleMatch[1].split(',').map(param => param.trim());
11
+ const params = exampleMatch[1].split(',').map(param => param.trim()).filter(param => param !== '');
12
12
  t.example = {};
13
13
  params.forEach((param, index) => {
14
14
  t.example[index] = param;