mirror of
https://github.com/dorny/test-reporter.git
synced 2025-12-15 22:07:09 +01:00
Merge branch 'main' into mocha-json
This commit is contained in:
commit
3768e4e756
30 changed files with 14928 additions and 558 deletions
|
|
@ -12,6 +12,7 @@ import {getReport} from './report/get-report'
|
|||
|
||||
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 {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser'
|
||||
|
||||
|
|
@ -185,6 +186,8 @@ class TestReporter {
|
|||
return new DotnetTrxParser(options)
|
||||
case 'flutter-json':
|
||||
return new DartJsonParser(options, 'flutter')
|
||||
case 'java-junit':
|
||||
return new JavaJunitParser(options)
|
||||
case 'jest-junit':
|
||||
return new JestJunitParser(options)
|
||||
case 'mocha-json':
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ export class DotnetTrxParser implements TestParser {
|
|||
}
|
||||
|
||||
private getTestClasses(trx: TrxReport): TestClass[] {
|
||||
if (trx.TestRun.TestDefinitions === undefined || trx.TestRun.Results === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
const unitTests: {[id: string]: TestMethod} = {}
|
||||
for (const td of trx.TestRun.TestDefinitions) {
|
||||
for (const ut of td.UnitTest) {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ export interface TrxReport {
|
|||
|
||||
export interface TestRun {
|
||||
Times: Times[]
|
||||
Results: Results[]
|
||||
TestDefinitions: TestDefinitions[]
|
||||
Results?: Results[]
|
||||
TestDefinitions?: TestDefinitions[]
|
||||
}
|
||||
|
||||
export interface Times {
|
||||
|
|
|
|||
199
src/parsers/java-junit/java-junit-parser.ts
Normal file
199
src/parsers/java-junit/java-junit-parser.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import * as path from 'path'
|
||||
import {ParseOptions, TestParser} from '../../test-parser'
|
||||
import {parseStringPromise} from 'xml2js'
|
||||
|
||||
import {JunitReport, SingleSuiteReport, TestCase, TestSuite} from './java-junit-types'
|
||||
import {normalizeFilePath} from '../../utils/path-utils'
|
||||
|
||||
import {
|
||||
TestExecutionResult,
|
||||
TestRunResult,
|
||||
TestSuiteResult,
|
||||
TestGroupResult,
|
||||
TestCaseResult,
|
||||
TestCaseError
|
||||
} from '../../test-results'
|
||||
|
||||
export class JavaJunitParser implements TestParser {
|
||||
readonly trackedFiles: {[fileName: string]: string[]}
|
||||
|
||||
constructor(readonly options: ParseOptions) {
|
||||
// Map to efficient lookup of all paths with given file name
|
||||
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.getJunitReport(filePath, content)
|
||||
const isReport = (reportOrSuite as JunitReport).testsuites !== undefined
|
||||
|
||||
// XML might contain:
|
||||
// - multiple suites under <testsuites> root node
|
||||
// - single <testsuite> as root node
|
||||
let ju: JunitReport
|
||||
if (isReport) {
|
||||
ju = reportOrSuite as JunitReport
|
||||
} else {
|
||||
// Make it behave the same way as if suite was inside <testsuites> root node
|
||||
const suite = (reportOrSuite as SingleSuiteReport).testsuite
|
||||
ju = {
|
||||
testsuites: {
|
||||
$: {time: suite.$.time},
|
||||
testsuite: [suite]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.getTestRunResult(filePath, ju)
|
||||
}
|
||||
|
||||
private async getJunitReport(filePath: string, content: string): Promise<JunitReport | SingleSuiteReport> {
|
||||
try {
|
||||
return await parseStringPromise(content)
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid XML at ${filePath}\n\n${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getTestRunResult(filePath: string, junit: JunitReport): TestRunResult {
|
||||
const suites =
|
||||
junit.testsuites.testsuite === undefined
|
||||
? []
|
||||
: junit.testsuites.testsuite.map(ts => {
|
||||
const name = ts.$.name.trim()
|
||||
const time = parseFloat(ts.$.time) * 1000
|
||||
const sr = new TestSuiteResult(name, this.getGroups(ts), time)
|
||||
return sr
|
||||
})
|
||||
|
||||
const time = parseFloat(junit.testsuites.$.time) * 1000
|
||||
return new TestRunResult(filePath, suites, time)
|
||||
}
|
||||
|
||||
private getGroups(suite: TestSuite): TestGroupResult[] {
|
||||
if (suite.testcase === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
const groups: {name: string; tests: TestCase[]}[] = []
|
||||
for (const tc of suite.testcase) {
|
||||
// Normally classname is same as suite name - both refer to same Java class
|
||||
// Therefore it doesn't make sense to process it as a group
|
||||
// and tests will be added to default group with empty name
|
||||
const className = tc.$.classname === suite.$.name ? '' : tc.$.classname
|
||||
let grp = groups.find(g => g.name === className)
|
||||
if (grp === undefined) {
|
||||
grp = {name: 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.name, 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 = failure._
|
||||
let filePath
|
||||
let line
|
||||
|
||||
const src = this.exceptionThrowSource(details)
|
||||
if (src) {
|
||||
filePath = src.filePath
|
||||
line = src.line
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
line,
|
||||
details,
|
||||
message: failure.message
|
||||
}
|
||||
}
|
||||
|
||||
private exceptionThrowSource(stackTrace: string): {filePath: string; line: number} | undefined {
|
||||
const lines = stackTrace.split(/\r?\n/)
|
||||
const re = /^at (.*)\((.*):(\d+)\)$/
|
||||
|
||||
for (const str of lines) {
|
||||
const match = str.match(re)
|
||||
if (match !== null) {
|
||||
const [_, tracePath, fileName, lineStr] = match
|
||||
const filePath = this.getFilePath(tracePath, fileName)
|
||||
if (filePath !== undefined) {
|
||||
const line = parseInt(lineStr)
|
||||
return {filePath, line}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stacktrace in Java doesn't contain full paths to source file.
|
||||
// There are only package, file name and line.
|
||||
// Assuming folder structure matches package name (as it should in Java),
|
||||
// we can try to match tracked file.
|
||||
private getFilePath(tracePath: string, fileName: string): string | undefined {
|
||||
// Check if there is any tracked file with given name
|
||||
const files = this.trackedFiles[fileName]
|
||||
if (files === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Remove class name and method name from trace.
|
||||
// Take parts until first item with capital letter - package names are lowercase while class name is CamelCase.
|
||||
const packageParts = tracePath.split(/\./g)
|
||||
const packageIndex = packageParts.findIndex(part => part[0] <= 'Z')
|
||||
if (packageIndex !== -1) {
|
||||
packageParts.splice(packageIndex, packageParts.length - packageIndex)
|
||||
}
|
||||
|
||||
if (packageParts.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Get right file
|
||||
// - file name matches
|
||||
// - parent folders structure must reflect the package name
|
||||
for (const filePath of files) {
|
||||
const dirs = path.dirname(filePath).split(/\//g)
|
||||
if (packageParts.length > dirs.length) {
|
||||
continue
|
||||
}
|
||||
// get only N parent folders, where N = length of package name parts
|
||||
if (dirs.length > packageParts.length) {
|
||||
dirs.splice(0, dirs.length - packageParts.length)
|
||||
}
|
||||
// check if parent folder structure matches package name
|
||||
const isMatch = packageParts.every((part, i) => part === dirs[i])
|
||||
if (isMatch) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
44
src/parsers/java-junit/java-junit-types.ts
Normal file
44
src/parsers/java-junit/java-junit-types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface JunitReport {
|
||||
testsuites: TestSuites
|
||||
}
|
||||
|
||||
export interface SingleSuiteReport {
|
||||
testsuite: TestSuite
|
||||
}
|
||||
|
||||
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?: Failure[]
|
||||
skipped?: string[]
|
||||
}
|
||||
|
||||
export interface Failure {
|
||||
_: string
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
|
|
@ -33,12 +33,15 @@ export class JestJunitParser implements TestParser {
|
|||
}
|
||||
|
||||
private getTestRunResult(path: string, junit: JunitReport): TestRunResult {
|
||||
const suites = junit.testsuites.testsuite.map(ts => {
|
||||
const name = ts.$.name.trim()
|
||||
const time = parseFloat(ts.$.time) * 1000
|
||||
const sr = new TestSuiteResult(name, this.getGroups(ts), time)
|
||||
return sr
|
||||
})
|
||||
const suites =
|
||||
junit.testsuites.testsuite === undefined
|
||||
? []
|
||||
: junit.testsuites.testsuite.map(ts => {
|
||||
const name = ts.$.name.trim()
|
||||
const time = parseFloat(ts.$.time) * 1000
|
||||
const sr = new TestSuiteResult(name, this.getGroups(ts), time)
|
||||
return sr
|
||||
})
|
||||
|
||||
const time = parseFloat(junit.testsuites.$.time) * 1000
|
||||
return new TestRunResult(path, suites, time)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@ export interface JunitReport {
|
|||
|
||||
export interface TestSuites {
|
||||
$: {
|
||||
name: string
|
||||
tests: string
|
||||
failures: string // assertion failed
|
||||
errors: string // unhandled exception during test execution
|
||||
time: string
|
||||
}
|
||||
testsuite: TestSuite[]
|
||||
testsuite?: TestSuite[]
|
||||
}
|
||||
|
||||
export interface TestSuite {
|
||||
|
|
|
|||
|
|
@ -148,7 +148,10 @@ function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOpt
|
|||
sections.push(`## ${nameLink} ${icon}`)
|
||||
|
||||
const time = formatTime(tr.time)
|
||||
const headingLine2 = `**${tr.tests}** tests were completed in **${time}** with **${tr.passed}** passed, **${tr.failed}** failed and **${tr.skipped}** skipped.`
|
||||
const headingLine2 =
|
||||
tr.tests > 0
|
||||
? `**${tr.tests}** tests were completed in **${time}** with **${tr.passed}** passed, **${tr.failed}** failed and **${tr.skipped}** skipped.`
|
||||
: 'No tests found'
|
||||
sections.push(headingLine2)
|
||||
|
||||
const suites = options.listSuites === 'failed' ? tr.failedSuites : tr.suites
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ export function getCheckRunContext(): {sha: string; runId: number} {
|
|||
if (!event.workflow_run) {
|
||||
throw new Error("Event of type 'workflow_run' is missing 'workflow_run' field")
|
||||
}
|
||||
if (event.workflow_run.conclusion === 'cancelled') {
|
||||
throw new Error(`Workflow run ${event.workflow_run.id} has been cancelled`)
|
||||
}
|
||||
return {
|
||||
sha: event.workflow_run.head_commit.id,
|
||||
runId: event.workflow_run.id
|
||||
|
|
@ -87,29 +84,45 @@ export async function downloadArtifact(
|
|||
}
|
||||
|
||||
export async function listFiles(octokit: InstanceType<typeof GitHub>, sha: string): Promise<string[]> {
|
||||
core.info('Fetching list of tracked files from GitHub')
|
||||
const commit = await octokit.git.getCommit({
|
||||
commit_sha: sha,
|
||||
...github.context.repo
|
||||
})
|
||||
const files = await listGitTree(octokit, commit.data.tree.sha, '')
|
||||
return files
|
||||
core.startGroup('Fetching list of tracked files from GitHub')
|
||||
try {
|
||||
const commit = await octokit.git.getCommit({
|
||||
commit_sha: sha,
|
||||
...github.context.repo
|
||||
})
|
||||
const files = await listGitTree(octokit, commit.data.tree.sha, '')
|
||||
return files
|
||||
} finally {
|
||||
core.endGroup()
|
||||
}
|
||||
}
|
||||
|
||||
async function listGitTree(octokit: InstanceType<typeof GitHub>, sha: string, path: string): Promise<string[]> {
|
||||
const tree = await octokit.git.getTree({
|
||||
const pathLog = path ? ` at ${path}` : ''
|
||||
core.info(`Fetching tree ${sha}${pathLog}`)
|
||||
let truncated = false
|
||||
let tree = await octokit.git.getTree({
|
||||
recursive: 'true',
|
||||
tree_sha: sha,
|
||||
...github.context.repo
|
||||
})
|
||||
|
||||
if (tree.data.truncated) {
|
||||
truncated = true
|
||||
tree = await octokit.git.getTree({
|
||||
tree_sha: sha,
|
||||
...github.context.repo
|
||||
})
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
for (const tr of tree.data.tree) {
|
||||
const file = `${path}${tr.path}`
|
||||
if (tr.type === 'tree') {
|
||||
if (tr.type === 'blob') {
|
||||
result.push(file)
|
||||
} else if (tr.type === 'tree' && truncated) {
|
||||
const files = await listGitTree(octokit, tr.sha, `${file}/`)
|
||||
result.push(...files)
|
||||
} else {
|
||||
result.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue