@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.
- package/README.md +1 -0
- package/lib/bin/cli.js +1 -1
- package/lib/bin/reportXml.js +5 -2
- package/lib/bin/startTest.js +0 -0
- package/lib/bin/uploadArtifacts.js +0 -0
- 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 +431 -0
- package/lib/uploader.js +4 -0
- package/lib/utils/utils.js +182 -21
- package/lib/xmlReader.d.ts +32 -26
- package/lib/xmlReader.js +111 -52
- package/package.json +1 -1
- package/src/bin/cli.js +15 -3
- package/src/client.js +2 -2
- package/src/junit-adapter/csharp.js +1 -1
- package/src/junit-adapter/nunit-parser.js +12 -2
- package/src/pipe/testomatio.js +2 -1
- package/src/utils/utils.js +0 -11
- package/src/xmlReader.js +5 -3
package/lib/utils/utils.js
CHANGED
|
@@ -116,19 +116,26 @@ const isValidUrl = s => {
|
|
|
116
116
|
}
|
|
117
117
|
};
|
|
118
118
|
exports.isValidUrl = isValidUrl;
|
|
119
|
-
const fileMatchRegex = /file:(
|
|
119
|
+
const fileMatchRegex = /file:(\/*)([A-Za-z]:[\\/].*?|\/.*?)\.(png|avi|webm|jpg|html|txt)/gi;
|
|
120
120
|
const fetchFilesFromStackTrace = (stack = '', checkExists = true) => {
|
|
121
|
-
|
|
122
|
-
.map(
|
|
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
|
-
//
|
|
126
|
-
|
|
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 =>
|
|
151
|
-
|
|
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 =>
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
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
|
}
|
package/lib/xmlReader.d.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
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
|
-
.
|
|
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
|
-
|
|
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;
|