diff --git a/__tests__/fixtures/external/pytest/report-tb-short.xml b/__tests__/fixtures/external/pytest/report-tb-short.xml new file mode 100644 index 0000000..b70b4a9 --- /dev/null +++ b/__tests__/fixtures/external/pytest/report-tb-short.xml @@ -0,0 +1,12 @@ + + + + + mnt/extra-addons/product_changes/tests/first_test.py:6: in test_something + assert False + E assert False + + + + \ No newline at end of file diff --git a/__tests__/pytest-junit.test.ts b/__tests__/pytest-junit.test.ts index b5ef3a4..ee5cfc3 100644 --- a/__tests__/pytest-junit.test.ts +++ b/__tests__/pytest-junit.test.ts @@ -21,4 +21,24 @@ describe('pytest-junit tests', () => { expect(result.tests).toBe(1) expect(result.result).toBe('success') }) + + it('test failure with trace back', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'external', 'pytest', 'report-tb-short.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('failed') + expect(result.failedSuites[0].failedGroups[0].failedTests[0].error).toMatchObject({ + line: 6, + message: 'assert False' + }) + }) }) diff --git a/dist/index.js b/dist/index.js index 78b2f56..a84f2fa 100644 --- a/dist/index.js +++ b/dist/index.js @@ -264,6 +264,7 @@ const dart_json_parser_1 = __nccwpck_require__(4528); const dotnet_trx_parser_1 = __nccwpck_require__(2664); const java_junit_parser_1 = __nccwpck_require__(676); const jest_junit_parser_1 = __nccwpck_require__(1113); +const pytest_junit_parser_1 = __nccwpck_require__(2842); const mocha_json_parser_1 = __nccwpck_require__(6043); const path_utils_1 = __nccwpck_require__(4070); const github_utils_1 = __nccwpck_require__(3522); @@ -426,6 +427,8 @@ class TestReporter { return new java_junit_parser_1.JavaJunitParser(options); case 'jest-junit': return new jest_junit_parser_1.JestJunitParser(options); + case 'pytest-junit': + return new pytest_junit_parser_1.PytestJunitParser(options); case 'mocha-json': return new mocha_json_parser_1.MochaJsonParser(options); default: @@ -1392,6 +1395,115 @@ class MochaJsonParser { exports.MochaJsonParser = MochaJsonParser; +/***/ }), + +/***/ 2842: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.PytestJunitParser = void 0; +const xml2js_1 = __nccwpck_require__(6189); +const test_results_1 = __nccwpck_require__(2768); +class PytestJunitParser { + constructor(options) { + this.options = options; + } + parse(path, content) { + return __awaiter(this, void 0, void 0, function* () { + const ju = yield this.getJunitReport(path, content); + return this.getTestRunResult(path, ju); + }); + } + getJunitReport(path, content) { + return __awaiter(this, void 0, void 0, function* () { + try { + return (yield (0, xml2js_1.parseStringPromise)(content)); + } + catch (e) { + throw new Error(`Invalid XML at ${path}\n\n${e}`); + } + }); + } + getTestRunResult(path, junit) { + const suites = junit.testsuites.testsuite === undefined + ? [] + : junit.testsuites.testsuite.map(ts => { + const name = ts.$.name.trim(); + const time = parseFloat(ts.$.time) * 1000; + return new test_results_1.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 test_results_1.TestRunResult(path, suites, time); + } + getGroups(suite) { + if (!suite.testcase) { + return []; + } + const groups = []; + 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 test_results_1.TestCaseResult(name, result, time, error); + }); + return new test_results_1.TestGroupResult(grp.describe, tests); + }); + } + getTestCaseResult(test) { + if (test.failure) + return 'failed'; + if (test.skipped) + return 'skipped'; + return 'success'; + } + getTestCaseError(tc) { + if (!this.options.parseErrors || !tc.failure) { + return undefined; + } + const failure = tc.failure[0]; + const details = typeof failure === 'object' ? failure._ : failure; + return Object.assign(Object.assign({}, this.errorSource(details)), { details }); + } + errorSource(details) { + const lines = details.split('\n').map(line => line.trim()); + const [path, pos] = lines[0].split(':'); + const line = Number.parseInt(pos); + if (path && Number.isFinite(line)) { + return { + path, + line, + message: lines[1] + }; + } + return undefined; + } +} +exports.PytestJunitParser = PytestJunitParser; + + /***/ }), /***/ 5867: diff --git a/src/parsers/pytest-junit/pytest-junit-parser.ts b/src/parsers/pytest-junit/pytest-junit-parser.ts index c22e4b4..cad0c0d 100644 --- a/src/parsers/pytest-junit/pytest-junit-parser.ts +++ b/src/parsers/pytest-junit/pytest-junit-parser.ts @@ -2,8 +2,6 @@ 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, @@ -88,37 +86,28 @@ export class PytestJunitParser implements TestParser { 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 - } + const failure = tc.failure[0] + const details = typeof failure === 'object' ? failure._ : failure return { - path, - line, + ...this.errorSource(details), 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 errorSource(details: string): {path: string; line: number; message: string} | undefined { + const lines = details.split('\n').map(line => line.trim()) + const [path, pos] = lines[0].split(':') + const line = Number.parseInt(pos) - private getWorkDir(path: string): string | undefined { - return ( - this.options.workDir ?? - this.assumedWorkDir ?? - (this.assumedWorkDir = getBasePath(path, this.options.trackedFiles)) - ) + if (path && Number.isFinite(line)) { + return { + path, + line, + message: lines[1] + } + } + + return undefined } } diff --git a/src/parsers/pytest-junit/pytest-junit-types.ts b/src/parsers/pytest-junit/pytest-junit-types.ts index c89c582..9f8f82d 100644 --- a/src/parsers/pytest-junit/pytest-junit-types.ts +++ b/src/parsers/pytest-junit/pytest-junit-types.ts @@ -1,3 +1,5 @@ +import {Failure} from '../java-junit/java-junit-types' + export interface JunitReport { testsuites: TestSuites } @@ -29,6 +31,6 @@ export interface TestCase { name: string time: string } - failure?: string[] + failure?: string | Failure[] skipped?: string[] }