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)