mirror of
https://github.com/dorny/test-reporter.git
synced 2026-06-08 18:17:09 +00:00
258 lines
8.7 KiB
TypeScript
258 lines
8.7 KiB
TypeScript
import {ParseOptions, TestParser} from '../../test-parser.js'
|
|
import {parseStringPromise} from 'xml2js'
|
|
|
|
import {PhpunitReport, SingleSuiteReport, TestCase, TestSuite} from './phpunit-junit-types.js'
|
|
import {getBasePath, normalizeFilePath} from '../../utils/path-utils.js'
|
|
|
|
import {
|
|
TestExecutionResult,
|
|
TestRunResult,
|
|
TestSuiteResult,
|
|
TestGroupResult,
|
|
TestCaseResult,
|
|
TestCaseError
|
|
} from '../../test-results.js'
|
|
|
|
export class PhpunitJunitParser implements TestParser {
|
|
readonly trackedFiles: Set<string>
|
|
readonly trackedFilesList: string[]
|
|
private assumedWorkDir: string | undefined
|
|
|
|
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.getPhpunitReport(filePath, content)
|
|
const isReport = (reportOrSuite as PhpunitReport).testsuites !== undefined
|
|
|
|
// XML might contain:
|
|
// - multiple suites under <testsuites> root node
|
|
// - single <testsuite> as root node
|
|
let report: PhpunitReport
|
|
if (isReport) {
|
|
report = reportOrSuite as PhpunitReport
|
|
} 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 getPhpunitReport(filePath: string, content: string): Promise<PhpunitReport | SingleSuiteReport> {
|
|
try {
|
|
return await parseStringPromise(content)
|
|
} catch (e) {
|
|
throw new Error(`Invalid XML at ${filePath}\n\n${e}`)
|
|
}
|
|
}
|
|
|
|
private getTestRunResult(filePath: string, report: PhpunitReport): TestRunResult {
|
|
const suites: TestSuiteResult[] = []
|
|
this.collectSuites(suites, report.testsuites.testsuite ?? [])
|
|
|
|
const seconds = parseFloat(report.testsuites.$?.time ?? '')
|
|
const time = isNaN(seconds) ? undefined : seconds * 1000
|
|
return new TestRunResult(filePath, suites, time)
|
|
}
|
|
|
|
private collectSuites(results: TestSuiteResult[], testsuites: TestSuite[]): void {
|
|
for (const ts of testsuites) {
|
|
// Recursively process nested test suites first (depth-first)
|
|
if (ts.testsuite) {
|
|
this.collectSuites(results, ts.testsuite)
|
|
}
|
|
|
|
// Only add suites that have direct test cases
|
|
// This avoids adding container suites that only hold nested suites
|
|
if (ts.testcase && ts.testcase.length > 0) {
|
|
const name = ts.$.name.trim()
|
|
const time = parseFloat(ts.$.time) * 1000
|
|
results.push(new TestSuiteResult(name, this.getGroups(ts), time))
|
|
}
|
|
}
|
|
}
|
|
|
|
private getGroups(suite: TestSuite): TestGroupResult[] {
|
|
if (!suite.testcase || suite.testcase.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const groups: {name: string; tests: TestCase[]}[] = []
|
|
for (const tc of suite.testcase) {
|
|
// Use classname (PHPUnit style) for grouping
|
|
// If classname matches suite name, use empty string to avoid redundancy
|
|
const className = tc.$.classname ?? tc.$.class ?? ''
|
|
const groupName = className === suite.$.name ? '' : className
|
|
let grp = groups.find(g => g.name === groupName)
|
|
if (grp === undefined) {
|
|
grp = {name: groupName, tests: []}
|
|
groups.push(grp)
|
|
}
|
|
grp.tests.push(tc)
|
|
}
|
|
|
|
return groups.map(grp => {
|
|
const tests = grp.tests.map(tc => {
|
|
const name = tc.$.name.trim()
|
|
const result = this.getTestCaseResult(tc)
|
|
const time = parseFloat(tc.$.time) * 1000
|
|
const error = this.getTestCaseError(tc)
|
|
return new TestCaseResult(name, result, time, error)
|
|
})
|
|
return new TestGroupResult(grp.name, tests)
|
|
})
|
|
}
|
|
|
|
private getTestCaseResult(test: TestCase): TestExecutionResult {
|
|
if (test.failure || test.error) return 'failed'
|
|
if (test.skipped) return 'skipped'
|
|
return 'success'
|
|
}
|
|
|
|
private getTestCaseError(tc: TestCase): 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]
|
|
const details = typeof failure === 'string' ? failure : (failure._ ?? '')
|
|
|
|
// PHPUnit provides file path directly in testcase attributes
|
|
let filePath: string | undefined
|
|
let line: number | undefined
|
|
|
|
if (tc.$.file) {
|
|
const relativePath = this.getRelativePath(tc.$.file)
|
|
if (this.trackedFiles.has(relativePath)) {
|
|
filePath = relativePath
|
|
}
|
|
if (tc.$.line) {
|
|
line = parseInt(tc.$.line)
|
|
}
|
|
}
|
|
|
|
// If file not in tracked files, try to extract from error details
|
|
if (!filePath && details) {
|
|
const extracted = this.extractFileAndLine(details)
|
|
if (extracted) {
|
|
filePath = extracted.filePath
|
|
line = extracted.line
|
|
}
|
|
}
|
|
|
|
let message: string | undefined
|
|
if (typeof failure !== 'string' && failure.$) {
|
|
message = failure.$.message
|
|
if (failure.$.type) {
|
|
message = message ? `${failure.$.type}: ${message}` : failure.$.type
|
|
}
|
|
}
|
|
|
|
return {
|
|
path: filePath,
|
|
line,
|
|
details,
|
|
message
|
|
}
|
|
}
|
|
|
|
private extractFileAndLine(details: string): {filePath: string; line: number} | undefined {
|
|
// PHPUnit stack traces typically have format: /path/to/file.php:123
|
|
const lines = details.split(/\r?\n/)
|
|
|
|
for (const str of lines) {
|
|
// Match patterns like /path/to/file.php:123 or at /path/to/file.php(123)
|
|
const matchColon = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt)):(\d+)/)
|
|
if (matchColon) {
|
|
const relativePath = this.getRelativePath(matchColon[1])
|
|
if (this.trackedFiles.has(relativePath)) {
|
|
return {filePath: relativePath, line: parseInt(matchColon[2])}
|
|
}
|
|
}
|
|
|
|
const matchParen = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt))\((\d+)\)/)
|
|
if (matchParen) {
|
|
const relativePath = this.getRelativePath(matchParen[1])
|
|
if (this.trackedFiles.has(relativePath)) {
|
|
return {filePath: relativePath, line: parseInt(matchParen[2])}
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Converts an absolute file path to a relative path by stripping the working directory prefix.
|
|
*
|
|
* @param path - The absolute file path from PHPUnit output (e.g., `/home/runner/work/repo/src/Test.php`)
|
|
* @returns The relative path (e.g., `src/Test.php`) if a working directory can be determined,
|
|
* otherwise returns the normalized original path
|
|
*/
|
|
private getRelativePath(path: string): string {
|
|
path = normalizeFilePath(path)
|
|
const workDir = this.getWorkDir(path)
|
|
if (workDir !== undefined && path.startsWith(workDir)) {
|
|
path = path.substring(workDir.length)
|
|
}
|
|
return path
|
|
}
|
|
|
|
/**
|
|
* Determines the working directory prefix to strip from absolute file paths.
|
|
*
|
|
* The working directory is resolved using the following priority:
|
|
*
|
|
* 1. **Explicit configuration** - If `options.workDir` is set, it takes precedence.
|
|
* This allows users to explicitly specify the working directory.
|
|
*
|
|
* 2. **Cached assumption** - If we've previously determined a working directory
|
|
* (`assumedWorkDir`) and the current path starts with it, we reuse that value.
|
|
* This avoids redundant computation for subsequent paths.
|
|
*
|
|
* 3. **Heuristic detection** - Uses `getBasePath()` to find the common prefix between
|
|
* the absolute path and the list of tracked files in the repository. For example:
|
|
* - Absolute path: `/home/runner/work/repo/src/Test.php`
|
|
* - Tracked file: `src/Test.php`
|
|
* - Detected workDir: `/home/runner/work/repo/`
|
|
*
|
|
* Once detected, the working directory is cached in `assumedWorkDir` for efficiency.
|
|
*
|
|
* @param path - The normalized absolute file path to analyze
|
|
* @returns The working directory prefix (with trailing slash), or `undefined` if it cannot be determined
|
|
*
|
|
* @example
|
|
* // With tracked file 'src/Foo.php' and path '/home/runner/work/repo/src/Foo.php'
|
|
* // Returns: '/home/runner/work/repo/'
|
|
*/
|
|
private getWorkDir(path: string): string | undefined {
|
|
if (this.options.workDir) {
|
|
return this.options.workDir
|
|
}
|
|
|
|
if (this.assumedWorkDir && path.startsWith(this.assumedWorkDir)) {
|
|
return this.assumedWorkDir
|
|
}
|
|
|
|
const basePath = getBasePath(path, this.trackedFilesList)
|
|
if (basePath !== undefined) {
|
|
this.assumedWorkDir = basePath
|
|
}
|
|
return basePath
|
|
}
|
|
}
|