diff --git a/.github/workflows/check-dist.yml b/.github/workflows/check-dist.yml
index 22b61c21..fb0b3bbe 100644
--- a/.github/workflows/check-dist.yml
+++ b/.github/workflows/check-dist.yml
@@ -23,10 +23,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Set Node.js 16.x
- uses: actions/setup-node@v3
+ - name: Set Node.js 20.x
+ uses: actions/setup-node@v4
with:
- node-version: 16.x
+ node-version: 20.x
- name: Install dependencies
run: npm ci
@@ -46,7 +46,7 @@ jobs:
id: diff
# If index.js was different than expected, upload the expected version as an artifact
- - uses: actions/upload-artifact@v3
+ - uses: actions/upload-artifact@v4
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
with:
name: dist
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 12b8778e..31a007dd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
- name: Upload test results
if: success() || failure()
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: test-results
path: __tests__/__results__/*.xml
diff --git a/README.md b/README.md
index 0ae7b766..1f183a9d 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,7 @@ jobs:
# flutter-json
# java-junit
# jest-junit
+ # pytest-junit
# mocha-json
reporter: ''
@@ -277,21 +278,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}"
+ }
}
```
@@ -307,8 +310,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/report-tb-short.xml b/__tests__/fixtures/external/pytest/report-tb-short.xml
new file mode 100644
index 00000000..b70b4a94
--- /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__/fixtures/external/pytest/single-case.xml b/__tests__/fixtures/external/pytest/single-case.xml
new file mode 100644
index 00000000..a091c1d3
--- /dev/null
+++ b/__tests__/fixtures/external/pytest/single-case.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/__tests__/integration.test.ts b/__tests__/integration.test.ts
new file mode 100644
index 00000000..a9b709ec
--- /dev/null
+++ b/__tests__/integration.test.ts
@@ -0,0 +1,79 @@
+import fs from 'fs'
+import {LocalFileProvider} from '../src/input-providers/local-file-provider'
+
+const input: Record = {
+ name: 'Test Results',
+ path: 'test-report.xml',
+ reporter: 'pytest-junit',
+ 'path-replace-backslashes': 'false',
+ 'list-suites': 'all',
+ 'list-tests': 'all',
+ 'max-annotations': '10',
+ 'fail-on-error': 'true',
+ 'only-summary': 'false',
+ 'directory-mapping': 'mnt/extra-addons:mypath',
+ token: '***'
+}
+
+const update = jest.fn().mockReturnValue({data: {}, status: 0})
+
+jest.mock('@actions/core', () => ({
+ getInput: jest.fn().mockImplementation((name: string) => input[name]),
+ setFailed: jest.fn(),
+ setOutput: jest.fn(),
+ startGroup: jest.fn(),
+ endGroup: jest.fn(),
+ info: jest.fn(),
+ warning: jest.fn()
+}))
+jest.mock('@actions/github', () => {
+ return {
+ getOctokit: jest.fn().mockReturnValue({
+ rest: {
+ checks: {
+ update,
+ create: jest.fn().mockReturnValue({data: {}})
+ }
+ }
+ }),
+ context: {
+ eventName: '',
+ payload: {}
+ }
+ }
+})
+
+jest.mock('../src/input-providers/local-file-provider')
+
+describe('integration test', () => {
+ it('pytest', async () => {
+ jest.spyOn(LocalFileProvider.prototype, 'load').mockResolvedValue({
+ 'report-tb-short.xml': [
+ {
+ file: 'report-tb-short.xml',
+ content: fs.readFileSync(__dirname + '/fixtures/external/pytest/report-tb-short.xml', {encoding: 'utf8'})
+ }
+ ]
+ })
+ jest
+ .spyOn(LocalFileProvider.prototype, 'listTrackedFiles')
+ .mockResolvedValue(['mypath/product_changes/tests/first_test.py'])
+
+ await import('../src/main')
+ // trick to wait for the pending "main" Promise
+ await new Promise(resolve => setTimeout(resolve))
+
+ expect(update).toHaveBeenCalledTimes(1)
+ expect(update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ output: expect.objectContaining({
+ annotations: [
+ expect.objectContaining({
+ path: 'mypath/product_changes/tests/first_test.py'
+ })
+ ]
+ })
+ })
+ )
+ })
+})
diff --git a/__tests__/pytest-junit.test.ts b/__tests__/pytest-junit.test.ts
new file mode 100644
index 00000000..629c41bd
--- /dev/null
+++ b/__tests__/pytest-junit.test.ts
@@ -0,0 +1,49 @@
+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'
+import {getAnnotations} from '../src/report/get-annotations'
+
+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')
+ })
+
+ 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: ['addons/product_changes/tests/first_test.py']
+ }
+
+ 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'
+ })
+
+ const annotations = getAnnotations([result], 1)
+ expect(annotations.length).toBe(1)
+ expect(annotations[0].path).toBe('addons/product_changes/tests/first_test.py')
+ })
+})
diff --git a/__tests__/utils/path-utils.test.ts b/__tests__/utils/path-utils.test.ts
new file mode 100644
index 00000000..d2e0cdc1
--- /dev/null
+++ b/__tests__/utils/path-utils.test.ts
@@ -0,0 +1,12 @@
+import {getBasePath} from '../../src/utils/path-utils'
+
+describe('getBasePath', () => {
+ it('tracked file in path', () => {
+ const path = 'C:/Users/Michal/Workspace/dorny/test-check/reports/jest/__tests__/main.test.js'
+ const trackedFiles = ['__tests__/main.test.js', '__tests__/second.test.js', 'lib/main.js']
+
+ const result = getBasePath(path, trackedFiles)
+
+ expect(result).toBe('C:/Users/Michal/Workspace/dorny/test-check/reports/jest/')
+ })
+})
diff --git a/action.yml b/action.yml
index ee0b64bc..fd786d14 100644
--- a/action.yml
+++ b/action.yml
@@ -30,6 +30,7 @@ inputs:
- flutter-json
- java-junit
- jest-junit
+ - pytest-junit
- mocha-json
required: true
list-suites:
@@ -71,6 +72,11 @@ inputs:
Detailed listing of test suites and test cases will be skipped.
default: 'false'
required: false
+ directory-mapping:
+ description: |
+ Map part of the file paths to something else, so they match the paths of the repository.
+ This is needed when you use run your code in a container with a different path than the source code repository.
+ required: false
token:
description: GitHub Access Token
required: false
@@ -94,7 +100,7 @@ outputs:
url_html:
description: Check run URL HTML
runs:
- using: 'node16'
+ using: 'node20'
main: 'dist/index.js'
branding:
color: blue
diff --git a/dist/index.js b/dist/index.js
index 6246d8d8..81cea6a8 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);
@@ -297,6 +298,7 @@ class TestReporter {
this.workDirInput = core.getInput('working-directory', { required: false });
this.onlySummary = core.getInput('only-summary', { required: false }) === 'true';
this.token = core.getInput('token', { required: true });
+ this.directoryMapping = core.getInput('directory-mapping', { required: false });
this.context = (0, github_utils_1.getCheckRunContext)();
this.octokit = github.getOctokit(this.token);
if (this.listSuites !== 'all' && this.listSuites !== 'failed') {
@@ -329,12 +331,14 @@ class TestReporter {
const parseErrors = this.maxAnnotations > 0;
const trackedFiles = parseErrors ? yield inputProvider.listTrackedFiles() : [];
const workDir = this.artifact ? undefined : (0, path_utils_1.normalizeDirPath)(process.cwd(), true);
+ const [from, to] = this.directoryMapping.split(':');
if (parseErrors)
core.info(`Found ${trackedFiles.length} files tracked by GitHub`);
const options = {
workDir,
trackedFiles,
- parseErrors
+ parseErrors,
+ directoryMapping: { from, to }
};
core.info(`Using test report parser '${this.reporter}'`);
const parser = this.getParser(this.reporter, options);
@@ -404,6 +408,7 @@ class TestReporter {
const conclusion = isFailed ? 'failure' : 'success';
const icon = isFailed ? markdown_utils_1.Icon.fail : markdown_utils_1.Icon.success;
core.info(`Updating check run conclusion (${conclusion}) and output`);
+ core.info(`Posted annotations: ${JSON.stringify(annotations)}`);
const resp = yield this.octokit.rest.checks.update(Object.assign({ check_run_id: createResp.data.id, conclusion, status: 'completed', output: {
title: `${name} ${icon}`,
summary,
@@ -429,6 +434,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:
@@ -1395,6 +1402,143 @@ 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);
+const path_utils_1 = __nccwpck_require__(4070);
+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: this.applyDirectoryMapping(this.getAbsolutePath(path)),
+ line,
+ message: lines[1]
+ };
+ }
+ return undefined;
+ }
+ applyDirectoryMapping(path) {
+ if (this.options.directoryMapping && this.options.directoryMapping.from) {
+ return path.replace(this.options.directoryMapping.from, this.options.directoryMapping.to);
+ }
+ return path;
+ }
+ getRelativePath(path) {
+ path = (0, path_utils_1.normalizeFilePath)(path);
+ const workDir = this.getWorkDir(path);
+ if (workDir !== undefined && path.startsWith(workDir)) {
+ path = path.substring(workDir.length);
+ }
+ return path;
+ }
+ getAbsolutePath(path) {
+ const relativePath = this.getRelativePath(path);
+ for (const file of this.options.trackedFiles) {
+ if (relativePath.endsWith(file)) {
+ return file;
+ }
+ }
+ return relativePath;
+ }
+ getWorkDir(path) {
+ var _a, _b;
+ return ((_b = (_a = this.options.workDir) !== null && _a !== void 0 ? _a : this.assumedWorkDir) !== null && _b !== void 0 ? _b : (this.assumedWorkDir = (0, path_utils_1.getBasePath)(path, this.options.trackedFiles)));
+ }
+}
+exports.PytestJunitParser = PytestJunitParser;
+
+
/***/ }),
/***/ 5867:
@@ -1448,9 +1592,10 @@ function getAnnotations(results, maxCount) {
// Limit number of created annotations
errors.splice(maxCount + 1);
const annotations = errors.map(e => {
+ const paths = e.path ? [e.path] : e.testRunPaths;
const message = [
'Failed test found in:',
- e.testRunPaths.map(p => ` ${p}`).join('\n'),
+ paths.map(p => ` ${p}`).join('\n'),
'Error:',
ident((0, markdown_utils_1.fixEol)(e.message), ' ')
].join('\n');
diff --git a/src/input-providers/local-file-provider.ts b/src/input-providers/local-file-provider.ts
index e1afd8c3..e18e9f5f 100644
--- a/src/input-providers/local-file-provider.ts
+++ b/src/input-providers/local-file-provider.ts
@@ -4,10 +4,7 @@ import {FileContent, InputProvider, ReportInput} from './input-provider'
import {listFiles} from '../utils/git'
export class LocalFileProvider implements InputProvider {
- constructor(
- readonly name: string,
- readonly pattern: string[]
- ) {}
+ constructor(readonly name: string, readonly pattern: string[]) {}
async load(): Promise {
const result: FileContent[] = []
diff --git a/src/main.ts b/src/main.ts
index 87a1da7e..b306a0a4 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'
@@ -44,6 +45,7 @@ class TestReporter {
readonly workDirInput = core.getInput('working-directory', {required: false})
readonly onlySummary = core.getInput('only-summary', {required: false}) === 'true'
readonly token = core.getInput('token', {required: true})
+ readonly directoryMapping = core.getInput('directory-mapping', {required: false})
readonly octokit: InstanceType
readonly context = getCheckRunContext()
@@ -94,13 +96,15 @@ class TestReporter {
const parseErrors = this.maxAnnotations > 0
const trackedFiles = parseErrors ? await inputProvider.listTrackedFiles() : []
const workDir = this.artifact ? undefined : normalizeDirPath(process.cwd(), true)
+ const [from, to] = this.directoryMapping.split(':')
if (parseErrors) core.info(`Found ${trackedFiles.length} files tracked by GitHub`)
const options: ParseOptions = {
workDir,
trackedFiles,
- parseErrors
+ parseErrors,
+ directoryMapping: {from, to}
}
core.info(`Using test report parser '${this.reporter}'`)
@@ -185,6 +189,7 @@ class TestReporter {
const icon = isFailed ? Icon.fail : Icon.success
core.info(`Updating check run conclusion (${conclusion}) and output`)
+ core.info(`Posted annotations: ${JSON.stringify(annotations)}`)
const resp = await this.octokit.rest.checks.update({
check_run_id: createResp.data.id,
conclusion,
@@ -217,6 +222,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 00000000..3e78ceb1
--- /dev/null
+++ b/src/parsers/pytest-junit/pytest-junit-parser.ts
@@ -0,0 +1,148 @@
+import {ParseOptions, TestParser} from '../../test-parser'
+import {parseStringPromise} from 'xml2js'
+
+import {JunitReport, TestCase, TestSuite} from './pytest-junit-types'
+
+import {
+ TestExecutionResult,
+ TestRunResult,
+ TestSuiteResult,
+ TestGroupResult,
+ TestCaseResult,
+ TestCaseError
+} from '../../test-results'
+import {getBasePath, normalizeFilePath} from '../../utils/path-utils'
+
+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 failure = tc.failure[0]
+ const details = typeof failure === 'object' ? failure._ : failure
+
+ return {
+ ...this.errorSource(details),
+ details
+ }
+ }
+
+ 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)
+
+ if (path && Number.isFinite(line)) {
+ return {
+ path: this.applyDirectoryMapping(this.getAbsolutePath(path)),
+ line,
+ message: lines[1]
+ }
+ }
+
+ return undefined
+ }
+
+ private applyDirectoryMapping(path: string): string {
+ if (this.options.directoryMapping && this.options.directoryMapping.from) {
+ return path.replace(this.options.directoryMapping.from, this.options.directoryMapping.to)
+ }
+ return 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
+ }
+
+ private getAbsolutePath(path: string): string {
+ const relativePath = this.getRelativePath(path)
+ for (const file of this.options.trackedFiles) {
+ if (relativePath.endsWith(file)) {
+ return file
+ }
+ }
+ return relativePath
+ }
+
+ 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 00000000..9f8f82d1
--- /dev/null
+++ b/src/parsers/pytest-junit/pytest-junit-types.ts
@@ -0,0 +1,36 @@
+import {Failure} from '../java-junit/java-junit-types'
+
+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 | Failure[]
+ skipped?: string[]
+}
diff --git a/src/report/get-annotations.ts b/src/report/get-annotations.ts
index 1b3e8aa4..a7fafb7d 100644
--- a/src/report/get-annotations.ts
+++ b/src/report/get-annotations.ts
@@ -69,9 +69,10 @@ export function getAnnotations(results: TestRunResult[], maxCount: number): Anno
errors.splice(maxCount + 1)
const annotations = errors.map(e => {
+ const paths = e.path ? [e.path] : e.testRunPaths
const message = [
'Failed test found in:',
- e.testRunPaths.map(p => ` ${p}`).join('\n'),
+ paths.map(p => ` ${p}`).join('\n'),
'Error:',
ident(fixEol(e.message), ' ')
].join('\n')
diff --git a/src/test-parser.ts b/src/test-parser.ts
index f1345614..032f9405 100644
--- a/src/test-parser.ts
+++ b/src/test-parser.ts
@@ -4,6 +4,7 @@ export interface ParseOptions {
parseErrors: boolean
workDir?: string
trackedFiles: string[]
+ directoryMapping?: {from: string; to: string}
}
export interface TestParser {
diff --git a/src/test-results.ts b/src/test-results.ts
index bca8c416..97cf3b42 100644
--- a/src/test-results.ts
+++ b/src/test-results.ts
@@ -1,11 +1,7 @@
import {DEFAULT_LOCALE} from './utils/node-utils'
export class TestRunResult {
- constructor(
- readonly path: string,
- readonly suites: TestSuiteResult[],
- private totalTime?: number
- ) {}
+ constructor(readonly path: string, readonly suites: TestSuiteResult[], private totalTime?: number) {}
get tests(): number {
return this.suites.reduce((sum, g) => sum + g.tests, 0)
@@ -44,11 +40,7 @@ export class TestRunResult {
}
export class TestSuiteResult {
- constructor(
- readonly name: string,
- readonly groups: TestGroupResult[],
- private totalTime?: number
- ) {}
+ constructor(readonly name: string, readonly groups: TestGroupResult[], private totalTime?: number) {}
get tests(): number {
return this.groups.reduce((sum, g) => sum + g.tests.length, 0)
@@ -86,10 +78,7 @@ export class TestSuiteResult {
}
export class TestGroupResult {
- constructor(
- readonly name: string | undefined | null,
- readonly tests: TestCaseResult[]
- ) {}
+ constructor(readonly name: string | undefined | null, readonly tests: TestCaseResult[]) {}
get passed(): number {
return this.tests.reduce((sum, t) => (t.result === 'success' ? sum + 1 : sum), 0)