@testomatio/reporter 2.3.9-beta-bin-fix → 2.4.0
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 +3 -2
- package/lib/adapter/codecept.js +12 -9
- package/lib/bin/cli.js +40 -11
- package/lib/bin/reportXml.js +5 -2
- package/lib/client.d.ts +1 -11
- package/lib/client.js +57 -152
- package/lib/data-storage.d.ts +1 -1
- package/lib/helpers.d.ts +1 -0
- package/lib/helpers.js +4 -0
- package/lib/junit-adapter/csharp.d.ts +0 -1
- package/lib/junit-adapter/csharp.js +43 -7
- package/lib/junit-adapter/nunit-parser.d.ts +82 -0
- package/lib/junit-adapter/nunit-parser.js +433 -0
- package/lib/pipe/bitbucket.js +5 -5
- package/lib/pipe/coverage.d.ts +82 -0
- package/lib/pipe/coverage.js +373 -0
- package/lib/pipe/gitlab.js +4 -4
- package/lib/pipe/index.js +2 -0
- package/lib/pipe/testomatio.d.ts +3 -2
- package/lib/pipe/testomatio.js +44 -18
- package/lib/reporter-functions.js +14 -12
- package/lib/reporter.d.ts +31 -21
- package/lib/reporter.js +40 -5
- package/lib/services/artifacts.d.ts +1 -1
- package/lib/services/key-values.d.ts +1 -1
- package/lib/services/links.d.ts +1 -1
- package/lib/services/logger.d.ts +1 -1
- package/lib/uploader.js +4 -0
- package/lib/utils/log-formatter.d.ts +28 -0
- package/lib/utils/log-formatter.js +127 -0
- package/lib/utils/pipe_utils.d.ts +15 -0
- package/lib/utils/pipe_utils.js +44 -2
- package/lib/utils/utils.d.ts +6 -0
- package/lib/utils/utils.js +260 -25
- package/lib/xmlReader.d.ts +32 -26
- package/lib/xmlReader.js +121 -52
- package/package.json +12 -7
- package/src/adapter/codecept.js +19 -19
- package/src/adapter/mocha.js +1 -1
- package/src/adapter/playwright.js +2 -2
- package/src/bin/cli.js +51 -13
- package/src/bin/reportXml.js +5 -2
- package/src/client.js +69 -130
- package/src/helpers.js +1 -0
- package/src/junit-adapter/csharp.js +48 -6
- package/src/junit-adapter/nunit-parser.js +474 -0
- package/src/pipe/bitbucket.js +5 -5
- package/src/pipe/coverage.js +440 -0
- package/src/pipe/debug.js +1 -2
- package/src/pipe/gitlab.js +4 -4
- package/src/pipe/index.js +2 -0
- package/src/pipe/testomatio.js +109 -85
- package/src/reporter-functions.js +15 -12
- package/src/reporter.js +6 -4
- package/src/services/links.js +1 -1
- package/src/uploader.js +5 -0
- package/src/utils/log-formatter.js +113 -0
- package/src/utils/pipe_utils.js +52 -3
- package/src/utils/utils.js +277 -22
- package/src/xmlReader.js +144 -46
- package/types/types.d.ts +364 -0
- package/types/vitest.types.d.ts +93 -0
package/src/utils/utils.js
CHANGED
|
@@ -6,6 +6,7 @@ import isValid from 'is-valid-path';
|
|
|
6
6
|
import createDebugMessages from 'debug';
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
9
10
|
|
|
10
11
|
const debug = createDebugMessages('@testomatio/reporter:util');
|
|
11
12
|
|
|
@@ -58,6 +59,21 @@ const validateSuiteId = suiteId => {
|
|
|
58
59
|
return match ? match[0] : null;
|
|
59
60
|
};
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Gets current git commit SHA
|
|
64
|
+
* @returns {String|null} git commit SHA or null if not available
|
|
65
|
+
*/
|
|
66
|
+
const getGitCommitSha = () => {
|
|
67
|
+
try {
|
|
68
|
+
const sha = execSync('git rev-parse --short HEAD', {
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
70
|
+
}).toString().trim();
|
|
71
|
+
return sha || null;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
61
77
|
const ansiRegExp = () => {
|
|
62
78
|
const pattern = [
|
|
63
79
|
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
|
|
@@ -76,21 +92,29 @@ const isValidUrl = s => {
|
|
|
76
92
|
}
|
|
77
93
|
};
|
|
78
94
|
|
|
79
|
-
const fileMatchRegex = /file:(
|
|
95
|
+
const fileMatchRegex = /file:(\/*)([A-Za-z]:[\\/].*?|\/.*?)\.(png|avi|webm|jpg|html|txt)/gi;
|
|
80
96
|
|
|
81
97
|
const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
|
|
82
|
-
|
|
83
|
-
.map(
|
|
98
|
+
let files = Array.from(stack.matchAll(fileMatchRegex))
|
|
99
|
+
.map(match => {
|
|
100
|
+
// match[0] is full match, match[1] is slashes, match[2] is path, match[3] is extension
|
|
101
|
+
const slashes = match[1] || '';
|
|
102
|
+
const path = match[2];
|
|
103
|
+
const extension = match[3];
|
|
104
|
+
return `${slashes}${path}.${extension}`;
|
|
105
|
+
})
|
|
106
|
+
.map(f => f.trim())
|
|
84
107
|
.map(f => f.replace(/^\/+/, '/').replace(/^\/([A-Za-z]:)/, '$1')) // Remove extra slashes, handle Windows paths
|
|
85
108
|
.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;
|
|
109
|
+
// Normalize path separators for cross-platform compatibility
|
|
110
|
+
return f.replace(/\\/g, '/');
|
|
92
111
|
});
|
|
93
112
|
|
|
113
|
+
// If we're not checking file existence, remove Windows drive letters for consistency
|
|
114
|
+
if (!checkExists) {
|
|
115
|
+
files = files.map(f => f.replace(/^([A-Za-z]):/, ''));
|
|
116
|
+
}
|
|
117
|
+
|
|
94
118
|
debug('Found files in stack trace: ', files);
|
|
95
119
|
|
|
96
120
|
return files.filter(f => {
|
|
@@ -105,21 +129,92 @@ const fetchSourceCodeFromStackTrace = (stack = '') => {
|
|
|
105
129
|
const stackLines = stack
|
|
106
130
|
.split('\n')
|
|
107
131
|
.filter(l => l.includes(':'))
|
|
108
|
-
// .map(l => l.match(/\[(.*?)\]/)?.[1] || l) // minitest format
|
|
109
|
-
// .map(l => l.split(':')[0])
|
|
110
132
|
.map(l => l.trim())
|
|
111
|
-
.map(l =>
|
|
112
|
-
|
|
133
|
+
.map(l => {
|
|
134
|
+
// Remove 'at ' prefix if present
|
|
135
|
+
if (l.startsWith('at ')) {
|
|
136
|
+
return l.substring(3).trim();
|
|
137
|
+
}
|
|
138
|
+
// Find the part that looks like a file path with line number
|
|
139
|
+
const parts = l.split(' ');
|
|
140
|
+
for (const part of parts) {
|
|
141
|
+
// Check if this part has a colon
|
|
142
|
+
if (part.includes(':')) {
|
|
143
|
+
// For Windows paths, we need to handle drive letters (C:, D:, etc.)
|
|
144
|
+
// Split by colon but keep drive letter with the path
|
|
145
|
+
const colonParts = part.split(':');
|
|
146
|
+
let filePath;
|
|
147
|
+
|
|
148
|
+
// Check if first part is a Windows drive letter (single letter)
|
|
149
|
+
if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
|
|
150
|
+
// Windows path like D:\path\file.php:24
|
|
151
|
+
// Reconstruct as D:\path\file.php
|
|
152
|
+
filePath = colonParts[0] + ':' + colonParts[1];
|
|
153
|
+
} else {
|
|
154
|
+
// Unix path like /path/file.php:24
|
|
155
|
+
filePath = colonParts[0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Only consider it valid if the file exists
|
|
159
|
+
if (fs.existsSync(filePath)) {
|
|
160
|
+
return part;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// If no valid file path found in parts, return the whole line
|
|
165
|
+
// It will be filtered out later if it's not a valid file path
|
|
166
|
+
return parts.find(p => p.includes(':')) || l;
|
|
167
|
+
})
|
|
168
|
+
.filter(l => {
|
|
169
|
+
// Extract file path from line (accounting for Windows drive letters)
|
|
170
|
+
if (!l) return false;
|
|
171
|
+
const colonParts = l.split(':');
|
|
172
|
+
let filePath;
|
|
173
|
+
|
|
174
|
+
if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
|
|
175
|
+
// Windows path
|
|
176
|
+
filePath = colonParts[0] + ':' + colonParts[1];
|
|
177
|
+
} else {
|
|
178
|
+
// Unix path
|
|
179
|
+
filePath = colonParts[0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return filePath && fs.existsSync(filePath);
|
|
183
|
+
})
|
|
113
184
|
|
|
114
185
|
// // filter out 3rd party libs
|
|
115
186
|
.filter(l => !l?.includes(`vendor${sep}`))
|
|
116
187
|
.filter(l => !l?.includes(`node_modules${sep}`))
|
|
117
|
-
.filter(l =>
|
|
118
|
-
|
|
188
|
+
.filter(l => {
|
|
189
|
+
// Extract file path for final check (accounting for Windows drive letters)
|
|
190
|
+
const colonParts = l.split(':');
|
|
191
|
+
let filePath;
|
|
192
|
+
|
|
193
|
+
if (colonParts.length >= 2 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
|
|
194
|
+
filePath = colonParts[0] + ':' + colonParts[1];
|
|
195
|
+
} else {
|
|
196
|
+
filePath = colonParts[0];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return fs.lstatSync(filePath).isFile();
|
|
200
|
+
});
|
|
119
201
|
|
|
120
202
|
if (!stackLines.length) return '';
|
|
121
203
|
|
|
122
|
-
|
|
204
|
+
// Extract file and line number (accounting for Windows drive letters)
|
|
205
|
+
const firstLine = stackLines[0];
|
|
206
|
+
const colonParts = firstLine.split(':');
|
|
207
|
+
let file, line;
|
|
208
|
+
|
|
209
|
+
if (colonParts.length >= 3 && colonParts[0].length === 1 && /[A-Za-z]/.test(colonParts[0])) {
|
|
210
|
+
// Windows path like D:\path\file.php:24
|
|
211
|
+
file = colonParts[0] + ':' + colonParts[1];
|
|
212
|
+
line = colonParts[2];
|
|
213
|
+
} else {
|
|
214
|
+
// Unix path like /path/file.php:24
|
|
215
|
+
file = colonParts[0];
|
|
216
|
+
line = colonParts[1];
|
|
217
|
+
}
|
|
123
218
|
|
|
124
219
|
const prepend = 3;
|
|
125
220
|
const source = fetchSourceCode(fs.readFileSync(file).toString(), { line, prepend, limit: 7 });
|
|
@@ -139,6 +234,8 @@ export const TEST_ID_REGEX = /@T([\w\d]{8})/;
|
|
|
139
234
|
export const SUITE_ID_REGEX = /@S([\w\d]{8})/;
|
|
140
235
|
|
|
141
236
|
const fetchIdFromCode = (code, opts = {}) => {
|
|
237
|
+
if (!code) return null;
|
|
238
|
+
|
|
142
239
|
const comments = code
|
|
143
240
|
.split('\n')
|
|
144
241
|
.map(l => l.trim())
|
|
@@ -180,8 +277,65 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
180
277
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`public void ${title}`));
|
|
181
278
|
if (lineIndex === -1) lineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
182
279
|
} else if (opts.lang === 'csharp') {
|
|
183
|
-
|
|
184
|
-
|
|
280
|
+
// Find the method declaration line
|
|
281
|
+
let methodLineIndex = lines.findIndex(l => l.includes(`public void ${title}(`));
|
|
282
|
+
|
|
283
|
+
if (methodLineIndex === -1) {
|
|
284
|
+
methodLineIndex = lines.findIndex(l => l.includes(`public async Task ${title}(`));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (methodLineIndex === -1) {
|
|
288
|
+
methodLineIndex = lines.findIndex(l => l.includes(`${title}(`));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If found, scan upwards to find [TestCase], [Test] attributes and XML comments
|
|
292
|
+
if (methodLineIndex !== -1) {
|
|
293
|
+
lineIndex = methodLineIndex;
|
|
294
|
+
|
|
295
|
+
// Scan upwards to find the start of attributes and comments
|
|
296
|
+
for (let i = methodLineIndex - 1; i >= 0; i--) {
|
|
297
|
+
const trimmedLine = lines[i].trim();
|
|
298
|
+
|
|
299
|
+
// Include [TestCase], [Test], and other attributes
|
|
300
|
+
if (trimmedLine.startsWith('[')) {
|
|
301
|
+
lineIndex = i;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Include XML documentation comments
|
|
306
|
+
if (trimmedLine.startsWith('///')) {
|
|
307
|
+
lineIndex = i;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Stop at empty lines (with some tolerance)
|
|
312
|
+
if (trimmedLine === '') {
|
|
313
|
+
// Check if next non-empty line is an attribute or comment
|
|
314
|
+
let hasMoreAttributes = false;
|
|
315
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
316
|
+
const nextTrimmed = lines[j].trim();
|
|
317
|
+
if (nextTrimmed === '') continue;
|
|
318
|
+
if (nextTrimmed.startsWith('[') || nextTrimmed.startsWith('///')) {
|
|
319
|
+
hasMoreAttributes = true;
|
|
320
|
+
lineIndex = j;
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
if (!hasMoreAttributes) break;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Stop at other method declarations or class-level elements
|
|
329
|
+
if (
|
|
330
|
+
trimmedLine.includes('public ') ||
|
|
331
|
+
trimmedLine.includes('private ') ||
|
|
332
|
+
trimmedLine.includes('protected ') ||
|
|
333
|
+
trimmedLine.includes('internal ')
|
|
334
|
+
) {
|
|
335
|
+
if (!trimmedLine.startsWith('[')) break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
185
339
|
} else {
|
|
186
340
|
lineIndex = lines.findIndex(l => l.includes(title));
|
|
187
341
|
}
|
|
@@ -191,11 +345,31 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
191
345
|
lineIndex -= opts.prepend;
|
|
192
346
|
}
|
|
193
347
|
|
|
194
|
-
if (lineIndex) {
|
|
348
|
+
if (lineIndex !== -1 && lineIndex !== undefined) {
|
|
195
349
|
const result = [];
|
|
350
|
+
let braceDepth = 0; // Track brace depth for C# methods
|
|
351
|
+
let methodStartFound = false; // Flag to indicate we've found the method opening brace
|
|
352
|
+
|
|
196
353
|
for (let i = lineIndex; i < lineIndex + limit; i++) {
|
|
197
354
|
if (lines[i] === undefined) continue;
|
|
198
355
|
|
|
356
|
+
// Track brace depth for C# to stop after method closes
|
|
357
|
+
if (opts.lang === 'csharp') {
|
|
358
|
+
const line = lines[i];
|
|
359
|
+
// Count opening and closing braces
|
|
360
|
+
const openBraces = (line.match(/\{/g) || []).length;
|
|
361
|
+
const closeBraces = (line.match(/\}/g) || []).length;
|
|
362
|
+
|
|
363
|
+
if (openBraces > 0) methodStartFound = true;
|
|
364
|
+
braceDepth += openBraces - closeBraces;
|
|
365
|
+
|
|
366
|
+
// If we've started the method and depth returns to 0, method is complete
|
|
367
|
+
if (methodStartFound && braceDepth === 0 && closeBraces > 0) {
|
|
368
|
+
// Don't include the closing brace - just break
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
199
373
|
if (i > lineIndex + 2 && !opts.prepend) {
|
|
200
374
|
// annotation
|
|
201
375
|
if (opts.lang === 'php' && lines[i].trim().startsWith('#[')) break;
|
|
@@ -216,7 +390,25 @@ const fetchSourceCode = (contents, opts = {}) => {
|
|
|
216
390
|
if (opts.lang === 'java' && lines[i].trim().match(/^@\w+/)) break;
|
|
217
391
|
if (opts.lang === 'java' && lines[i].includes(' public void ')) break;
|
|
218
392
|
if (opts.lang === 'java' && lines[i].includes(' class ')) break;
|
|
393
|
+
// For C#, additional checks if brace tracking didn't stop us
|
|
394
|
+
if (opts.lang === 'csharp') {
|
|
395
|
+
const trimmed = lines[i].trim();
|
|
396
|
+
// Stop at attribute that marks beginning of next test (but not if we're still in the current method)
|
|
397
|
+
if (trimmed.match(/^\[(Test|TestCase|Theory|Fact)/) && methodStartFound && braceDepth === 0) break;
|
|
398
|
+
// Stop at XML documentation comments that belong to next method
|
|
399
|
+
if (trimmed.startsWith('///') && methodStartFound && braceDepth === 0) break;
|
|
400
|
+
// Stop at another method declaration (but not if we're still in the current method)
|
|
401
|
+
if (
|
|
402
|
+
trimmed.match(/^\s*(public|private|protected|internal)\s+(\w+|async\s+\w+)\s+\w+\s*\(/) &&
|
|
403
|
+
methodStartFound &&
|
|
404
|
+
braceDepth === 0
|
|
405
|
+
)
|
|
406
|
+
break;
|
|
407
|
+
// Stop at class declaration
|
|
408
|
+
if (trimmed.includes(' class ') && trimmed.includes('public')) break;
|
|
409
|
+
}
|
|
219
410
|
}
|
|
411
|
+
|
|
220
412
|
result.push(lines[i]);
|
|
221
413
|
}
|
|
222
414
|
return result.join('\n');
|
|
@@ -429,10 +621,71 @@ function transformEnvVarToBoolean(value) {
|
|
|
429
621
|
}
|
|
430
622
|
|
|
431
623
|
function truncate(s, size = 255) {
|
|
432
|
-
if (s
|
|
433
|
-
return
|
|
624
|
+
if (s === undefined || s === null) {
|
|
625
|
+
return '';
|
|
626
|
+
}
|
|
627
|
+
const str = s.toString();
|
|
628
|
+
if (str.trim().length < size) {
|
|
629
|
+
return str;
|
|
630
|
+
}
|
|
631
|
+
return `${str.substring(0, size)}...`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function applyFilter(command, tests) {
|
|
635
|
+
if (!tests || !tests.length) return command;
|
|
636
|
+
|
|
637
|
+
const lower = (command || '').toLowerCase();
|
|
638
|
+
const regexPattern = `(${tests.join('|')})`;
|
|
639
|
+
|
|
640
|
+
if (lower.includes('jest')) {
|
|
641
|
+
return `${command} --testNamePattern ${regexPattern}`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (lower.includes('cypress')) {
|
|
645
|
+
const grepValue = tests.join(',');
|
|
646
|
+
const baseEnv = {
|
|
647
|
+
grep: grepValue,
|
|
648
|
+
grepFilterSpecs: true,
|
|
649
|
+
grepOmitFiltered: true,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
if (command.includes('--env')) {
|
|
653
|
+
return command.replace(
|
|
654
|
+
/--env\s+(['"]?)([^\s'"]+)\1/,
|
|
655
|
+
(match, quote, envVal) => {
|
|
656
|
+
const existingEnv = {};
|
|
657
|
+
|
|
658
|
+
if (envVal.startsWith('{') && envVal.endsWith('}')) {
|
|
659
|
+
try {
|
|
660
|
+
Object.assign(existingEnv, JSON.parse(envVal));
|
|
661
|
+
} catch (e) {
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!Object.keys(existingEnv).length) {
|
|
666
|
+
envVal.split(',').forEach((pair) => {
|
|
667
|
+
const [k, v] = pair.split('=');
|
|
668
|
+
if (!k) return;
|
|
669
|
+
|
|
670
|
+
if (v === 'true') existingEnv[k] = true;
|
|
671
|
+
else if (v === 'false') existingEnv[k] = false;
|
|
672
|
+
else existingEnv[k] = v;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const merged = { ...existingEnv, ...baseEnv };
|
|
677
|
+
const json = JSON.stringify(merged);
|
|
678
|
+
|
|
679
|
+
return `--env ${json}`;
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const json = JSON.stringify(baseEnv);
|
|
685
|
+
return `${command} --env ${json}`;
|
|
434
686
|
}
|
|
435
|
-
|
|
687
|
+
|
|
688
|
+
return `${command} --grep ${regexPattern}`;
|
|
436
689
|
}
|
|
437
690
|
|
|
438
691
|
export {
|
|
@@ -449,6 +702,7 @@ export {
|
|
|
449
702
|
foundedTestLog,
|
|
450
703
|
formatStep,
|
|
451
704
|
getCurrentDateTime,
|
|
705
|
+
getGitCommitSha,
|
|
452
706
|
getTestomatIdFromTestTitle,
|
|
453
707
|
humanize,
|
|
454
708
|
isValidUrl,
|
|
@@ -460,4 +714,5 @@ export {
|
|
|
460
714
|
testRunnerHelper,
|
|
461
715
|
transformEnvVarToBoolean,
|
|
462
716
|
validateSuiteId,
|
|
717
|
+
applyFilter
|
|
463
718
|
};
|
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,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
fetchIdFromCode,
|
|
15
16
|
humanize,
|
|
16
17
|
TEST_ID_REGEX,
|
|
18
|
+
transformEnvVarToBoolean,
|
|
17
19
|
} from './utils/utils.js';
|
|
18
20
|
import { pipesFactory } from './pipe/index.js';
|
|
19
21
|
import adapterFactory from './junit-adapter/index.js';
|
|
@@ -35,6 +37,7 @@ const {
|
|
|
35
37
|
TESTOMATIO_ENV,
|
|
36
38
|
TESTOMATIO_RUN,
|
|
37
39
|
TESTOMATIO_MARK_DETACHED,
|
|
40
|
+
TESTOMATIO_LEGACY_NUNIT,
|
|
38
41
|
} = process.env;
|
|
39
42
|
|
|
40
43
|
const options = {
|
|
@@ -75,6 +78,11 @@ class XmlReader {
|
|
|
75
78
|
this.stats.language = opts.lang?.toLowerCase();
|
|
76
79
|
this.uploader = new S3Uploader();
|
|
77
80
|
|
|
81
|
+
// Enhanced NUnit parsing - enabled by default for NUnit XML
|
|
82
|
+
// Can be disabled via opts.enhancedNunit = false or TESTOMATIO_LEGACY_NUNIT=1
|
|
83
|
+
this.enhancedNunit = !transformEnvVarToBoolean(TESTOMATIO_LEGACY_NUNIT);
|
|
84
|
+
this.groupParameterized = opts.groupParameterized !== false; // Default true, can be disabled
|
|
85
|
+
|
|
78
86
|
// @ts-ignore
|
|
79
87
|
const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
80
88
|
this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version;
|
|
@@ -126,7 +134,8 @@ class XmlReader {
|
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
processJUnit(jsonSuite) {
|
|
129
|
-
const { testsuite, name,
|
|
137
|
+
const { testsuite, name, failures, errors } = jsonSuite;
|
|
138
|
+
const tests = testsuite?.tests || jsonSuite.tests;
|
|
130
139
|
|
|
131
140
|
reduceOptions.preferClassname = this.stats.language === 'python';
|
|
132
141
|
const resultTests = processTestSuite(testsuite);
|
|
@@ -157,6 +166,14 @@ class XmlReader {
|
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
processNUnit(jsonSuite) {
|
|
169
|
+
// Use enhanced NUnit parser if enabled and this is actually NUnit XML
|
|
170
|
+
if (this.enhancedNunit && this.isNUnitXml(jsonSuite)) {
|
|
171
|
+
debug('Using enhanced NUnit parser');
|
|
172
|
+
return this.processNUnitEnhanced(jsonSuite);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fallback to legacy parser for backward compatibility
|
|
176
|
+
debug('Using legacy NUnit parser');
|
|
160
177
|
const { result, total, passed, failed, inconclusive, skipped } = jsonSuite;
|
|
161
178
|
|
|
162
179
|
reduceOptions.preferClassname = this.stats.language === 'python';
|
|
@@ -175,63 +192,71 @@ class XmlReader {
|
|
|
175
192
|
};
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Check if the XML is actually NUnit format (has test-suite hierarchy)
|
|
197
|
+
* @param {Object} jsonSuite - Parsed XML suite object
|
|
198
|
+
* @returns {boolean} - True if this is NUnit XML format
|
|
199
|
+
*/
|
|
200
|
+
isNUnitXml(jsonSuite) {
|
|
201
|
+
// NUnit XML has test-suite elements with type attributes
|
|
202
|
+
if (jsonSuite['test-suite']) {
|
|
203
|
+
const testSuite = Array.isArray(jsonSuite['test-suite']) ? jsonSuite['test-suite'][0] : jsonSuite['test-suite'];
|
|
204
|
+
|
|
205
|
+
// Check for NUnit-specific test-suite types
|
|
206
|
+
return (
|
|
207
|
+
testSuite &&
|
|
208
|
+
testSuite.type &&
|
|
209
|
+
['Assembly', 'TestSuite', 'TestFixture', 'ParameterizedMethod'].includes(testSuite.type)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
processNUnitEnhanced(jsonSuite) {
|
|
216
|
+
debug('Processing NUnit XML with enhanced parser');
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const nunitParser = new NUnitXmlParser({
|
|
220
|
+
groupParameterized: this.groupParameterized,
|
|
221
|
+
...this.opts,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = nunitParser.parseTestRun(jsonSuite);
|
|
225
|
+
|
|
226
|
+
// Add parsed tests to our collection
|
|
227
|
+
this.tests = this.tests.concat(result.tests);
|
|
228
|
+
|
|
229
|
+
debug(`Enhanced NUnit parser processed ${result.tests.length} tests`);
|
|
230
|
+
|
|
231
|
+
return result;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
debug('Enhanced NUnit parser failed, falling back to legacy parser:', error.message);
|
|
234
|
+
console.warn(`${APP_PREFIX} Enhanced NUnit parsing failed, using legacy parser: ${error.message}`);
|
|
235
|
+
|
|
236
|
+
// Fallback to legacy parser
|
|
237
|
+
this.enhancedNunit = false;
|
|
238
|
+
return this.processNUnit(jsonSuite);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
178
242
|
processTRX(jsonSuite) {
|
|
179
243
|
let defs = jsonSuite?.TestRun?.TestDefinitions?.UnitTest;
|
|
180
244
|
if (!Array.isArray(defs)) defs = [defs].filter(d => !!d);
|
|
181
245
|
|
|
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
|
-
}) || [];
|
|
246
|
+
// Parse test definitions
|
|
247
|
+
const tests = defs.map(td => this._parseTRXTestDefinition(td));
|
|
198
248
|
|
|
249
|
+
// Parse test results
|
|
199
250
|
let result = jsonSuite?.TestRun?.Results?.UnitTestResult;
|
|
200
251
|
if (!Array.isArray(result)) result = [result].filter(d => !!d);
|
|
201
252
|
|
|
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
|
-
});
|
|
253
|
+
const results = result.map(td => this._parseTRXTestResult(td, tests));
|
|
226
254
|
|
|
227
255
|
debug(results);
|
|
228
256
|
|
|
229
257
|
const counters = jsonSuite?.TestRun?.ResultSummary?.Counters || {};
|
|
230
|
-
|
|
231
258
|
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;
|
|
259
|
+
const status = failed_count > 0 ? STATUS.FAILED : STATUS.PASSED.toString();
|
|
235
260
|
|
|
236
261
|
this.tests = results.filter(t => !!t.title);
|
|
237
262
|
|
|
@@ -246,6 +271,74 @@ class XmlReader {
|
|
|
246
271
|
};
|
|
247
272
|
}
|
|
248
273
|
|
|
274
|
+
_parseTRXTestDefinition(td) {
|
|
275
|
+
const title = td.name.replace(/\(.*?\)/, '').trim();
|
|
276
|
+
const exampleMatch = td.name.match(/\((.*?)\)/);
|
|
277
|
+
const example = exampleMatch
|
|
278
|
+
? {
|
|
279
|
+
...exampleMatch[1]
|
|
280
|
+
.split(',')
|
|
281
|
+
.map(p => p.trim())
|
|
282
|
+
.filter(p => p !== ''),
|
|
283
|
+
}
|
|
284
|
+
: null;
|
|
285
|
+
|
|
286
|
+
const suite = td.TestMethod.className.split(', ')[0].split('.');
|
|
287
|
+
const suite_title = suite.pop();
|
|
288
|
+
|
|
289
|
+
// Convert namespace to file path for C#
|
|
290
|
+
const file = `${suite.join('/')}.cs`;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
title, // Base name without parameters for test import
|
|
294
|
+
example, // Parameters object for parameterized tests
|
|
295
|
+
file, // File path with .cs extension
|
|
296
|
+
description: td.Description,
|
|
297
|
+
suite_title,
|
|
298
|
+
id: td.Execution.id,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_parseTRXTestResult(td, tests) {
|
|
303
|
+
const test = tests.find(t => t.id === td.executionId) || {};
|
|
304
|
+
|
|
305
|
+
const result = {
|
|
306
|
+
suite_title: test.suite_title,
|
|
307
|
+
title: test.title?.trim(),
|
|
308
|
+
file: test.file,
|
|
309
|
+
description: test.description,
|
|
310
|
+
code: test.code,
|
|
311
|
+
run_time: parseFloat(td.duration) * 1000,
|
|
312
|
+
stack: td.Output?.StdOut || '',
|
|
313
|
+
files: td?.ResultFiles?.ResultFile?.map(rf => rf.path),
|
|
314
|
+
create: true,
|
|
315
|
+
overwrite: true,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Add example for parameterized tests
|
|
319
|
+
if (test.example) {
|
|
320
|
+
result.example = test.example;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Map TRX status to Testomat.io status
|
|
324
|
+
result.status = this._mapTRXStatus(td.outcome);
|
|
325
|
+
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_mapTRXStatus(outcome) {
|
|
330
|
+
switch (outcome) {
|
|
331
|
+
case 'Passed':
|
|
332
|
+
return STATUS.PASSED;
|
|
333
|
+
case 'Failed':
|
|
334
|
+
return STATUS.FAILED;
|
|
335
|
+
case 'Skipped':
|
|
336
|
+
return STATUS.SKIPPED;
|
|
337
|
+
default:
|
|
338
|
+
return STATUS.PASSED;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
249
342
|
processXUnit(assemblies) {
|
|
250
343
|
const tests = [];
|
|
251
344
|
|
|
@@ -512,7 +605,12 @@ function reduceTestCases(prev, item) {
|
|
|
512
605
|
|
|
513
606
|
const exampleMatches = testCaseItem.name?.match(/\S\((.*?)\)/);
|
|
514
607
|
if (exampleMatches) {
|
|
515
|
-
example = {
|
|
608
|
+
example = {
|
|
609
|
+
...exampleMatches[1]
|
|
610
|
+
.split(',')
|
|
611
|
+
.map(v => v.trim().replace(/[^\w\s-]/g, ''))
|
|
612
|
+
.filter(v => v !== ''),
|
|
613
|
+
};
|
|
516
614
|
title = title.replace(/\(.*?\)/, '').trim();
|
|
517
615
|
}
|
|
518
616
|
|