@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/README.md +1 -1
- package/lib/bin/cli.js +1 -1
- package/lib/bin/reportXml.js +5 -2
- package/lib/bin/startTest.js +3 -3
- package/lib/client.js +7 -3
- package/lib/junit-adapter/csharp.d.ts +0 -1
- package/lib/junit-adapter/csharp.js +40 -7
- package/lib/junit-adapter/nunit-parser.d.ts +82 -0
- package/lib/junit-adapter/nunit-parser.js +369 -0
- package/lib/pipe/debug.js +1 -1
- package/lib/pipe/testomatio.js +19 -15
- package/lib/template/testomatio.hbs +1366 -1026
- package/lib/uploader.js +4 -0
- package/lib/utils/utils.js +90 -11
- package/lib/xmlReader.d.ts +32 -26
- package/lib/xmlReader.js +106 -50
- package/package.json +1 -1
- package/src/bin/cli.js +1 -1
- package/src/bin/reportXml.js +5 -2
- package/src/bin/startTest.js +5 -5
- package/src/client.js +7 -4
- package/src/junit-adapter/csharp.js +45 -6
- package/src/junit-adapter/nunit-parser.js +404 -0
- package/src/pipe/debug.js +2 -3
- package/src/pipe/testomatio.js +75 -81
- package/src/template/testomatio.hbs +1366 -1026
- package/src/uploader.js +5 -0
- package/src/utils/utils.js +96 -9
- package/src/xmlReader.js +128 -45
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);
|
package/src/utils/utils.js
CHANGED
|
@@ -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
|
-
//
|
|
87
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|