mirror of
https://github.com/dorny/test-reporter.git
synced 2026-03-21 23:52:12 +01:00
260 lines
8.3 KiB
TypeScript
260 lines
8.3 KiB
TypeScript
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<string>
|
|
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<TestRunResult> {
|
|
const reportOrSuite = await this.getNetteTesterReport(filePath, content)
|
|
const isReport = (reportOrSuite as NetteTesterReport).testsuites !== undefined
|
|
|
|
// XML might contain:
|
|
// - multiple suites under <testsuites> root node
|
|
// - single <testsuite> as root node
|
|
let report: NetteTesterReport
|
|
if (isReport) {
|
|
report = reportOrSuite as NetteTesterReport
|
|
} else {
|
|
// Make it behave the same way as if suite was inside <testsuites> 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<NetteTesterReport | SingleSuiteReport> {
|
|
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<string, TestCase[]> = 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 <error> and <failure> 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
|
|
}
|
|
}
|