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