@testomatio/reporter
Version:
Testomatio Reporter Client
434 lines (432 loc) • 18 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.NUnitXmlParser = void 0;
const debug_1 = __importDefault(require("debug"));
const constants_js_1 = require("../constants.js");
const utils_js_1 = require("../utils/utils.js");
const debug = (0, debug_1.default)('@testomatio/reporter:nunit-parser');
/**
* Enhanced NUnit XML Parser that properly handles test-suite hierarchy
* and parameterized tests
*/
class NUnitXmlParser {
constructor(options = {}) {
this.options = options;
this.tests = [];
this.stats = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
inconclusive: 0,
};
}
/**
* Parse NUnit XML test-run structure
* @param {Object} testRun - Parsed XML test-run object
* @returns {Object} - Parsed test results
*/
parseTestRun(testRun) {
debug('Parsing NUnit test-run');
// Extract run-level statistics
this.stats = {
total: parseInt(testRun.total || 0, 10),
passed: parseInt(testRun.passed || 0, 10),
failed: parseInt(testRun.failed || 0, 10),
skipped: parseInt(testRun.skipped || 0, 10),
inconclusive: parseInt(testRun.inconclusive || 0, 10),
};
// Process the root test-suite
if (testRun['test-suite']) {
this.parseTestSuite(testRun['test-suite'], []);
}
debug(`Parsed ${this.tests.length} tests from NUnit XML`);
return {
status: testRun.result?.toLowerCase() || 'unknown',
create_tests: true,
tests_count: this.tests.length,
passed_count: this.tests.filter(t => t.status === constants_js_1.STATUS.PASSED).length,
failed_count: this.tests.filter(t => t.status === constants_js_1.STATUS.FAILED).length,
skipped_count: this.tests.filter(t => t.status === constants_js_1.STATUS.SKIPPED).length,
tests: this.tests,
};
}
/**
* Recursively parse test-suite elements based on their type
* @param {Object|Array} testSuite - Test suite object or array
* @param {Array} parentPath - Current path in the hierarchy
*/
parseTestSuite(testSuite, parentPath = []) {
if (!testSuite)
return;
// Handle arrays of test suites
if (Array.isArray(testSuite)) {
testSuite.forEach(suite => this.parseTestSuite(suite, parentPath));
return;
}
const suiteType = testSuite.type;
const suiteName = testSuite.name;
const fullName = testSuite.fullname;
debug(`Processing test-suite: type=${suiteType}, name=${suiteName}`);
switch (suiteType) {
case 'Assembly':
// Assembly level - ignore the name, just process children
debug('Processing Assembly level - ignoring name, processing children');
this.processChildren(testSuite, parentPath);
break;
case 'TestSuite':
// Namespace/grouping level - add to path but don't create test
debug(`Processing TestSuite level - adding '${suiteName}' to path`);
// Avoid adding duplicate suite names to the path
const newPath = parentPath[parentPath.length - 1] === suiteName ? [...parentPath] : [...parentPath, suiteName];
this.processChildren(testSuite, newPath);
break;
case 'TestFixture':
// Test class level - add to path and process test cases
debug(`Processing TestFixture level - test class '${suiteName}'`);
const testFixturePath = [...parentPath, suiteName];
this.processChildren(testSuite, testFixturePath);
break;
case 'ParameterizedMethod':
// Parameterized method level - process test cases directly
debug(`Processing ParameterizedMethod level - method '${suiteName}'`);
// Don't add to path, just process children directly
this.processChildren(testSuite, parentPath);
break;
default:
debug(`Unknown test-suite type: ${suiteType}, treating as TestSuite`);
const unknownPath = [...parentPath, suiteName];
this.processChildren(testSuite, unknownPath);
break;
}
}
/**
* Process child elements of a test suite
* @param {Object} testSuite - Test suite object
* @param {Array} currentPath - Current path in hierarchy
*/
processChildren(testSuite, currentPath) {
// Process test-cases first (to maintain order)
if (testSuite['test-case']) {
this.parseTestCases(testSuite['test-case'], currentPath, testSuite);
}
// Process nested test-suites
if (testSuite['test-suite']) {
this.parseTestSuite(testSuite['test-suite'], currentPath);
}
}
/**
* Parse test-case elements (actual tests)
* @param {Object|Array} testCases - Test case object or array
* @param {Array} suitePath - Path to the test suite
* @param {Object} parentSuite - Parent test suite for context
*/
parseTestCases(testCases, suitePath, parentSuite) {
if (!testCases)
return;
// Handle arrays of test cases
if (!Array.isArray(testCases)) {
testCases = [testCases];
}
testCases.forEach(testCase => {
const parsedTest = this.parseTestCase(testCase, suitePath, parentSuite);
if (parsedTest) {
this.tests.push(parsedTest);
}
});
}
/**
* Parse individual test case
* @param {Object} testCase - Test case object
* @param {Array} suitePath - Path to the test suite
* @param {Object} parentSuite - Parent test suite for context
* @returns {Object|null} - Parsed test object
*/
parseTestCase(testCase, suitePath, parentSuite) {
if (!testCase || !testCase.name) {
debug('Skipping test case without name');
return null;
}
// Use Description from properties if available (for SpecFlow tests), otherwise use name
let testName = testCase.name;
if (testCase.properties && testCase.properties.property) {
const properties = Array.isArray(testCase.properties.property)
? testCase.properties.property
: [testCase.properties.property];
const descriptionProperty = properties.find(p => p.name === 'Description');
if (descriptionProperty && descriptionProperty.value) {
// Clean up SpecFlow description format: [C211256] Allow mobile print behavior -> Allow mobile print behavior
testName = descriptionProperty.value.replace(/^\[[^\]]+\]\s*/, '');
}
}
const fullName = testCase.fullname;
const methodName = testCase.methodname || this.extractMethodName(testName);
const className = testCase.classname || parentSuite?.name;
debug(`Parsing test case: ${testName}`);
debug(`Test case structure:`, JSON.stringify(testCase, null, 2));
// Extract parameters if this is a parameterized test
const { baseMethodName, parameters, isParameterized } = this.extractParameters(testName);
// Determine test status
let status = constants_js_1.STATUS.PASSED;
if (testCase.result) {
switch (testCase.result.toLowerCase()) {
case 'passed':
status = constants_js_1.STATUS.PASSED;
break;
case 'failed':
status = constants_js_1.STATUS.FAILED;
break;
case 'skipped':
case 'ignored':
status = constants_js_1.STATUS.SKIPPED;
break;
case 'inconclusive':
status = constants_js_1.STATUS.SKIPPED; // Treat inconclusive as skipped
break;
default:
status = constants_js_1.STATUS.PASSED;
}
}
// Extract error information
let message = '';
let stack = '';
const files = [];
// Extract attachments (NUnit format)
if (testCase.attachments) {
const attachments = Array.isArray(testCase.attachments.attachment)
? testCase.attachments.attachment
: [testCase.attachments.attachment];
const attachmentFiles = attachments.filter(a => a && a.filePath).map(a => a.filePath);
files.push(...attachmentFiles);
}
if (testCase.failure) {
message = testCase.failure.message || '';
stack = testCase.failure['stack-trace'] || testCase.failure['#text'] || '';
}
if (testCase.output) {
const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
const stackFiles = (0, utils_js_1.fetchFilesFromStackTrace)(outputText);
files.push(...stackFiles);
if (outputText) {
debug(`Found output in test case: ${outputText.substring(0, 100)}...`);
stack = `${stack}\n\n${outputText}`.trim();
}
else {
debug('No output text found in test case');
}
}
else {
debug('No output found in test case');
}
// Extract test ID and tags from properties
let testId = null;
let tags = [];
if (testCase.properties && testCase.properties.property) {
const properties = Array.isArray(testCase.properties.property)
? testCase.properties.property
: [testCase.properties.property];
const idProperty = properties.find(p => p.name === 'ID');
if (idProperty) {
testId = idProperty.value;
// Remove @ and T prefixes if present
if (testId.startsWith('@'))
testId = testId.slice(1);
if (testId.startsWith('T'))
testId = testId.slice(1);
}
// Extract Category properties as tags
const categoryProperties = properties.filter(p => p.name === 'Category');
tags = categoryProperties.map(p => p.value);
}
// If no test ID found in properties, try to extract from output
if (!testId && testCase.output) {
const outputText = typeof testCase.output === 'string' ? testCase.output : testCase.output['#text'];
if (outputText) {
debug(`Looking for test ID in output: ${outputText.substring(0, 200)}...`);
const idMatch = outputText.match(/\[ID\]\s+tid:\/\/@T([a-f0-9]{8})/i);
if (idMatch) {
testId = idMatch[1];
debug(`Found test ID in output: ${testId}`);
}
else {
debug('No test ID found in output');
}
}
}
// Build file path from suite path and class name
const filePath = this.buildFilePath(suitePath, className, parentSuite);
// For parameterized tests, format example as expected by Testomatio API
// Convert array of parameters to object with numeric keys
let example = null;
if (isParameterized && parameters.length > 0) {
example = {};
parameters.forEach((param, index) => {
example[index] = param;
});
}
return {
// For runs: use full test name with parameters (TestBooleanValue(true))
// For import: API will group by base name using the example field
title: testName, // Full name with parameters for run display
methodName: baseMethodName || methodName || testName,
fullName: fullName,
suitePath: suitePath,
suite_title: className || suitePath[suitePath.length - 1] || 'Unknown',
file: filePath,
files: files, // Array of files that will be attached
status: status,
message: message,
stack: stack,
run_time: parseFloat(testCase.duration || testCase.time || 0) * 1000,
test_id: testId,
tags: tags, // Array of category tags from properties
create: true,
retry: false,
// Parameterized test metadata
example: example, // Parameters as object for API grouping
isParameterized: isParameterized,
parameters: parameters, // Keep original array for reference
baseMethodName: baseMethodName,
};
}
/**
* Extract method name and parameters from test name
* @param {string} testName - Full test name
* @returns {Object} - Extracted information
*/
extractParameters(testName) {
const paramMatch = testName.match(/^(.+?)\((.+)\)$/);
if (paramMatch) {
const baseMethodName = paramMatch[1].trim();
const paramString = paramMatch[2];
// Parse parameters - handle quoted strings and nested structures
const parameters = this.parseParameterString(paramString);
return {
baseMethodName: baseMethodName,
parameters: parameters,
isParameterized: true,
};
}
return {
baseMethodName: testName,
parameters: [],
isParameterized: false,
};
}
/**
* Parse parameter string into array of parameters
* @param {string} paramString - Parameter string
* @returns {Array} - Array of parameters
*/
parseParameterString(paramString) {
const parameters = [];
let current = '';
let inQuotes = false;
let quoteChar = null;
let depth = 0;
for (let i = 0; i < paramString.length; i++) {
const char = paramString[i];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
current += char;
}
else if (inQuotes && char === quoteChar) {
inQuotes = false;
quoteChar = null;
current += char;
}
else if (!inQuotes && char === '(') {
depth++;
current += char;
}
else if (!inQuotes && char === ')') {
depth--;
current += char;
}
else if (!inQuotes && char === ',' && depth === 0) {
parameters.push(current.trim());
current = '';
}
else {
current += char;
}
}
if (current.trim()) {
parameters.push(current.trim());
}
// Clean up parameters - remove quotes if they wrap the entire parameter and filter empty ones
return parameters
.map(param => {
param = param.trim();
if ((param.startsWith('"') && param.endsWith('"')) || (param.startsWith("'") && param.endsWith("'"))) {
return param.slice(1, -1);
}
return param;
})
.filter(p => !!p);
}
/**
* Extract method name from test name (fallback)
* @param {string} testName - Test name
* @returns {string} - Method name
*/
extractMethodName(testName) {
// Remove parameters if present
const paramMatch = testName.match(/^(.+?)\(/);
return paramMatch ? paramMatch[1].trim() : testName;
}
/**
* Build file path from suite path and class name
* @param {Array} suitePath - Suite path array
* @param {string} className - Class name
* @param {Object} parentSuite - Parent suite for context
* @returns {string} - File path
*/
buildFilePath(suitePath, className, parentSuite) {
// Try to get file path from parent suite
if (parentSuite && parentSuite.filepath) {
return parentSuite.filepath;
}
// Build path from suite hierarchy
const pathParts = [...suitePath];
if (className && !pathParts.includes(className)) {
pathParts.push(className);
}
// Convert to file path format
return pathParts.join('/') + '.cs'; // Assume C# for NUnit
}
/**
* Group parameterized tests by base method name
* @param {Array} tests - Array of parsed tests
* @returns {Object} - Grouped tests
*/
groupParameterizedTests(tests) {
const grouped = {};
tests.forEach(test => {
const key = test.isParameterized
? `${test.suitePath.join('.')}.${test.baseMethodName}`
: `${test.suitePath.join('.')}.${test.title}`;
if (!grouped[key]) {
grouped[key] = {
baseTest: {
name: test.baseMethodName || test.title,
suitePath: test.suitePath,
suite_title: test.suite_title,
file: test.file,
isParameterized: test.isParameterized,
},
variations: [],
};
}
grouped[key].variations.push(test);
});
return grouped;
}
}
exports.NUnitXmlParser = NUnitXmlParser;
module.exports = NUnitXmlParser;
module.exports.NUnitXmlParser = NUnitXmlParser;