@testomatio/reporter 2.3.7 → 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.
@@ -23,7 +23,7 @@ program
23
23
  .option('--timelimit <time>', 'default time limit in seconds to kill a stuck process')
24
24
  .option('--env-file <envfile>', 'Load environment variables from env file')
25
25
  .action(async (pattern, opts) => {
26
- if (!pattern.endsWith('.xml')) {
26
+ if (!pattern.endsWith('.xml') && !pattern.includes('*')) {
27
27
  pattern += '.xml';
28
28
  }
29
29
  let { javaTests, lang } = opts;
@@ -34,7 +34,10 @@ program
34
34
  }
35
35
  lang = lang?.toLowerCase();
36
36
  if (javaTests === true || (lang === 'java' && !javaTests)) javaTests = 'src/test/java';
37
- const runReader = new XmlReader({ javaTests, lang });
37
+ const runReader = new XmlReader({
38
+ javaTests,
39
+ lang,
40
+ });
38
41
  const files = glob.sync(pattern, { cwd: opts.dir || process.cwd() });
39
42
  if (!files.length) {
40
43
  console.log(APP_PREFIX, `Report can't be created. No XML files found 😥`);
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;
@@ -3,18 +3,50 @@ import Adapter from './adapter.js';
3
3
 
4
4
  class CSharpAdapter extends Adapter {
5
5
  formatTest(t) {
6
- const title = t.title.replace(/\(.*?\)/, '').trim();
7
- const example = t.title.match(/\((.*?)\)/);
8
- if (example) t.example = { ...example[1].split(',') };
6
+ // Extract example from title if not already present
7
+ if (!t.example) {
8
+ const exampleMatch = t.title.match(/\((.*?)\)/);
9
+ if (exampleMatch) {
10
+ // Extract parameters as object with numeric keys for API
11
+ const params = exampleMatch[1].split(',').map(param => param.trim()).filter(param => param !== '');
12
+ t.example = {};
13
+ params.forEach((param, index) => {
14
+ t.example[index] = param;
15
+ });
16
+ }
17
+ }
18
+
19
+ // Remove parameters from title to avoid duplicates in Test Suite
20
+ // The example field will be used for grouping on import
21
+ t.title = t.title.replace(/\(.*?\)/, '').trim();
22
+
9
23
  const suite = t.suite_title.split('.');
10
24
  t.suite_title = suite.pop();
11
25
  t.file = namespaceToFileName(t.file);
12
- t.title = title.trim();
13
26
  return t;
14
27
  }
15
28
 
16
29
  getFilePath(t) {
17
- const fileName = namespaceToFileName(t.file);
30
+ if (!t.file) return null;
31
+
32
+ // Normalize path separators for cross-platform compatibility
33
+ let filePath = t.file.replace(/\\/g, '/');
34
+
35
+ // If file already has .cs extension, use it directly
36
+ if (filePath.endsWith('.cs')) {
37
+ // Make relative path if it's absolute
38
+ if (path.isAbsolute(filePath)) {
39
+ // Try to find project-relative path
40
+ const cwd = process.cwd().replace(/\\/g, '/');
41
+ if (filePath.startsWith(cwd)) {
42
+ filePath = path.relative(cwd, filePath).replace(/\\/g, '/');
43
+ }
44
+ }
45
+ return filePath;
46
+ }
47
+
48
+ // Convert namespace path to file path
49
+ const fileName = namespaceToFileName(filePath);
18
50
  return fileName;
19
51
  }
20
52
  }
@@ -22,7 +54,14 @@ class CSharpAdapter extends Adapter {
22
54
  export default CSharpAdapter;
23
55
 
24
56
  function namespaceToFileName(fileName) {
57
+ if (!fileName) return '';
58
+
59
+ // If already a .cs file path, clean it up
60
+ if (fileName.endsWith('.cs')) {
61
+ return fileName.replace(/\\/g, '/');
62
+ }
63
+
25
64
  const fileParts = fileName.split('.');
26
65
  fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
27
- return `${fileParts.join(path.sep)}.cs`;
66
+ return `${fileParts.join('/')}.cs`;
28
67
  }
@@ -0,0 +1,472 @@
1
+ import createDebugMessages from 'debug';
2
+ import { STATUS } from '../constants.js';
3
+ import { fetchFilesFromStackTrace } from '../utils/utils.js';
4
+
5
+ const debug = createDebugMessages('@testomatio/reporter:nunit-parser');
6
+
7
+ /**
8
+ * Enhanced NUnit XML Parser that properly handles test-suite hierarchy
9
+ * and parameterized tests
10
+ */
11
+ export class NUnitXmlParser {
12
+ constructor(options = {}) {
13
+ this.options = options;
14
+ this.tests = [];
15
+ this.stats = {
16
+ total: 0,
17
+ passed: 0,
18
+ failed: 0,
19
+ skipped: 0,
20
+ inconclusive: 0,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Parse NUnit XML test-run structure
26
+ * @param {Object} testRun - Parsed XML test-run object
27
+ * @returns {Object} - Parsed test results
28
+ */
29
+ parseTestRun(testRun) {
30
+ debug('Parsing NUnit test-run');
31
+
32
+ // Extract run-level statistics
33
+ this.stats = {
34
+ total: parseInt(testRun.total || 0, 10),
35
+ passed: parseInt(testRun.passed || 0, 10),
36
+ failed: parseInt(testRun.failed || 0, 10),
37
+ skipped: parseInt(testRun.skipped || 0, 10),
38
+ inconclusive: parseInt(testRun.inconclusive || 0, 10),
39
+ };
40
+
41
+ // Process the root test-suite
42
+ if (testRun['test-suite']) {
43
+ this.parseTestSuite(testRun['test-suite'], []);
44
+ }
45
+
46
+ debug(`Parsed ${this.tests.length} tests from NUnit XML`);
47
+
48
+ return {
49
+ status: testRun.result?.toLowerCase() || 'unknown',
50
+ create_tests: true,
51
+ tests_count: this.tests.length,
52
+ passed_count: this.tests.filter(t => t.status === STATUS.PASSED).length,
53
+ failed_count: this.tests.filter(t => t.status === STATUS.FAILED).length,
54
+ skipped_count: this.tests.filter(t => t.status === STATUS.SKIPPED).length,
55
+ tests: this.tests,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Recursively parse test-suite elements based on their type
61
+ * @param {Object|Array} testSuite - Test suite object or array
62
+ * @param {Array} parentPath - Current path in the hierarchy
63
+ */
64
+ parseTestSuite(testSuite, parentPath = []) {
65
+ if (!testSuite) return;
66
+
67
+ // Handle arrays of test suites
68
+ if (Array.isArray(testSuite)) {
69
+ testSuite.forEach(suite => this.parseTestSuite(suite, parentPath));
70
+ return;
71
+ }
72
+
73
+ const suiteType = testSuite.type;
74
+ const suiteName = testSuite.name;
75
+ const fullName = testSuite.fullname;
76
+
77
+ debug(`Processing test-suite: type=${suiteType}, name=${suiteName}`);
78
+
79
+ switch (suiteType) {
80
+ case 'Assembly':
81
+ // Assembly level - ignore the name, just process children
82
+ debug('Processing Assembly level - ignoring name, processing children');
83
+ this.processChildren(testSuite, parentPath);
84
+ break;
85
+
86
+ case 'TestSuite':
87
+ // Namespace/grouping level - add to path but don't create test
88
+ debug(`Processing TestSuite level - adding '${suiteName}' to path`);
89
+ // Avoid adding duplicate suite names to the path
90
+ const newPath = parentPath[parentPath.length - 1] === suiteName ? [...parentPath] : [...parentPath, suiteName];
91
+ this.processChildren(testSuite, newPath);
92
+ break;
93
+
94
+ case 'TestFixture':
95
+ // Test class level - add to path and process test cases
96
+ debug(`Processing TestFixture level - test class '${suiteName}'`);
97
+ const testFixturePath = [...parentPath, suiteName];
98
+ this.processChildren(testSuite, testFixturePath);
99
+ break;
100
+
101
+ case 'ParameterizedMethod':
102
+ // Parameterized method level - process test cases directly
103
+ debug(`Processing ParameterizedMethod level - method '${suiteName}'`);
104
+ // Don't add to path, just process children directly
105
+ this.processChildren(testSuite, parentPath);
106
+ break;
107
+
108
+ default:
109
+ debug(`Unknown test-suite type: ${suiteType}, treating as TestSuite`);
110
+ const unknownPath = [...parentPath, suiteName];
111
+ this.processChildren(testSuite, unknownPath);
112
+ break;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Process child elements of a test suite
118
+ * @param {Object} testSuite - Test suite object
119
+ * @param {Array} currentPath - Current path in hierarchy
120
+ */
121
+ processChildren(testSuite, currentPath) {
122
+ // Process test-cases first (to maintain order)
123
+ if (testSuite['test-case']) {
124
+ this.parseTestCases(testSuite['test-case'], currentPath, testSuite);
125
+ }
126
+
127
+ // Process nested test-suites
128
+ if (testSuite['test-suite']) {
129
+ this.parseTestSuite(testSuite['test-suite'], currentPath);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Parse test-case elements (actual tests)
135
+ * @param {Object|Array} testCases - Test case object or array
136
+ * @param {Array} suitePath - Path to the test suite
137
+ * @param {Object} parentSuite - Parent test suite for context
138
+ */
139
+ parseTestCases(testCases, suitePath, parentSuite) {
140
+ if (!testCases) return;
141
+
142
+ // Handle arrays of test cases
143
+ if (!Array.isArray(testCases)) {
144
+ testCases = [testCases];
145
+ }
146
+
147
+ testCases.forEach(testCase => {
148
+ const parsedTest = this.parseTestCase(testCase, suitePath, parentSuite);
149
+ if (parsedTest) {
150
+ this.tests.push(parsedTest);
151
+ }
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Parse individual test case
157
+ * @param {Object} testCase - Test case object
158
+ * @param {Array} suitePath - Path to the test suite
159
+ * @param {Object} parentSuite - Parent test suite for context
160
+ * @returns {Object|null} - Parsed test object
161
+ */
162
+ parseTestCase(testCase, suitePath, parentSuite) {
163
+ if (!testCase || !testCase.name) {
164
+ debug('Skipping test case without name');
165
+ return null;
166
+ }
167
+
168
+ // Use Description from properties if available (for SpecFlow tests), otherwise use name
169
+ let testName = testCase.name;
170
+ if (testCase.properties && testCase.properties.property) {
171
+ const properties = Array.isArray(testCase.properties.property)
172
+ ? testCase.properties.property
173
+ : [testCase.properties.property];
174
+
175
+ const descriptionProperty = properties.find(p => p.name === 'Description');
176
+ if (descriptionProperty && descriptionProperty.value) {
177
+ // Clean up SpecFlow description format: [C211256] Allow mobile print behavior -> Allow mobile print behavior
178
+ testName = descriptionProperty.value.replace(/^\[[^\]]+\]\s*/, '');
179
+ }
180
+ }
181
+
182
+ const fullName = testCase.fullname;
183
+ const methodName = testCase.methodname || this.extractMethodName(testName);
184
+ const className = testCase.classname || parentSuite?.name;
185
+
186
+ debug(`Parsing test case: ${testName}`);
187
+ debug(`Test case structure:`, JSON.stringify(testCase, null, 2));
188
+
189
+ // Extract parameters if this is a parameterized test
190
+ const { baseMethodName, parameters, isParameterized } = this.extractParameters(testName);
191
+
192
+ // Determine test status
193
+ let status = STATUS.PASSED;
194
+ if (testCase.result) {
195
+ switch (testCase.result.toLowerCase()) {
196
+ case 'passed':
197
+ status = STATUS.PASSED;
198
+ break;
199
+ case 'failed':
200
+ status = STATUS.FAILED;
201
+ break;
202
+ case 'skipped':
203
+ case 'ignored':
204
+ status = STATUS.SKIPPED;
205
+ break;
206
+ case 'inconclusive':
207
+ status = STATUS.SKIPPED; // Treat inconclusive as skipped
208
+ break;
209
+ default:
210
+ status = STATUS.PASSED;
211
+ }
212
+ }
213
+
214
+ // Extract error information
215
+ let message = '';
216
+ let stack = '';
217
+
218
+ const files = [];
219
+
220
+ // Extract attachments (NUnit format)
221
+ if (testCase.attachments) {
222
+ const attachments = Array.isArray(testCase.attachments.attachment)
223
+ ? testCase.attachments.attachment
224
+ : [testCase.attachments.attachment];
225
+
226
+ const attachmentFiles = attachments.filter(a => a && a.filePath).map(a => a.filePath);
227
+ files.push(...attachmentFiles);
228
+ }
229
+
230
+ if (testCase.failure) {
231
+ message = testCase.failure.message || '';
232
+ stack = testCase.failure['stack-trace'] || testCase.failure['#text'] || '';
233
+ }
234
+
235
+ if (testCase.output) {
236
+ const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
237
+ const stackFiles = fetchFilesFromStackTrace(outputText);
238
+ files.push(...stackFiles);
239
+
240
+ if (outputText) {
241
+ debug(`Found output in test case: ${outputText.substring(0, 100)}...`);
242
+ stack = `${stack}\n\n${outputText}`.trim();
243
+ } else {
244
+ debug('No output text found in test case');
245
+ }
246
+ } else {
247
+ debug('No output found in test case');
248
+ }
249
+
250
+ // Extract test ID and tags from properties
251
+ let testId = null;
252
+ let tags = [];
253
+ if (testCase.properties && testCase.properties.property) {
254
+ const properties = Array.isArray(testCase.properties.property)
255
+ ? testCase.properties.property
256
+ : [testCase.properties.property];
257
+
258
+ const idProperty = properties.find(p => p.name === 'ID');
259
+ if (idProperty) {
260
+ testId = idProperty.value;
261
+ // Remove @ and T prefixes if present
262
+ if (testId.startsWith('@')) testId = testId.slice(1);
263
+ if (testId.startsWith('T')) testId = testId.slice(1);
264
+ }
265
+
266
+ // Extract Category properties as tags
267
+ const categoryProperties = properties.filter(p => p.name === 'Category');
268
+ tags = categoryProperties.map(p => p.value);
269
+ }
270
+
271
+ // If no test ID found in properties, try to extract from output
272
+ if (!testId && testCase.output) {
273
+ const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
274
+ if (outputText) {
275
+ debug(`Looking for test ID in output: ${outputText.substring(0, 200)}...`);
276
+ const idMatch = outputText.match(/\[ID\]\s+tid:\/\/@T([a-f0-9]{8})/i);
277
+ if (idMatch) {
278
+ testId = idMatch[1];
279
+ debug(`Found test ID in output: ${testId}`);
280
+ } else {
281
+ debug('No test ID found in output');
282
+ }
283
+ }
284
+ }
285
+
286
+ // Build file path from suite path and class name
287
+ const filePath = this.buildFilePath(suitePath, className, parentSuite);
288
+
289
+ // For parameterized tests, format example as expected by Testomatio API
290
+ // Convert array of parameters to object with numeric keys
291
+ let example = null;
292
+ if (isParameterized && parameters.length > 0) {
293
+ example = {};
294
+ parameters.forEach((param, index) => {
295
+ example[index] = param;
296
+ });
297
+ }
298
+
299
+ return {
300
+ // For runs: use full test name with parameters (TestBooleanValue(true))
301
+ // For import: API will group by base name using the example field
302
+ title: testName, // Full name with parameters for run display
303
+ methodName: baseMethodName || methodName || testName,
304
+ fullName: fullName,
305
+ suitePath: suitePath,
306
+ suite_title: className || suitePath[suitePath.length - 1] || 'Unknown',
307
+ file: filePath,
308
+ files: files, // Array of files that will be attached
309
+ status: status,
310
+ message: message,
311
+ stack: stack,
312
+ run_time: parseFloat(testCase.duration || testCase.time || 0) * 1000,
313
+ test_id: testId,
314
+ tags: tags, // Array of category tags from properties
315
+ create: true,
316
+ retry: false,
317
+ // Parameterized test metadata
318
+ example: example, // Parameters as object for API grouping
319
+ isParameterized: isParameterized,
320
+ parameters: parameters, // Keep original array for reference
321
+ baseMethodName: baseMethodName,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Extract method name and parameters from test name
327
+ * @param {string} testName - Full test name
328
+ * @returns {Object} - Extracted information
329
+ */
330
+ extractParameters(testName) {
331
+ const paramMatch = testName.match(/^(.+?)\((.+)\)$/);
332
+
333
+ if (paramMatch) {
334
+ const baseMethodName = paramMatch[1].trim();
335
+ const paramString = paramMatch[2];
336
+
337
+ // Parse parameters - handle quoted strings and nested structures
338
+ const parameters = this.parseParameterString(paramString);
339
+
340
+ return {
341
+ baseMethodName: baseMethodName,
342
+ parameters: parameters,
343
+ isParameterized: true,
344
+ };
345
+ }
346
+
347
+ return {
348
+ baseMethodName: testName,
349
+ parameters: [],
350
+ isParameterized: false,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Parse parameter string into array of parameters
356
+ * @param {string} paramString - Parameter string
357
+ * @returns {Array} - Array of parameters
358
+ */
359
+ parseParameterString(paramString) {
360
+ const parameters = [];
361
+ let current = '';
362
+ let inQuotes = false;
363
+ let quoteChar = null;
364
+ let depth = 0;
365
+
366
+ for (let i = 0; i < paramString.length; i++) {
367
+ const char = paramString[i];
368
+
369
+ if (!inQuotes && (char === '"' || char === "'")) {
370
+ inQuotes = true;
371
+ quoteChar = char;
372
+ current += char;
373
+ } else if (inQuotes && char === quoteChar) {
374
+ inQuotes = false;
375
+ quoteChar = null;
376
+ current += char;
377
+ } else if (!inQuotes && char === '(') {
378
+ depth++;
379
+ current += char;
380
+ } else if (!inQuotes && char === ')') {
381
+ depth--;
382
+ current += char;
383
+ } else if (!inQuotes && char === ',' && depth === 0) {
384
+ parameters.push(current.trim());
385
+ current = '';
386
+ } else {
387
+ current += char;
388
+ }
389
+ }
390
+
391
+ if (current.trim()) {
392
+ parameters.push(current.trim());
393
+ }
394
+
395
+ // Clean up parameters - remove quotes if they wrap the entire parameter and filter empty ones
396
+ return parameters.map(param => {
397
+ param = param.trim();
398
+ if ((param.startsWith('"') && param.endsWith('"')) || (param.startsWith("'") && param.endsWith("'"))) {
399
+ return param.slice(1, -1);
400
+ }
401
+ return param;
402
+ }).filter(p => !!p);
403
+ }
404
+
405
+ /**
406
+ * Extract method name from test name (fallback)
407
+ * @param {string} testName - Test name
408
+ * @returns {string} - Method name
409
+ */
410
+ extractMethodName(testName) {
411
+ // Remove parameters if present
412
+ const paramMatch = testName.match(/^(.+?)\(/);
413
+ return paramMatch ? paramMatch[1].trim() : testName;
414
+ }
415
+
416
+ /**
417
+ * Build file path from suite path and class name
418
+ * @param {Array} suitePath - Suite path array
419
+ * @param {string} className - Class name
420
+ * @param {Object} parentSuite - Parent suite for context
421
+ * @returns {string} - File path
422
+ */
423
+ buildFilePath(suitePath, className, parentSuite) {
424
+ // Try to get file path from parent suite
425
+ if (parentSuite && parentSuite.filepath) {
426
+ return parentSuite.filepath;
427
+ }
428
+
429
+ // Build path from suite hierarchy
430
+ const pathParts = [...suitePath];
431
+ if (className && !pathParts.includes(className)) {
432
+ pathParts.push(className);
433
+ }
434
+
435
+ // Convert to file path format
436
+ return pathParts.join('/') + '.cs'; // Assume C# for NUnit
437
+ }
438
+
439
+ /**
440
+ * Group parameterized tests by base method name
441
+ * @param {Array} tests - Array of parsed tests
442
+ * @returns {Object} - Grouped tests
443
+ */
444
+ groupParameterizedTests(tests) {
445
+ const grouped = {};
446
+
447
+ tests.forEach(test => {
448
+ const key = test.isParameterized
449
+ ? `${test.suitePath.join('.')}.${test.baseMethodName}`
450
+ : `${test.suitePath.join('.')}.${test.title}`;
451
+
452
+ if (!grouped[key]) {
453
+ grouped[key] = {
454
+ baseTest: {
455
+ name: test.baseMethodName || test.title,
456
+ suitePath: test.suitePath,
457
+ suite_title: test.suite_title,
458
+ file: test.file,
459
+ isParameterized: test.isParameterized,
460
+ },
461
+ variations: [],
462
+ };
463
+ }
464
+
465
+ grouped[key].variations.push(test);
466
+ });
467
+
468
+ return grouped;
469
+ }
470
+ }
471
+
472
+ export default NUnitXmlParser;
@@ -163,7 +163,7 @@ class TestomatioPipe {
163
163
 
164
164
  /**
165
165
  * Creates a new run on Testomat.io
166
- * @param {{isBatchEnabled?: boolean}} params
166
+ * @param {{isBatchEnabled?: boolean, kind?: string}} params
167
167
  * @returns Promise<void>
168
168
  */
169
169
  async createRun(params = {}) {
@@ -204,6 +204,7 @@ class TestomatioPipe {
204
204
  label: this.label,
205
205
  shared_run: this.sharedRun,
206
206
  shared_run_timeout: this.sharedRunTimeout,
207
+ kind: params.kind,
207
208
  }).filter(([, value]) => !!value),
208
209
  );
209
210
  debug(' >>>>>> Run params', JSON.stringify(runParams, null, 2));
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);