From a97700c53ceb15ac6d0eb859e6972a5d071e5f84 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 13:53:47 +0100 Subject: [PATCH] Include tests for parsing files names and line numbers in the `PhpunitJunitParser` Co-Authored-By: Codex --- __tests__/fixtures/phpunit/phpunit-paths.xml | 23 +++++++ __tests__/phpunit-junit.test.ts | 61 ++++++++++++++++++ .../phpunit-junit/phpunit-junit-parser.ts | 62 ++++++++++++++----- 3 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 __tests__/fixtures/phpunit/phpunit-paths.xml diff --git a/__tests__/fixtures/phpunit/phpunit-paths.xml b/__tests__/fixtures/phpunit/phpunit-paths.xml new file mode 100644 index 0000000..46b0a16 --- /dev/null +++ b/__tests__/fixtures/phpunit/phpunit-paths.xml @@ -0,0 +1,23 @@ + + + + + /home/runner/work/repo/src/Fake.php:42 + + + /home/runner/work/repo/src/Other.php:10 + + + at /home/runner/work/repo/src/Paren.php(123) + + + C:\repo\src\Win.php:77 + + + at C:\repo\src\WinParen.php(88) + + + /home/runner/work/repo/tests/Sample.phpt:12 + + + diff --git a/__tests__/phpunit-junit.test.ts b/__tests__/phpunit-junit.test.ts index 03a4306..8075208 100644 --- a/__tests__/phpunit-junit.test.ts +++ b/__tests__/phpunit-junit.test.ts @@ -100,6 +100,67 @@ describe('phpunit-junit tests', () => { } }) + it('maps absolute paths to tracked files for annotations', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'phpunit', 'phpunit-paths.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [ + 'src/Fake.php', + 'src/Other.php', + 'src/Paren.php', + 'src/Win.php', + 'src/WinParen.php', + 'tests/Sample.phpt' + ] + } + + const parser = new PhpunitJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + const suite = result.suites.find(s => s.name === 'SampleSuite') + expect(suite).toBeDefined() + + const tests = suite!.groups.flatMap(g => g.tests) + const fileFailure = tests.find(t => t.name === 'testFailure') + expect(fileFailure).toBeDefined() + expect(fileFailure!.error).toBeDefined() + expect(fileFailure!.error!.path).toBe('src/Fake.php') + expect(fileFailure!.error!.line).toBe(42) + + const stringFailure = tests.find(t => t.name === 'testStringFailure') + expect(stringFailure).toBeDefined() + expect(stringFailure!.error).toBeDefined() + expect(stringFailure!.error!.path).toBe('src/Other.php') + expect(stringFailure!.error!.line).toBe(10) + + const parenFailure = tests.find(t => t.name === 'testParenFailure') + expect(parenFailure).toBeDefined() + expect(parenFailure!.error).toBeDefined() + expect(parenFailure!.error!.path).toBe('src/Paren.php') + expect(parenFailure!.error!.line).toBe(123) + + const windowsFailure = tests.find(t => t.name === 'testWindowsFailure') + expect(windowsFailure).toBeDefined() + expect(windowsFailure!.error).toBeDefined() + expect(windowsFailure!.error!.path).toBe('src/Win.php') + expect(windowsFailure!.error!.line).toBe(77) + + const windowsParenFailure = tests.find(t => t.name === 'testWindowsParenFailure') + expect(windowsParenFailure).toBeDefined() + expect(windowsParenFailure!.error).toBeDefined() + expect(windowsParenFailure!.error!.path).toBe('src/WinParen.php') + expect(windowsParenFailure!.error!.line).toBe(88) + + const phptFailure = tests.find(t => t.name === 'testPhptFailure') + expect(phptFailure).toBeDefined() + expect(phptFailure!.error).toBeDefined() + expect(phptFailure!.error!.path).toBe('tests/Sample.phpt') + expect(phptFailure!.error!.line).toBe(12) + }) + it('parses junit-basic.xml with nested suites and failure', async () => { const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'junit-basic.xml') const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) diff --git a/src/parsers/phpunit-junit/phpunit-junit-parser.ts b/src/parsers/phpunit-junit/phpunit-junit-parser.ts index 79e6056..c24e87d 100644 --- a/src/parsers/phpunit-junit/phpunit-junit-parser.ts +++ b/src/parsers/phpunit-junit/phpunit-junit-parser.ts @@ -2,7 +2,7 @@ import {ParseOptions, TestParser} from '../../test-parser' import {parseStringPromise} from 'xml2js' import {PhpunitReport, SingleSuiteReport, TestCase, TestSuite} from './phpunit-junit-types' -import {normalizeFilePath} from '../../utils/path-utils' +import {getBasePath, normalizeFilePath} from '../../utils/path-utils' import { TestExecutionResult, @@ -15,9 +15,12 @@ import { export class PhpunitJunitParser implements TestParser { readonly trackedFiles: Set + readonly trackedFilesList: string[] + private assumedWorkDir: string | undefined constructor(readonly options: ParseOptions) { - this.trackedFiles = new Set(options.trackedFiles.map(f => normalizeFilePath(f))) + this.trackedFilesList = options.trackedFiles.map(f => normalizeFilePath(f)) + this.trackedFiles = new Set(this.trackedFilesList) } async parse(filePath: string, content: string): Promise { @@ -127,16 +130,16 @@ export class PhpunitJunitParser implements TestParser { } const failure = failures[0] - const details = failure._ ?? '' + 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 normalizedPath = normalizeFilePath(tc.$.file) - if (this.trackedFiles.has(normalizedPath)) { - filePath = normalizedPath + const relativePath = this.getRelativePath(tc.$.file) + if (this.trackedFiles.has(relativePath)) { + filePath = relativePath } if (tc.$.line) { line = parseInt(tc.$.line) @@ -153,7 +156,7 @@ export class PhpunitJunitParser implements TestParser { } let message: string | undefined - if (failure.$) { + if (typeof failure !== 'string' && failure.$) { message = failure.$.message if (failure.$.type) { message = message ? `${failure.$.type}: ${message}` : failure.$.type @@ -174,17 +177,48 @@ export class PhpunitJunitParser implements TestParser { for (const str of lines) { // Match patterns like /path/to/file.php:123 or at /path/to/file.php(123) - const match = str.match(/([^\s:()]+\.php):(\d+)|([^\s:()]+\.php)\((\d+)\)/) - if (match) { - const path = match[1] ?? match[3] - const lineStr = match[2] ?? match[4] - const normalizedPath = normalizeFilePath(path) - if (this.trackedFiles.has(normalizedPath)) { - return {filePath: normalizedPath, line: parseInt(lineStr)} + 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 } + + private getRelativePath(path: string): string { + path = normalizeFilePath(path) + const workDir = this.getWorkDir(path) + if (workDir !== undefined && path.startsWith(workDir)) { + path = path.substr(workDir.length) + } + return path + } + + 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 + } }