@testomatio/reporter 2.3.7 → 2.3.8-rc.1

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.
@@ -0,0 +1,462 @@
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
+ if (testCase.failure) {
221
+ message = testCase.failure.message || '';
222
+ stack = testCase.failure['stack-trace'] || testCase.failure['#text'] || '';
223
+ }
224
+
225
+ if (testCase.output) {
226
+ const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
227
+ const stackFiles = fetchFilesFromStackTrace(outputText);
228
+ files.push(...stackFiles);
229
+
230
+ if (outputText) {
231
+ debug(`Found output in test case: ${outputText.substring(0, 100)}...`);
232
+ stack = `${stack}\n\n${outputText}`.trim();
233
+ } else {
234
+ debug('No output text found in test case');
235
+ }
236
+ } else {
237
+ debug('No output found in test case');
238
+ }
239
+
240
+ // Extract test ID and tags from properties
241
+ let testId = null;
242
+ let tags = [];
243
+ if (testCase.properties && testCase.properties.property) {
244
+ const properties = Array.isArray(testCase.properties.property)
245
+ ? testCase.properties.property
246
+ : [testCase.properties.property];
247
+
248
+ const idProperty = properties.find(p => p.name === 'ID');
249
+ if (idProperty) {
250
+ testId = idProperty.value;
251
+ // Remove @ and T prefixes if present
252
+ if (testId.startsWith('@')) testId = testId.slice(1);
253
+ if (testId.startsWith('T')) testId = testId.slice(1);
254
+ }
255
+
256
+ // Extract Category properties as tags
257
+ const categoryProperties = properties.filter(p => p.name === 'Category');
258
+ tags = categoryProperties.map(p => p.value);
259
+ }
260
+
261
+ // If no test ID found in properties, try to extract from output
262
+ if (!testId && testCase.output) {
263
+ const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
264
+ if (outputText) {
265
+ debug(`Looking for test ID in output: ${outputText.substring(0, 200)}...`);
266
+ const idMatch = outputText.match(/\[ID\]\s+tid:\/\/@T([a-f0-9]{8})/i);
267
+ if (idMatch) {
268
+ testId = idMatch[1];
269
+ debug(`Found test ID in output: ${testId}`);
270
+ } else {
271
+ debug('No test ID found in output');
272
+ }
273
+ }
274
+ }
275
+
276
+ // Build file path from suite path and class name
277
+ const filePath = this.buildFilePath(suitePath, className, parentSuite);
278
+
279
+ // For parameterized tests, format example as expected by Testomatio API
280
+ // Convert array of parameters to object with numeric keys
281
+ let example = null;
282
+ if (isParameterized && parameters.length > 0) {
283
+ example = {};
284
+ parameters.forEach((param, index) => {
285
+ example[index] = param;
286
+ });
287
+ }
288
+
289
+ return {
290
+ // For runs: use full test name with parameters (TestBooleanValue(true))
291
+ // For import: API will group by base name using the example field
292
+ title: testName, // Full name with parameters for run display
293
+ methodName: baseMethodName || methodName || testName,
294
+ fullName: fullName,
295
+ suitePath: suitePath,
296
+ suite_title: className || suitePath[suitePath.length - 1] || 'Unknown',
297
+ file: filePath,
298
+ files: files, // Array of files that will be attached
299
+ status: status,
300
+ message: message,
301
+ stack: stack,
302
+ run_time: parseFloat(testCase.duration || testCase.time || 0) * 1000,
303
+ test_id: testId,
304
+ tags: tags, // Array of category tags from properties
305
+ create: true,
306
+ retry: false,
307
+ // Parameterized test metadata
308
+ example: example, // Parameters as object for API grouping
309
+ isParameterized: isParameterized,
310
+ parameters: parameters, // Keep original array for reference
311
+ baseMethodName: baseMethodName,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Extract method name and parameters from test name
317
+ * @param {string} testName - Full test name
318
+ * @returns {Object} - Extracted information
319
+ */
320
+ extractParameters(testName) {
321
+ const paramMatch = testName.match(/^(.+?)\((.+)\)$/);
322
+
323
+ if (paramMatch) {
324
+ const baseMethodName = paramMatch[1].trim();
325
+ const paramString = paramMatch[2];
326
+
327
+ // Parse parameters - handle quoted strings and nested structures
328
+ const parameters = this.parseParameterString(paramString);
329
+
330
+ return {
331
+ baseMethodName: baseMethodName,
332
+ parameters: parameters,
333
+ isParameterized: true,
334
+ };
335
+ }
336
+
337
+ return {
338
+ baseMethodName: testName,
339
+ parameters: [],
340
+ isParameterized: false,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Parse parameter string into array of parameters
346
+ * @param {string} paramString - Parameter string
347
+ * @returns {Array} - Array of parameters
348
+ */
349
+ parseParameterString(paramString) {
350
+ const parameters = [];
351
+ let current = '';
352
+ let inQuotes = false;
353
+ let quoteChar = null;
354
+ let depth = 0;
355
+
356
+ for (let i = 0; i < paramString.length; i++) {
357
+ const char = paramString[i];
358
+
359
+ if (!inQuotes && (char === '"' || char === "'")) {
360
+ inQuotes = true;
361
+ quoteChar = char;
362
+ current += char;
363
+ } else if (inQuotes && char === quoteChar) {
364
+ inQuotes = false;
365
+ quoteChar = null;
366
+ current += char;
367
+ } else if (!inQuotes && char === '(') {
368
+ depth++;
369
+ current += char;
370
+ } else if (!inQuotes && char === ')') {
371
+ depth--;
372
+ current += char;
373
+ } else if (!inQuotes && char === ',' && depth === 0) {
374
+ parameters.push(current.trim());
375
+ current = '';
376
+ } else {
377
+ current += char;
378
+ }
379
+ }
380
+
381
+ if (current.trim()) {
382
+ parameters.push(current.trim());
383
+ }
384
+
385
+ // Clean up parameters - remove quotes if they wrap the entire parameter
386
+ return parameters.map(param => {
387
+ param = param.trim();
388
+ if ((param.startsWith('"') && param.endsWith('"')) || (param.startsWith("'") && param.endsWith("'"))) {
389
+ return param.slice(1, -1);
390
+ }
391
+ return param;
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Extract method name from test name (fallback)
397
+ * @param {string} testName - Test name
398
+ * @returns {string} - Method name
399
+ */
400
+ extractMethodName(testName) {
401
+ // Remove parameters if present
402
+ const paramMatch = testName.match(/^(.+?)\(/);
403
+ return paramMatch ? paramMatch[1].trim() : testName;
404
+ }
405
+
406
+ /**
407
+ * Build file path from suite path and class name
408
+ * @param {Array} suitePath - Suite path array
409
+ * @param {string} className - Class name
410
+ * @param {Object} parentSuite - Parent suite for context
411
+ * @returns {string} - File path
412
+ */
413
+ buildFilePath(suitePath, className, parentSuite) {
414
+ // Try to get file path from parent suite
415
+ if (parentSuite && parentSuite.filepath) {
416
+ return parentSuite.filepath;
417
+ }
418
+
419
+ // Build path from suite hierarchy
420
+ const pathParts = [...suitePath];
421
+ if (className && !pathParts.includes(className)) {
422
+ pathParts.push(className);
423
+ }
424
+
425
+ // Convert to file path format
426
+ return pathParts.join('/') + '.cs'; // Assume C# for NUnit
427
+ }
428
+
429
+ /**
430
+ * Group parameterized tests by base method name
431
+ * @param {Array} tests - Array of parsed tests
432
+ * @returns {Object} - Grouped tests
433
+ */
434
+ groupParameterizedTests(tests) {
435
+ const grouped = {};
436
+
437
+ tests.forEach(test => {
438
+ const key = test.isParameterized
439
+ ? `${test.suitePath.join('.')}.${test.baseMethodName}`
440
+ : `${test.suitePath.join('.')}.${test.title}`;
441
+
442
+ if (!grouped[key]) {
443
+ grouped[key] = {
444
+ baseTest: {
445
+ name: test.baseMethodName || test.title,
446
+ suitePath: test.suitePath,
447
+ suite_title: test.suite_title,
448
+ file: test.file,
449
+ isParameterized: test.isParameterized,
450
+ },
451
+ variations: [],
452
+ };
453
+ }
454
+
455
+ grouped[key].variations.push(test);
456
+ });
457
+
458
+ return grouped;
459
+ }
460
+ }
461
+
462
+ export default NUnitXmlParser;
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);