Added support for dotnet nunit

This commit is contained in:
Simen Sandvaer 2022-11-06 14:12:13 +01:00
parent c9b3d0e2bd
commit a97564ca53
21 changed files with 3265 additions and 1615 deletions

View file

@ -15,6 +15,7 @@ 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 {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser'
import {DotnetNunitParser} from './parsers/dotnet-nunit/dotnet-nunit-parser'
import {normalizeDirPath, normalizeFilePath} from './utils/path-utils'
import {getCheckRunContext} from './utils/github-utils'
@ -211,6 +212,8 @@ class TestReporter {
return new JestJunitParser(options)
case 'mocha-json':
return new MochaJsonParser(options)
case 'dotnet-nunit':
return new DotnetNunitParser(options)
default:
throw new Error(`Input variable 'reporter' is set to invalid value '${reporter}'`)
}

View file

@ -1,5 +1,5 @@
import {ParseOptions, TestParser} from '../../test-parser'
import {DEFAULT_LOCALE} from '../../utils/node-utils'
import {getBasePath, normalizeFilePath} from '../../utils/path-utils'
import {
@ -135,6 +135,8 @@ export class DartJsonParser implements TestParser {
return new TestSuiteResult(this.getRelativePath(s.suite.path), this.getGroups(s))
})
suites.sort((a, b) => a.name.localeCompare(b.name, DEFAULT_LOCALE))
return new TestRunResult(tr.path, suites, tr.time)
}

View file

@ -0,0 +1,150 @@
import * as path from 'path'
import {ParseOptions, TestParser} from '../../test-parser'
import {parseStringPromise} from 'xml2js'
import {NunitReport, TestCase, TestSuite} from './dotnet-nunit-types'
import {normalizeFilePath} from '../../utils/path-utils'
import {
TestExecutionResult,
TestRunResult,
TestSuiteResult,
TestGroupResult,
TestCaseResult,
TestCaseError
} from '../../test-results'
export class DotnetNunitParser implements TestParser {
readonly trackedFiles: {[fileName: string]: string[]}
constructor(readonly options: ParseOptions) {
this.trackedFiles = {}
for (const filePath of options.trackedFiles) {
const fileName = path.basename(filePath)
const files = this.trackedFiles[fileName] ?? (this.trackedFiles[fileName] = [])
files.push(normalizeFilePath(filePath))
}
}
async parse(filePath: string, content: string): Promise<TestRunResult> {
const reportOrSuite = await this.getNunitReport(filePath, content)
return this.getTestRunResult(filePath, reportOrSuite)
}
private async getNunitReport(filePath: string, content: string): Promise<NunitReport> {
try {
return await parseStringPromise(content)
} catch (e) {
throw new Error(`Invalid XML at ${filePath}\n\n${e}`)
}
}
private getTestSuiteResultRecursive(
testSuites: TestSuite[] | undefined,
suiteResults: TestSuiteResult[],
depth: number
): void {
if (testSuites !== undefined) {
testSuites.map(ts => {
const name = ts.$.name.trim()
const time = parseFloat(ts.$.duration) * 1000
const groups = this.getGroups(ts)
const sr = new TestSuiteResult(name, groups, time, depth)
suiteResults.push(sr)
if (groups.length === 0) {
const nestedTestSuites = ts['test-suite']
if (nestedTestSuites !== undefined) {
this.getTestSuiteResultRecursive(nestedTestSuites, suiteResults, depth + 1)
}
}
})
}
}
private getTestRunResult(filePath: string, nunit: NunitReport): TestRunResult {
const suites: TestSuiteResult[] = []
const testSuites = nunit['test-run']['test-suite']
this.getTestSuiteResultRecursive(testSuites, suites, 0)
const seconds = parseFloat(nunit['test-run'].$?.time)
const time = isNaN(seconds) ? undefined : seconds * 1000
return new TestRunResult(filePath, suites, time)
}
private getGroups(suite: TestSuite): TestGroupResult[] {
const groups: {describe: string; tests: TestCase[]}[] = []
if (suite['test-case'] === undefined) {
return []
}
for (const tc of suite['test-case']) {
let grp = groups.find(g => g.describe === tc.$.name)
if (grp === undefined) {
grp = {describe: tc.$.name, 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.$.result === 'Skipped') return 'skipped'
return 'success'
}
private getTestCaseError(tc: TestCase): TestCaseError | undefined {
if (!this.options.parseErrors) {
return undefined
}
const failure = tc.failure
if (!failure) {
return undefined
}
const details = failure[0]['stack-trace'] === undefined ? '' : failure[0]['stack-trace'][0]
let filePath
let line
const src = this.exceptionThrowSource(details)
if (src) {
filePath = src.filePath
line = src.line
}
return {
path: filePath,
line,
details,
message: failure[0].message === undefined ? '' : failure[0].message[0]
}
}
private exceptionThrowSource(stackTrace: string): {filePath: string; line: number} | undefined {
const lines = stackTrace.split(/\r?\n/)
const re = /^at (.*\) in .*):(.+)$/
for (const str of lines) {
const match = str.match(re)
if (match !== null) {
const [, , filePath, lineStr] = match
const line = parseInt(lineStr)
return {filePath, line}
}
}
}
}

View file

@ -0,0 +1,39 @@
export interface NunitReport {
'test-run': TestRun
}
export interface TestRun {
$: {
time: string
}
'test-suite'?: TestSuite[]
}
export interface TestSuite {
$: {
name: string
tests: string
errors: string
failed: string
skipped: string
passed: string
duration: string
}
'test-case'?: TestCase[]
'test-suite'?: TestSuite[]
}
export interface TestCase {
$: {
fullname: string
name: string
time: string
result: string
}
failure?: Failure[]
}
export interface Failure {
'stack-trace'?: string
message: string
}

View file

@ -2,6 +2,7 @@ import * as path from 'path'
import {ParseOptions, TestParser} from '../../test-parser'
import {parseStringPromise} from 'xml2js'
import {DEFAULT_LOCALE} from '../../utils/node-utils'
import {JunitReport, SingleSuiteReport, TestCase, TestSuite} from './java-junit-types'
import {normalizeFilePath} from '../../utils/path-utils'
@ -70,6 +71,8 @@ export class JavaJunitParser implements TestParser {
return sr
})
suites.sort((a, b) => a.name.localeCompare(b.name, DEFAULT_LOCALE))
const seconds = parseFloat(junit.testsuites.$?.time)
const time = isNaN(seconds) ? undefined : seconds * 1000
return new TestRunResult(filePath, suites, time)

View file

@ -81,9 +81,6 @@ function trimReport(lines: string[]): string {
function applySort(results: TestRunResult[]): void {
results.sort((a, b) => a.path.localeCompare(b.path, DEFAULT_LOCALE))
for (const res of results) {
res.suites.sort((a, b) => a.name.localeCompare(b.name, DEFAULT_LOCALE))
}
}
function getByteLength(text: string): number {
@ -184,7 +181,7 @@ function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOpt
[Align.Left, Align.Right, Align.Right, Align.Right, Align.Right],
...suites.map((s, suiteIndex) => {
const tsTime = formatTime(s.time)
const tsName = s.name
const tsName = prependDepthIndentationToName(s.name.trim(), s.depth)
const skipLink = options.listTests === 'none' || (options.listTests === 'failed' && s.result !== 'failed')
const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex).link
const tsNameLink = skipLink ? tsName : link(tsName, tsAddr)
@ -208,6 +205,11 @@ function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOpt
return sections
}
function prependDepthIndentationToName(name: string, depth: number): string {
const depthPrefix = Array(depth).fill('&nbsp;&nbsp;&nbsp;&nbsp;').join('')
return depthPrefix + name
}
function getTestsReport(ts: TestSuiteResult, runIndex: number, suiteIndex: number, options: ReportOptions): string[] {
if (options.listTests === 'failed' && ts.result !== 'failed') {
return []

View file

@ -40,7 +40,12 @@ 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,
private indentationDepth: number = 0
) {}
get tests(): number {
return this.groups.reduce((sum, g) => sum + g.tests.length, 0)
@ -49,12 +54,15 @@ export class TestSuiteResult {
get passed(): number {
return this.groups.reduce((sum, g) => sum + g.passed, 0)
}
get failed(): number {
return this.groups.reduce((sum, g) => sum + g.failed, 0)
}
get skipped(): number {
return this.groups.reduce((sum, g) => sum + g.skipped, 0)
}
get time(): number {
return this.totalTime ?? this.groups.reduce((sum, g) => sum + g.time, 0)
}
@ -67,6 +75,10 @@ export class TestSuiteResult {
return this.groups.filter(grp => grp.result === 'failed')
}
get depth(): number {
return this.indentationDepth
}
sort(deep: boolean): void {
this.groups.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', DEFAULT_LOCALE))
if (deep) {