blob: 010ddd2e951d0de6126bbbd7f6084f8ee56c31f5 [file] [log] [blame]
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import * as fs from 'fs'
import * as parser from 'xml-js'
import * as pathHelper from 'path'
import {applyTransformer} from './utils'
interface InternalTestResult {
totalCount: number
skipped: number
annotations: Annotation[]
}
export interface TestResult {
checkName: string
summary: string
totalCount: number
skipped: number
failed: number
passed: number
annotations: Annotation[]
}
export interface Annotation {
path: string
start_line: number
end_line: number
start_column: number
end_column: number
annotation_level: 'failure' | 'notice' | 'warning'
title: string
message: string
raw_details: string
}
export interface Position {
fileName: string
line: number
}
export interface Transformer {
searchValue: string
replaceValue: string
}
/**
* Copyright 2020 ScaCap
* https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L6
*
* Modification Copyright 2022 Mike Penz
* https://github.com/mikepenz/action-junit-report/
*/
export async function resolveFileAndLine(
file: string | null,
line: string | null,
className: string,
output: string
): Promise<Position> {
let fileName = file ? file : className.split('.').slice(-1)[0]
const lineNumber = safeParseInt(line)
try {
if (fileName && lineNumber) {
return {fileName, line: lineNumber}
}
const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('::', '/') // Rust test output contains colons between package names - See: https://github.com/mikepenz/action-junit-report/pull/359
const matches = output.match(new RegExp(` [^ ]*${escapedFileName}.*?:\\d+`, 'g'))
if (!matches) return {fileName, line: lineNumber || 1}
const [lastItem] = matches.slice(-1)
const lineTokens = lastItem.split(':')
line = lineTokens.pop() || '0'
// check, if the error message is from a rust file -- this way we have the chance to find
// out the involved test file
// See: https://github.com/mikepenz/action-junit-report/pull/360
{
const lineNumberPrefix = lineTokens.pop() || ''
if (lineNumberPrefix.endsWith('.rs')) {
fileName = lineNumberPrefix.split(' ').pop() || ''
}
}
core.debug(`Resolved file ${fileName} and line ${line}`)
return {fileName, line: safeParseInt(line) || -1}
} catch (error) {
core.warning(`⚠️ Failed to resolve file (${file}) and/or line (${line}) for ${className}`)
return {fileName, line: safeParseInt(line) || -1}
}
}
/**
* Parse the provided string line number, and return its value, or null if it is not available or NaN.
*/
function safeParseInt(line: string | null): number | null {
if (!line) return null
const parsed = parseInt(line)
if (isNaN(parsed)) return null
return parsed
}
/**
* Copyright 2020 ScaCap
* https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L18
*
* Modification Copyright 2022 Mike Penz
* https://github.com/mikepenz/action-junit-report/
*/
export async function resolvePath(fileName: string, excludeSources: string[]): Promise<string> {
core.debug(`Resolving path for ${fileName}`)
const normalizedFilename = fileName.replace(/^\.\//, '') // strip relative prefix (./)
const globber = await glob.create(`**/${normalizedFilename}.*`, {
followSymbolicLinks: false
})
const searchPath = globber.getSearchPaths() ? globber.getSearchPaths()[0] : ''
for await (const result of globber.globGenerator()) {
core.debug(`Matched file: ${result}`)
const found = excludeSources.find(v => result.includes(v))
if (!found) {
const path = result.slice(searchPath.length + 1)
core.debug(`Resolved path: ${path}`)
return path
}
}
return normalizedFilename
}
/**
* Copyright 2020 ScaCap
* https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L43
*
* Modification Copyright 2022 Mike Penz
* https://github.com/mikepenz/action-junit-report/
*/
export async function parseFile(
file: string,
suiteRegex = '',
includePassed = false,
checkRetries = false,
excludeSources: string[] = ['/build/', '/__pycache__/'],
checkTitleTemplate: string | undefined = undefined,
testFilesPrefix = '',
transformer: Transformer[] = []
): Promise<InternalTestResult> {
core.debug(`Parsing file ${file}`)
const data: string = fs.readFileSync(file, 'utf8')
const report = JSON.parse(parser.xml2json(data, {compact: true}))
return parseSuite(
report,
'',
suiteRegex,
includePassed,
checkRetries,
excludeSources,
checkTitleTemplate,
testFilesPrefix,
transformer
)
}
function templateVar(varName: string): string {
return `{{${varName}}}`
}
async function parseSuite(
/* eslint-disable @typescript-eslint/no-explicit-any */
suite: any,
parentName: string,
suiteRegex: string,
includePassed = false,
checkRetries = false,
excludeSources: string[],
checkTitleTemplate: string | undefined = undefined,
testFilesPrefix = '',
transformer: Transformer[]
): Promise<InternalTestResult> {
let totalCount = 0
let skipped = 0
const annotations: Annotation[] = []
if (!suite.testsuite && !suite.testsuites) {
return {totalCount, skipped, annotations}
}
const testsuites = suite.testsuite
? Array.isArray(suite.testsuite)
? suite.testsuite
: [suite.testsuite]
: Array.isArray(suite.testsuites.testsuite)
? suite.testsuites.testsuite
: [suite.testsuites.testsuite]
for (const testsuite of testsuites) {
if (!testsuite) {
return {totalCount, skipped, annotations}
}
let suiteName = ''
if (suiteRegex) {
if (parentName) {
suiteName = `${parentName}/${testsuite._attributes.name}`
} else if (suiteRegex !== '*') {
suiteName = testsuite._attributes.name.match(suiteRegex)
}
if (!suiteName) {
suiteName = testsuite._attributes.name
}
}
const res = await parseSuite(
testsuite,
suiteName,
suiteRegex,
includePassed,
checkRetries,
excludeSources,
checkTitleTemplate,
testFilesPrefix,
transformer
)
totalCount += res.totalCount
skipped += res.skipped
annotations.push(...res.annotations)
if (!testsuite.testcase) {
continue
}
let testcases = Array.isArray(testsuite.testcase)
? testsuite.testcase
: testsuite.testcase
? [testsuite.testcase]
: []
if (checkRetries) {
// identify duplicates, in case of flaky tests, and remove them
const testcaseMap = new Map<string, any>()
for (const testcase of testcases) {
const key = testcase._attributes.name
if (testcaseMap.get(key) !== undefined) {
// testcase with matching name exists
const failed = testcase.failure || testcase.error
const previousFailed = testcaseMap.get(key).failure || testcaseMap.get(key).error
if (failed && !previousFailed) {
// previous is a success, drop failure
core.debug(`Drop flaky test failure for (1): ${key}`)
} else if (!failed && previousFailed) {
// previous failed, new one not, replace
testcaseMap.set(key, testcase)
core.debug(`Drop flaky test failure for (2): ${key}`)
}
} else {
testcaseMap.set(key, testcase)
}
}
testcases = Array.from(testcaseMap.values())
}
for (const testcase of testcases) {
totalCount++
const failed = testcase.failure || testcase.error
const success = !failed
if (testcase.skipped || testcase._attributes.status === 'disabled') {
skipped++
}
if (failed || (includePassed && success)) {
const stackTrace: string = (
(testcase.failure && testcase.failure._cdata) ||
(testcase.failure && testcase.failure._text) ||
(testcase.error && testcase.error._cdata) ||
(testcase.error && testcase.error._text) ||
''
)
.toString()
.trim()
const message: string = (
(testcase.failure && testcase.failure._attributes && testcase.failure._attributes.message) ||
(testcase.error && testcase.error._attributes && testcase.error._attributes.message) ||
stackTrace.split('\n').slice(0, 2).join('\n') ||
testcase._attributes.name
).trim()
const pos = await resolveFileAndLine(
testcase._attributes.file || testsuite._attributes.file,
testcase._attributes.line || testsuite._attributes.line,
testcase._attributes.classname ? testcase._attributes.classname : testcase._attributes.name,
stackTrace
)
let transformedFileName = pos.fileName
for (const r of transformer) {
transformedFileName = applyTransformer(r, transformedFileName)
}
let resolvedPath = await resolvePath(transformedFileName, excludeSources)
core.debug(`Path prior to stripping: ${resolvedPath}`)
const githubWorkspacePath = process.env['GITHUB_WORKSPACE']
if (githubWorkspacePath) {
resolvedPath = resolvedPath.replace(`${githubWorkspacePath}/`, '') // strip workspace prefix, make the path relative
}
let title = ''
if (checkTitleTemplate) {
// ensure to not duplicate the test_name if file_name is equal
const fileName = pos.fileName !== testcase._attributes.name ? pos.fileName : ''
title = checkTitleTemplate
.replace(templateVar('FILE_NAME'), fileName)
.replace(templateVar('SUITE_NAME'), suiteName ?? '')
.replace(templateVar('TEST_NAME'), testcase._attributes.name)
} else if (pos.fileName !== testcase._attributes.name) {
title = suiteName
? `${pos.fileName}.${suiteName}/${testcase._attributes.name}`
: `${pos.fileName}.${testcase._attributes.name}`
} else {
title = suiteName ? `${suiteName}/${testcase._attributes.name}` : `${testcase._attributes.name}`
}
// optionally attach the prefix to the path
resolvedPath = testFilesPrefix ? pathHelper.join(testFilesPrefix, resolvedPath) : resolvedPath
core.info(`${resolvedPath}:${pos.line} | ${message.replace(/\n/g, ' ')}`)
annotations.push({
path: resolvedPath,
start_line: pos.line,
end_line: pos.line,
start_column: 0,
end_column: 0,
annotation_level: success ? 'notice' : 'failure',
title: escapeEmoji(title),
message: escapeEmoji(message),
raw_details: escapeEmoji(stackTrace)
})
}
}
}
return {totalCount, skipped, annotations}
}
/**
* Copyright 2020 ScaCap
* https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L113
*
* Modification Copyright 2022 Mike Penz
* https://github.com/mikepenz/action-junit-report/
*/
export async function parseTestReports(
checkName: string,
summary: string,
reportPaths: string,
suiteRegex: string,
includePassed = false,
checkRetries = false,
excludeSources: string[],
checkTitleTemplate: string | undefined = undefined,
testFilesPrefix = '',
transformer: Transformer[]
): Promise<TestResult> {
core.debug(`Process test report for: ${reportPaths} (${checkName})`)
const globber = await glob.create(reportPaths, {followSymbolicLinks: false})
let annotations: Annotation[] = []
let totalCount = 0
let skipped = 0
for await (const file of globber.globGenerator()) {
core.debug(`Parsing report file: ${file}`)
const {
totalCount: c,
skipped: s,
annotations: a
} = await parseFile(
file,
suiteRegex,
includePassed,
checkRetries,
excludeSources,
checkTitleTemplate,
testFilesPrefix,
transformer
)
if (c === 0) continue
totalCount += c
skipped += s
annotations = annotations.concat(a)
}
// get the count of passed and failed tests.
const failed = annotations.filter(a => a.annotation_level === 'failure').length
const passed = totalCount - failed - skipped
return {
checkName,
summary,
totalCount,
skipped,
failed,
passed,
annotations
}
}
/**
* Escape emoji sequences.
*/
export function escapeEmoji(input: string): string {
const regex =
/[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/gu
return input.replace(regex, ``) // replace emoji with empty string (\\u${(match.codePointAt(0) || "").toString(16)})
}