1
0
Fork 0
mirror of https://github.com/dorny/test-reporter.git synced 2026-02-04 05:27:55 +01:00
test-reporter/src/report/get-report.ts
Rick Shanor fb2dd2ba55 feat: Add listTestCaseTime flag to print test times next to test names
- Add a new listTestCaseTime flag, that optionally prints test times
next to test names. This flag defaults to false, for backward
compatibility.
- Update get-report.ts to use this flag when generating a report.
- Update existing tests to set ReportOptions listTestCaseTime: true to
verify above feature.
- Update README with documentation about this new flag.

Note this feature was needed for individual test times under pytest,
since the xml is generated with all tests under one test suite.

https://github.com/dorny/test-reporter/issues/260
2026-01-06 07:39:38 -08:00

328 lines
10 KiB
TypeScript

import * as core from '@actions/core'
import {TestExecutionResult, TestRunResult, TestSuiteResult} from '../test-results'
import {Align, formatTime, Icon, link, table} from '../utils/markdown-utils'
import {DEFAULT_LOCALE} from '../utils/node-utils'
import {getFirstNonEmptyLine} from '../utils/parse-utils'
import {slug} from '../utils/slugger'
const MAX_REPORT_LENGTH = 65535
const MAX_ACTIONS_SUMMARY_LENGTH = 1048576
export interface ReportOptions {
listSuites: 'all' | 'failed' | 'none'
listTests: 'all' | 'failed' | 'none'
listTestCaseTime: boolean
baseUrl: string
onlySummary: boolean
useActionsSummary: boolean
badgeTitle: string
reportTitle: string
collapsed: 'auto' | 'always' | 'never'
}
export const DEFAULT_OPTIONS: ReportOptions = {
listSuites: 'all',
listTests: 'all',
listTestCaseTime: false,
baseUrl: '',
onlySummary: false,
useActionsSummary: true,
badgeTitle: 'tests',
reportTitle: '',
collapsed: 'auto'
}
export function getReport(
results: TestRunResult[],
options: ReportOptions = DEFAULT_OPTIONS,
shortSummary = ''
): string {
applySort(results)
const opts = {...options}
let lines = renderReport(results, opts, shortSummary)
let report = lines.join('\n')
if (getByteLength(report) <= getMaxReportLength(options)) {
return report
}
if (opts.listTests === 'all') {
core.info("Test report summary is too big - setting 'listTests' to 'failed'")
opts.listTests = 'failed'
lines = renderReport(results, opts, shortSummary)
report = lines.join('\n')
if (getByteLength(report) <= getMaxReportLength(options)) {
return report
}
}
core.warning(`Test report summary exceeded limit of ${getMaxReportLength(options)} bytes and will be trimmed`)
return trimReport(lines, options)
}
function getMaxReportLength(options: ReportOptions = DEFAULT_OPTIONS): number {
return options.useActionsSummary ? MAX_ACTIONS_SUMMARY_LENGTH : MAX_REPORT_LENGTH
}
function trimReport(lines: string[], options: ReportOptions): string {
const closingBlock = '```'
const errorMsg = `**Report exceeded GitHub limit of ${getMaxReportLength(options)} bytes and has been trimmed**`
const maxErrorMsgLength = closingBlock.length + errorMsg.length + 2
const maxReportLength = getMaxReportLength(options) - maxErrorMsgLength
let reportLength = 0
let codeBlock = false
let endLineIndex = 0
for (endLineIndex = 0; endLineIndex < lines.length; endLineIndex++) {
const line = lines[endLineIndex]
const lineLength = getByteLength(line)
reportLength += lineLength + 1
if (reportLength > maxReportLength) {
break
}
if (line === '```') {
codeBlock = !codeBlock
}
}
const reportLines = lines.slice(0, endLineIndex)
if (codeBlock) {
reportLines.push('```')
}
reportLines.push(errorMsg)
return reportLines.join('\n')
}
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 {
return Buffer.byteLength(text, 'utf8')
}
function renderReport(results: TestRunResult[], options: ReportOptions, shortSummary: string): string[] {
const sections: string[] = []
const reportTitle: string = options.reportTitle.trim()
if (reportTitle) {
sections.push(`# ${reportTitle}`)
}
if (shortSummary) {
sections.push(`## ${shortSummary}`)
}
const badge = getReportBadge(results, options)
sections.push(badge)
const runs = getTestRunsReport(results, options)
sections.push(...runs)
return sections
}
function getReportBadge(results: TestRunResult[], options: ReportOptions): string {
const passed = results.reduce((sum, tr) => sum + tr.passed, 0)
const skipped = results.reduce((sum, tr) => sum + tr.skipped, 0)
const failed = results.reduce((sum, tr) => sum + tr.failed, 0)
return getBadge(passed, failed, skipped, options)
}
export function getBadge(passed: number, failed: number, skipped: number, options: ReportOptions): string {
const text = []
if (passed > 0) {
text.push(`${passed} passed`)
}
if (failed > 0) {
text.push(`${failed} failed`)
}
if (skipped > 0) {
text.push(`${skipped} skipped`)
}
const message = text.length > 0 ? text.join(', ') : 'none'
let color = 'success'
if (failed > 0) {
color = 'critical'
} else if (passed === 0 && failed === 0) {
color = 'yellow'
}
const hint = failed > 0 ? 'Tests failed' : 'Tests passed successfully'
const encodedBadgeTitle = encodeImgShieldsURIComponent(options.badgeTitle)
const encodedMessage = encodeImgShieldsURIComponent(message)
const encodedColor = encodeImgShieldsURIComponent(color)
return `![${hint}](https://img.shields.io/badge/${encodedBadgeTitle}-${encodedMessage}-${encodedColor})`
}
function getTestRunsReport(testRuns: TestRunResult[], options: ReportOptions): string[] {
const sections: string[] = []
const totalFailed = testRuns.reduce((sum, tr) => sum + tr.failed, 0)
// Determine if report should be collapsed based on collapsed option
const shouldCollapse = options.collapsed === 'always' || (options.collapsed === 'auto' && totalFailed === 0)
if (shouldCollapse) {
sections.push(`<details><summary>Expand for details</summary>`)
sections.push(` `)
}
if (testRuns.length > 0 || options.onlySummary) {
const tableData = testRuns
.map((tr, originalIndex) => ({tr, originalIndex}))
.filter(({tr}) => tr.passed > 0 || tr.failed > 0 || tr.skipped > 0)
.map(({tr, originalIndex}) => {
const time = formatTime(tr.time)
const name = tr.path
const addr = options.baseUrl + makeRunSlug(originalIndex, options).link
const nameLink = link(name, addr)
const passed = tr.passed > 0 ? `${tr.passed} ${Icon.success}` : ''
const failed = tr.failed > 0 ? `${tr.failed} ${Icon.fail}` : ''
const skipped = tr.skipped > 0 ? `${tr.skipped} ${Icon.skip}` : ''
return [nameLink, passed, failed, skipped, time]
})
const resultsTable = table(
['Report', 'Passed', 'Failed', 'Skipped', 'Time'],
[Align.Left, Align.Right, Align.Right, Align.Right, Align.Right],
...tableData
)
sections.push(resultsTable)
}
if (options.onlySummary === false) {
const suitesReports = testRuns.map((tr, i) => getSuitesReport(tr, i, options)).flat()
sections.push(...suitesReports)
}
if (shouldCollapse) {
sections.push(`</details>`)
}
return sections
}
function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOptions): string[] {
const sections: string[] = []
const suites = options.listSuites === 'failed' ? tr.failedSuites : tr.suites
if (options.listSuites !== 'none') {
const trSlug = makeRunSlug(runIndex, options)
const nameLink = `<a id="${trSlug.id}" href="${options.baseUrl + trSlug.link}">${tr.path}</a>`
const icon = getResultIcon(tr.result)
sections.push(`## ${icon}\xa0${nameLink}`)
const time = formatTime(tr.time)
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)
if (suites.length > 0) {
const suitesTable = table(
['Test suite', 'Passed', 'Failed', 'Skipped', 'Time'],
[Align.Left, Align.Right, Align.Right, Align.Right, Align.Right],
...suites.map((s, suiteIndex) => {
const tsTime = formatTime(s.time)
const tsName = s.name
const skipLink = options.listTests === 'none' || (options.listTests === 'failed' && s.result !== 'failed')
const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex, options).link
const tsNameLink = skipLink ? tsName : link(tsName, tsAddr)
const passed = s.passed > 0 ? `${s.passed} ${Icon.success}` : ''
const failed = s.failed > 0 ? `${s.failed} ${Icon.fail}` : ''
const skipped = s.skipped > 0 ? `${s.skipped} ${Icon.skip}` : ''
return [tsNameLink, passed, failed, skipped, tsTime]
})
)
sections.push(suitesTable)
}
}
if (options.listTests !== 'none') {
const tests = suites.map((ts, suiteIndex) => getTestsReport(ts, runIndex, suiteIndex, options)).flat()
if (tests.length > 1) {
sections.push(...tests)
}
}
return sections
}
function getTestsReport(ts: TestSuiteResult, runIndex: number, suiteIndex: number, options: ReportOptions): string[] {
if (options.listTests === 'failed' && ts.result !== 'failed') {
return []
}
const groups = ts.groups
if (groups.length === 0) {
return []
}
const sections: string[] = []
const tsName = ts.name
const tsSlug = makeSuiteSlug(runIndex, suiteIndex, options)
const tsNameLink = `<a id="${tsSlug.id}" href="${options.baseUrl + tsSlug.link}">${tsName}</a>`
const icon = getResultIcon(ts.result)
sections.push(`### ${icon}\xa0${tsNameLink}`)
sections.push('```')
for (const grp of groups) {
if (grp.name) {
sections.push(grp.name)
}
const space = grp.name ? ' ' : ''
for (const tc of grp.tests) {
if (options.listTests === 'failed' && tc.result !== 'failed') {
continue
}
const result = getResultIcon(tc.result)
const time = options.listTestCaseTime && tc.time ? ` (${formatTime(tc.time)})` : ''
sections.push(`${space}${result} ${tc.name}${time}`)
if (tc.error) {
const lines = (tc.error.message ?? getFirstNonEmptyLine(tc.error.details)?.trim())
?.split(/\r?\n/g)
.map(l => '\t' + l)
if (lines) {
sections.push(...lines)
}
}
}
}
sections.push('```')
return sections
}
function makeRunSlug(runIndex: number, options: ReportOptions): {id: string; link: string} {
// use prefix to avoid slug conflicts after escaping the paths
return slug(`r${runIndex}`, options)
}
function makeSuiteSlug(runIndex: number, suiteIndex: number, options: ReportOptions): {id: string; link: string} {
// use prefix to avoid slug conflicts after escaping the paths
return slug(`r${runIndex}s${suiteIndex}`, options)
}
function getResultIcon(result: TestExecutionResult): string {
switch (result) {
case 'success':
return Icon.success
case 'skipped':
return Icon.skip
case 'failed':
return Icon.fail
default:
return ''
}
}
function encodeImgShieldsURIComponent(component: string): string {
return encodeURIComponent(component).replace(/-/g, '--').replace(/_/g, '__')
}