import * as path from 'path' import {ParseOptions, TestParser} from '../../test-parser.js' import {parseStringPromise} from 'xml2js' import {NetteTesterReport, SingleSuiteReport, TestCase, TestSuite} from './tester-junit-types.js' import {normalizeFilePath} from '../../utils/path-utils.js' import { TestExecutionResult, TestRunResult, TestSuiteResult, TestGroupResult, TestCaseResult, TestCaseError } from '../../test-results.js' interface ParsedTestName { filePath: string method?: string description?: string className?: string displayName: string } export class NetteTesterJunitParser implements TestParser { readonly trackedFiles: Set readonly trackedFilesList: string[] constructor(readonly options: ParseOptions) { this.trackedFilesList = options.trackedFiles.map(f => normalizeFilePath(f)) this.trackedFiles = new Set(this.trackedFilesList) } async parse(filePath: string, content: string): Promise { const reportOrSuite = await this.getNetteTesterReport(filePath, content) const isReport = (reportOrSuite as NetteTesterReport).testsuites !== undefined // XML might contain: // - multiple suites under root node // - single as root node let report: NetteTesterReport if (isReport) { report = reportOrSuite as NetteTesterReport } else { // Make it behave the same way as if suite was inside root node const suite = (reportOrSuite as SingleSuiteReport).testsuite report = { testsuites: { $: {time: suite.$.time}, testsuite: [suite] } } } return this.getTestRunResult(filePath, report) } private async getNetteTesterReport( filePath: string, content: string ): Promise { try { return await parseStringPromise(content) } catch (e) { throw new Error(`Invalid XML at ${filePath}\n\n${e}`) } } private getTestRunResult(filePath: string, report: NetteTesterReport): TestRunResult { const suites = report.testsuites.testsuite === undefined ? [] : report.testsuites.testsuite.map((ts, index) => { // Use report file name as suite name (user preference) const fileName = path.basename(filePath) // If there are multiple test suites, add index to distinguish them const name = report.testsuites.testsuite && report.testsuites.testsuite.length > 1 ? `${fileName} #${index + 1}` : fileName const time = parseFloat(ts.$.time) * 1000 const sr = new TestSuiteResult(name, this.getGroups(ts), time) return sr }) const seconds = parseFloat(report.testsuites.$?.time ?? '') const time = isNaN(seconds) ? undefined : seconds * 1000 return new TestRunResult(filePath, suites, time) } private getGroups(suite: TestSuite): TestGroupResult[] { if (!suite.testcase || suite.testcase.length === 0) { return [] } // Group tests by directory structure const groups: Map = new Map() for (const tc of suite.testcase) { const parsed = this.parseTestCaseName(tc.$.classname) const directory = path.dirname(parsed.filePath) if (!groups.has(directory)) { groups.set(directory, []) } groups.get(directory)!.push(tc) } return Array.from(groups.entries()).map(([dir, tests]) => { const testResults = tests.map(tc => { const parsed = this.parseTestCaseName(tc.$.classname) const result = this.getTestCaseResult(tc) const time = parseFloat(tc.$.time || '0') * 1000 const error = this.getTestCaseError(tc, parsed.filePath) return new TestCaseResult(parsed.displayName, result, time, error) }) return new TestGroupResult(dir, testResults) }) } /** * Parse test case name from classname attribute. * * Handles multiple patterns: * 1. Simple: "tests/Framework/Assert.equal.phpt" * 2. With method: "tests/Framework/Assert.equal.recursive.phpt [method=testSimple]" * 3. With description: "Prevent loop in error handling. The #268 regression. | tests/Framework/TestCase.ownErrorHandler.phpt" * 4. With class and method: "Kdyby\BootstrapFormRenderer\BootstrapRenderer. | KdybyTests/BootstrapFormRenderer/BootstrapRendererTest.phpt [method=testRenderingBasics]" */ private parseTestCaseName(classname: string): ParsedTestName { let filePath = classname let method: string | undefined let description: string | undefined let className: string | undefined // Pattern: "Description | filepath [method=methodName]" // or "ClassName | filepath [method=methodName]" const pipePattern = /^(.+?)\s*\|\s*(.+?)(?:\s*\[method=(.+?)\])?$/ const pipeMatch = classname.match(pipePattern) if (pipeMatch) { const prefix = pipeMatch[1].trim() filePath = pipeMatch[2].trim() method = pipeMatch[3] // Check if prefix looks like a class name (contains backslash AND ends with dot) // Examples: "Kdyby\BootstrapFormRenderer\BootstrapRenderer." // vs description: "Prevent loop in error handling. The #268 regression." if (prefix.includes('\\') && prefix.endsWith('.')) { className = prefix } else { description = prefix } } else { // Pattern: "filepath [method=methodName]" const methodPattern = /^(.+?)\s*\[method=(.+?)\]$/ const methodMatch = classname.match(methodPattern) if (methodMatch) { filePath = methodMatch[1].trim() method = methodMatch[2].trim() } } // Generate display name const baseName = path.basename(filePath) let displayName = baseName if (method) { displayName = `${baseName}::${method}` } if (description) { displayName = `${description} (${baseName})` } else if (className && method) { // For class names, keep them but still show the file displayName = `${baseName}::${method}` } return {filePath, method, description, className, displayName} } private getTestCaseResult(test: TestCase): TestExecutionResult { if (test.failure || test.error) return 'failed' if (test.skipped) return 'skipped' return 'success' } private getTestCaseError(tc: TestCase, filePath: string): TestCaseError | undefined { if (!this.options.parseErrors) { return undefined } // We process and the same way const failures = tc.failure ?? tc.error if (!failures || failures.length === 0) { return undefined } const failure = failures[0] // For Nette Tester, details are in the message attribute, not as inner text const details = typeof failure === 'string' ? failure : (failure._ ?? failure.$?.message ?? '') // Try to extract file path and line from error details let errorFilePath: string | undefined let line: number | undefined if (details) { const extracted = this.extractFileAndLine(details) if (extracted) { errorFilePath = extracted.filePath line = extracted.line } } // Fallback: use test file path if tracked if (!errorFilePath) { const normalized = normalizeFilePath(filePath) if (this.trackedFiles.has(normalized)) { errorFilePath = normalized } } let message: string | undefined if (typeof failure !== 'string' && failure.$) { message = failure.$.message if (failure.$.type) { message = message ? `${failure.$.type}: ${message}` : failure.$.type } } return { path: errorFilePath, line, details, message } } /** * Extract file path and line number from error details. * Matches patterns like: /path/to/file.phpt:123 or /path/to/file.php:456 */ private extractFileAndLine(details: string): {filePath: string; line: number} | undefined { const lines = details.split(/\r?\n/) for (const str of lines) { // Match PHP file patterns: /path/to/file.phpt:123 or /path/to/file.php:456 const match = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt)):(\d+)/) if (match) { const normalized = normalizeFilePath(match[1]) if (this.trackedFiles.has(normalized)) { return {filePath: normalized, line: parseInt(match[2])} } } } return undefined } }