diff --git a/__tests__/__snapshots__/dotnet-trx.test.ts.snap b/__tests__/__snapshots__/dotnet-trx.test.ts.snap new file mode 100644 index 0000000..c2e72fd --- /dev/null +++ b/__tests__/__snapshots__/dotnet-trx.test.ts.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dotnet-trx tests matches report snapshot 1`] = ` +Object { + "annotations": Array [], + "summary": "**7** tests were completed in **1.061s** with **3** passed, **1** skipped and **3** failed. +| Result | Suite | Tests | Time | Passed ✔️ | Failed ❌ | Skipped ✖️ | +| :---: | :--- | ---: | ---: | ---: | ---: | ---: | +| ❌ | [DotnetTests.XUnitTests.CalculatorTests](#ts-0-DotnetTests-XUnitTests-CalculatorTests) | 7 | 109.5761ms | 3 | 3 | 1 | +# Test Suites + +## DotnetTests.XUnitTests.CalculatorTests ❌ + +| Result | Test | Time | +| :---: | :--- | ---: | +| ❌ | Exception_In_TargetTest | 0.4975ms | +| ❌ | Exception_In_Test | 2.2728ms | +| ❌ | Failing_Test | 3.2953ms | +| ✔️ | Passing_Test | 0.1254ms | +| ✔️ | Passing_Test_With_Name | 0.103ms | +| ✖️ | Skipped_Test | 1ms | +| ✔️ | Timeout_Test | 102.2821ms | +", + "title": "Dotnet TRX tests ❌", +} +`; diff --git a/__tests__/dotnet-trx.test.ts b/__tests__/dotnet-trx.test.ts new file mode 100644 index 0000000..6e1054b --- /dev/null +++ b/__tests__/dotnet-trx.test.ts @@ -0,0 +1,26 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {parseDotnetTrx} from '../src/parsers/dotnet-trx/dotnet-trx-parser' +import {ParseOptions} from '../src/parsers/parser-types' + +const xmlFixture = fs.readFileSync(path.join(__dirname, 'fixtures', 'dotnet-trx.trx'), {encoding: 'utf8'}) +const outputPath = __dirname + '/__outputs__/dotnet-trx.md' + +describe('dotnet-trx tests', () => { + it('matches report snapshot', async () => { + const opts: ParseOptions = { + name: 'Dotnet TRX tests', + annotations: true, + trackedFiles: ['DotnetTests.Unit/Calculator.cs', 'DotnetTests.XUnitTests/CalculatorTests.cs'], + workDir: 'C:/Users/Michal/Workspace/dorny/test-check/reports/dotnet/' + } + + const result = await parseDotnetTrx(xmlFixture, opts) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, result?.output?.summary ?? '') + + expect(result.success).toBeFalsy() + expect(result?.output).toMatchSnapshot() + }) +}) diff --git a/src/main.ts b/src/main.ts index ef98380..154fdea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ import * as core from '@actions/core' import * as github from '@actions/github' -import {parseJestJunit} from './parsers/jest-junit/jest-junit-parser' import {parseDartJson} from './parsers/dart-json/dart-json-parser' +import {parseDotnetTrx} from './parsers/dotnet-trx/dotnet-trx-parser' +import {parseJestJunit} from './parsers/jest-junit/jest-junit-parser' import {ParseOptions, ParseTestResult} from './parsers/parser-types' import {getFileContent, normalizeDirPath} from './utils/file-utils' import {listFiles} from './utils/git' @@ -67,7 +68,7 @@ function getParser(reporter: string): ParseTestResult { case 'dart-json': return parseDartJson case 'dotnet-trx': - throw new Error('Not implemented yet!') + return parseDotnetTrx case 'flutter-machine': return parseDartJson case 'jest-junit': diff --git a/src/parsers/dotnet-trx/dotnet-trx-parser.ts b/src/parsers/dotnet-trx/dotnet-trx-parser.ts new file mode 100644 index 0000000..a1ac221 --- /dev/null +++ b/src/parsers/dotnet-trx/dotnet-trx-parser.ts @@ -0,0 +1,115 @@ +import {ErrorInfo, Outcome, TestMethod, TrxReport} from './dotnet-trx-types' + +import {Annotation, ParseOptions, TestResult} from '../parser-types' +import {parseStringPromise} from 'xml2js' + +import {parseAttribute} from '../../utils/xml-utils' +import {Icon} from '../../utils/markdown-utils' + +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult +} from '../../report/test-results' +import getReport from '../../report/get-report' + +class TestClass { + constructor(readonly name: string) {} + readonly tests: Test[] = [] +} + +class Test { + constructor( + readonly name: string, + readonly outcome: Outcome, + readonly duration: number, + readonly error?: ErrorInfo + ) {} + + get result(): TestExecutionResult { + switch (this.outcome) { + case 'Passed': + return 'success' + case 'NotExecuted': + return 'skipped' + case 'Failed': + return 'failed' + } + } +} + +export async function parseDotnetTrx(content: string, options: ParseOptions): Promise { + const trx = (await parseStringPromise(content, { + attrValueProcessors: [parseAttribute] + })) as TrxReport + + const testClasses = getTestClasses(trx) + const testRun = getTestRunResult(trx, testClasses) + const success = testRun.result === 'success' + const icon = success ? Icon.success : Icon.fail + + return { + success, + output: { + title: `${options.name.trim()} ${icon}`, + summary: getReport(testRun), + annotations: options.annotations + ? getAnnotations(/*testClasses, options.workDir, options.trackedFiles*/) + : undefined + } + } +} + +function getTestRunResult(trx: TrxReport, testClasses: TestClass[]): TestRunResult { + const times = trx.TestRun.Times[0].$ + const totalTime = times.finish.getTime() - times.start.getTime() + + const suites = testClasses.map(tc => { + const tests = tc.tests.map(t => new TestCaseResult(t.name, t.result, t.duration)) + const group = new TestGroupResult(null, tests) + return new TestSuiteResult(tc.name, [group]) + }) + + return new TestRunResult(suites, totalTime) +} + +function getTestClasses(trx: TrxReport): TestClass[] { + const unitTests: {[id: string]: TestMethod} = {} + for (const td of trx.TestRun.TestDefinitions) { + for (const ut of td.UnitTest) { + unitTests[ut.$.id] = ut.TestMethod[0] + } + } + + const unitTestsResults = trx.TestRun.Results.flatMap(r => r.UnitTestResult).flatMap(unitTestResult => ({ + unitTestResult, + testMethod: unitTests[unitTestResult.$.testId] + })) + + const testClasses: {[name: string]: TestClass} = {} + for (const r of unitTestsResults) { + let tc = testClasses[r.testMethod.$.className] + if (tc === undefined) { + tc = new TestClass(r.testMethod.$.className) + testClasses[tc.name] = tc + } + const output = r.unitTestResult.Output + const error = output?.length > 0 && output[0].ErrorInfo?.length > 0 ? output[0].ErrorInfo[0] : undefined + const test = new Test(r.testMethod.$.name, r.unitTestResult.$.outcome, r.unitTestResult.$.duration, error) + tc.tests.push(test) + } + + const result = Object.values(testClasses) + result.sort((a, b) => a.name.localeCompare(b.name)) + for (const tc of result) { + tc.tests.sort((a, b) => a.name.localeCompare(b.name)) + } + + return result +} + +function getAnnotations(/*testClasses: TestClass[], workDir: string, trackedFiles: string[]*/): Annotation[] { + return [] +} diff --git a/src/parsers/dotnet-trx/dotnet-trx-types.ts b/src/parsers/dotnet-trx/dotnet-trx-types.ts new file mode 100644 index 0000000..c43f301 --- /dev/null +++ b/src/parsers/dotnet-trx/dotnet-trx-types.ts @@ -0,0 +1,60 @@ +export interface TrxReport { + TestRun: TestRun +} + +export interface TestRun { + Times: Times[] + Results: Results[] + TestDefinitions: TestDefinitions[] +} + +export interface Times { + $: { + creation: Date + queuing: Date + start: Date + finish: Date + } +} + +export interface TestDefinitions { + UnitTest: UnitTest[] +} + +export interface UnitTest { + $: { + id: string + } + TestMethod: TestMethod[] +} + +export interface TestMethod { + $: { + className: string + name: string + } +} + +export interface Results { + UnitTestResult: UnitTestResult[] +} + +export interface UnitTestResult { + $: { + testId: string + testName: string + duration: number + outcome: Outcome + } + Output: Output[] +} + +export interface Output { + ErrorInfo: ErrorInfo[] +} +export interface ErrorInfo { + Message: string[] + StackTrace: string[] +} + +export type Outcome = 'Passed' | 'NotExecuted' | 'Failed' diff --git a/src/report/test-results.ts b/src/report/test-results.ts index 84d0af4..ad72894 100644 --- a/src/report/test-results.ts +++ b/src/report/test-results.ts @@ -50,7 +50,7 @@ export class TestSuiteResult { } export class TestGroupResult { - constructor(readonly name: string | undefined, 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) diff --git a/src/utils/xml-utils.ts b/src/utils/xml-utils.ts index 8d7b33c..319d26b 100644 --- a/src/utils/xml-utils.ts +++ b/src/utils/xml-utils.ts @@ -1,8 +1,23 @@ -export function parseAttribute(str: string | undefined): string | number | undefined { +const isoDateRe = /^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/ + +// matches dotnet duration: 00:00:00.0010000 +const durationRe = /^(\d\d):(\d\d):(\d\d\.\d+)$/ + +export function parseAttribute(str: string | undefined): string | Date | number | undefined { if (str === '' || str === undefined) { return str } + if (isoDateRe.test(str)) { + return new Date(str) + } + + const durationMatch = str.match(durationRe) + if (durationMatch !== null) { + const [_, hourStr, minStr, secStr] = durationMatch + return (parseInt(hourStr) * 3600 + parseInt(minStr) * 60 + parseFloat(secStr)) * 1000 + } + const num = parseFloat(str) if (isNaN(num)) { return str