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[] +}