diff --git a/README.md b/README.md
index 727968f..240f24d 100644
--- a/README.md
+++ b/README.md
@@ -132,6 +132,7 @@ jobs:
# flutter-json
# java-junit
# jest-junit
+ # pytest-junit
# mocha-json
reporter: ''
@@ -264,21 +265,23 @@ Some heuristic was necessary to figure out the mapping between the line in the s
It will create test results in Junit XML format which can be then processed by this action.
You can use the following example configuration in `package.json`:
```json
-"scripts": {
- "test": "jest --ci --reporters=default --reporters=jest-Junit"
-},
-"devDependencies": {
- "jest": "^26.5.3",
- "jest-junit": "^12.0.0"
-},
-"jest-junit": {
- "outputDirectory": "reports",
- "outputName": "jest-junit.xml",
- "ancestorSeparator": " › ",
- "uniqueOutputName": "false",
- "suiteNameTemplate": "{filepath}",
- "classNameTemplate": "{classname}",
- "titleTemplate": "{title}"
+{
+ "scripts": {
+ "test": "jest --ci --reporters=default --reporters=jest-Junit"
+ },
+ "devDependencies": {
+ "jest": "^26.5.3",
+ "jest-junit": "^12.0.0"
+ },
+ "jest-junit": {
+ "outputDirectory": "reports",
+ "outputName": "jest-junit.xml",
+ "ancestorSeparator": " › ",
+ "uniqueOutputName": "false",
+ "suiteNameTemplate": "{filepath}",
+ "classNameTemplate": "{classname}",
+ "titleTemplate": "{title}"
+ }
}
```
@@ -294,8 +297,10 @@ Configuration of `uniqueOutputName`, `suiteNameTemplate`, `classNameTemplate`, `
You can use the following example configuration in `package.json`:
```json
-"scripts": {
- "test": "mocha --reporter json > test-results.json"
+{
+ "scripts": {
+ "test": "mocha --reporter json > test-results.json"
+ }
}
```
diff --git a/__tests__/fixtures/external/pytest/single-case.xml b/__tests__/fixtures/external/pytest/single-case.xml
new file mode 100644
index 0000000..a091c1d
--- /dev/null
+++ b/__tests__/fixtures/external/pytest/single-case.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/__tests__/pytest-junit.test.ts b/__tests__/pytest-junit.test.ts
new file mode 100644
index 0000000..b5ef3a4
--- /dev/null
+++ b/__tests__/pytest-junit.test.ts
@@ -0,0 +1,24 @@
+import * as fs from 'fs'
+import * as path from 'path'
+
+import {PytestJunitParser} from '../src/parsers/pytest-junit/pytest-junit-parser'
+import {ParseOptions} from '../src/test-parser'
+import {normalizeFilePath} from '../src/utils/path-utils'
+
+describe('pytest-junit tests', () => {
+ it('test with one successful test', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'pytest', 'single-case.xml')
+ const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
+ const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
+
+ const opts: ParseOptions = {
+ parseErrors: true,
+ trackedFiles: []
+ }
+
+ const parser = new PytestJunitParser(opts)
+ const result = await parser.parse(filePath, fileContent)
+ expect(result.tests).toBe(1)
+ expect(result.result).toBe('success')
+ })
+})
diff --git a/src/main.ts b/src/main.ts
index d0c5a06..c4e8f9a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -14,6 +14,7 @@ import {DartJsonParser} from './parsers/dart-json/dart-json-parser'
import {DotnetTrxParser} from './parsers/dotnet-trx/dotnet-trx-parser'
import {JavaJunitParser} from './parsers/java-junit/java-junit-parser'
import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser'
+import {PytestJunitParser} from './parsers/pytest-junit/pytest-junit-parser'
import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser'
import {normalizeDirPath, normalizeFilePath} from './utils/path-utils'
@@ -214,6 +215,8 @@ class TestReporter {
return new JavaJunitParser(options)
case 'jest-junit':
return new JestJunitParser(options)
+ case 'pytest-junit':
+ return new PytestJunitParser(options)
case 'mocha-json':
return new MochaJsonParser(options)
default:
diff --git a/src/parsers/pytest-junit/pytest-junit-parser.ts b/src/parsers/pytest-junit/pytest-junit-parser.ts
new file mode 100644
index 0000000..c22e4b4
--- /dev/null
+++ b/src/parsers/pytest-junit/pytest-junit-parser.ts
@@ -0,0 +1,124 @@
+import {ParseOptions, TestParser} from '../../test-parser'
+import {parseStringPromise} from 'xml2js'
+
+import {JunitReport, TestCase, TestSuite} from './pytest-junit-types'
+import {getExceptionSource} from '../../utils/node-utils'
+import {getBasePath, normalizeFilePath} from '../../utils/path-utils'
+
+import {
+ TestExecutionResult,
+ TestRunResult,
+ TestSuiteResult,
+ TestGroupResult,
+ TestCaseResult,
+ TestCaseError
+} from '../../test-results'
+
+export class PytestJunitParser implements TestParser {
+ assumedWorkDir: string | undefined
+
+ constructor(readonly options: ParseOptions) {}
+
+ async parse(path: string, content: string): Promise {
+ const ju = await this.getJunitReport(path, content)
+ return this.getTestRunResult(path, ju)
+ }
+
+ private async getJunitReport(path: string, content: string): Promise {
+ try {
+ return (await parseStringPromise(content)) as JunitReport
+ } catch (e) {
+ throw new Error(`Invalid XML at ${path}\n\n${e}`)
+ }
+ }
+
+ private getTestRunResult(path: string, junit: JunitReport): TestRunResult {
+ const suites: TestSuiteResult[] =
+ junit.testsuites.testsuite === undefined
+ ? []
+ : junit.testsuites.testsuite.map(ts => {
+ const name = ts.$.name.trim()
+ const time = parseFloat(ts.$.time) * 1000
+ return new TestSuiteResult(name, this.getGroups(ts), time)
+ })
+
+ const time =
+ junit.testsuites.$ === undefined
+ ? suites.reduce((sum, suite) => sum + suite.time, 0)
+ : parseFloat(junit.testsuites.$.time) * 1000
+
+ return new TestRunResult(path, suites, time)
+ }
+
+ private getGroups(suite: TestSuite): TestGroupResult[] {
+ if (!suite.testcase) {
+ return []
+ }
+
+ const groups: {describe: string; tests: TestCase[]}[] = []
+ for (const tc of suite.testcase) {
+ let grp = groups.find(g => g.describe === tc.$.classname)
+ if (grp === undefined) {
+ grp = {describe: tc.$.classname, 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.describe, tests)
+ })
+ }
+
+ private getTestCaseResult(test: TestCase): TestExecutionResult {
+ if (test.failure) return 'failed'
+ if (test.skipped) return 'skipped'
+ return 'success'
+ }
+
+ private getTestCaseError(tc: TestCase): TestCaseError | undefined {
+ if (!this.options.parseErrors || !tc.failure) {
+ return undefined
+ }
+
+ const details = tc.failure[0]
+ let path
+ let line
+
+ const src = getExceptionSource(details, this.options.trackedFiles, file => this.getRelativePath(file))
+ if (src) {
+ path = src.path
+ line = src.line
+ }
+
+ return {
+ path,
+ line,
+ details
+ }
+ }
+
+ 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 {
+ return (
+ this.options.workDir ??
+ this.assumedWorkDir ??
+ (this.assumedWorkDir = getBasePath(path, this.options.trackedFiles))
+ )
+ }
+}
diff --git a/src/parsers/pytest-junit/pytest-junit-types.ts b/src/parsers/pytest-junit/pytest-junit-types.ts
new file mode 100644
index 0000000..c89c582
--- /dev/null
+++ b/src/parsers/pytest-junit/pytest-junit-types.ts
@@ -0,0 +1,34 @@
+export interface JunitReport {
+ testsuites: TestSuites
+}
+
+export interface TestSuites {
+ $?: {
+ time: string
+ }
+ testsuite?: TestSuite[]
+}
+
+export interface TestSuite {
+ $: {
+ name: string
+ tests: string
+ errors: string
+ failures: string
+ skipped: string
+ time: string
+ timestamp?: Date
+ }
+ testcase?: TestCase[]
+}
+
+export interface TestCase {
+ $: {
+ classname: string
+ file?: string
+ name: string
+ time: string
+ }
+ failure?: string[]
+ skipped?: string[]
+}