diff --git a/src/parsers/dart-json/dart-json-parser.ts b/src/parsers/dart-json/dart-json-parser.ts index 90452be..5374651 100644 --- a/src/parsers/dart-json/dart-json-parser.ts +++ b/src/parsers/dart-json/dart-json-parser.ts @@ -1,8 +1,8 @@ import {Annotation, ParseOptions, TestResult} from '../parser-types' +import getReport from '../../report/get-report' import {normalizeFilePath} from '../../utils/file-utils' -import {Align, Icon, link, table} from '../../utils/markdown-utils' -import {slug} from '../../utils/slugger' +import {Icon} from '../../utils/markdown-utils' import { ReportEvent, @@ -19,60 +19,26 @@ import { isDoneEvent } from './dart-json-types' +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult +} from '../../report/test-results' + class TestRun { constructor(readonly suites: TestSuite[], readonly success: boolean, readonly time: number) {} - get count(): number { - return Object.values(this.suites).reduce((sum, g) => sum + g.count, 0) - } - get passed(): number { - return Object.values(this.suites).reduce((sum, g) => sum + g.passed, 0) - } - get failed(): number { - return Object.values(this.suites).reduce((sum, g) => sum + g.failed, 0) - } - get skipped(): number { - return Object.values(this.suites).reduce((sum, g) => sum + g.skipped, 0) - } } class TestSuite { constructor(readonly suite: Suite) {} - groups: {[id: number]: TestGroup} = {} - get count(): number { - return Object.values(this.groups).reduce((sum, g) => sum + g.count, 0) - } - get passed(): number { - return Object.values(this.groups).reduce((sum, g) => sum + g.passed, 0) - } - get failed(): number { - return Object.values(this.groups).reduce((sum, g) => sum + g.failed, 0) - } - get skipped(): number { - return Object.values(this.groups).reduce((sum, g) => sum + g.skipped, 0) - } - get time(): number { - return Object.values(this.groups).reduce((sum, g) => sum + g.time, 0) - } + readonly groups: {[id: number]: TestGroup} = {} } class TestGroup { constructor(readonly group: Group) {} - tests: TestCase[] = [] - get count(): number { - return this.tests.length - } - get passed(): number { - return this.tests.reduce((sum, t) => (t.isPassed ? sum + 1 : sum), 0) - } - get failed(): number { - return this.tests.reduce((sum, t) => (t.isFailed ? sum + 1 : sum), 0) - } - get skipped(): number { - return this.tests.reduce((sum, t) => (t.isSkipped ? sum + 1 : sum), 0) - } - get time(): number { - return this.tests.reduce((sum, t) => sum + t.time, 0) - } + readonly tests: TestCase[] = [] } class TestCase { @@ -82,15 +48,21 @@ class TestCase { readonly groupId: number testDone?: TestDoneEvent error?: ErrorEvent - get isPassed(): boolean { - return this.testDone?.result === 'success' && !this.testDone?.skipped - } - get isFailed(): boolean { - return this.testDone?.result !== 'success' - } - get isSkipped(): boolean { - return this.testDone?.skipped === true + get result(): TestExecutionResult { + if (this.testDone?.skipped) { + return 'skipped' + } + if (this.testDone?.result === 'success') { + return 'success' + } + + if (this.testDone?.result === 'error' || this.testDone?.result === 'failure') { + return 'failed' + } + + return undefined } + get time(): number { return this.testDone !== undefined ? this.testDone.time - this.testStart.time : 0 } @@ -104,7 +76,7 @@ export async function parseDartJson(content: string, options: ParseOptions): Pro success: testRun.success, output: { title: `${options.name.trim()} ${icon}`, - summary: getSummary(testRun), + summary: getReport(getTestRunResult(testRun)), annotations: options.annotations ? getAnnotations(testRun, options.workDir, options.trackedFiles) : undefined } } @@ -143,72 +115,23 @@ function getTestRun(content: string): TestRun { return new TestRun(Object.values(suites), success, totalTime) } -function getSummary(tr: TestRun): string { - const time = `${(tr.time / 1000).toFixed(3)}s` - const headingLine = `**${tr.count}** tests were completed in **${time}** with **${tr.passed}** passed, **${tr.skipped}** skipped and **${tr.failed}** failed.` - - const suitesSummary = tr.suites.map((s, i) => { - const icon = s.failed === 0 ? Icon.success : Icon.fail - const tsTime = `${s.time}ms` - const tsName = s.suite.path - const tsAddr = makeSuiteSlug(i, tsName).link - const tsNameLink = link(tsName, tsAddr) - return [icon, tsNameLink, s.count, tsTime, s.passed, s.failed, s.skipped] +function getTestRunResult(tr: TestRun): TestRunResult { + const suites = tr.suites.map(s => { + return new TestSuiteResult(s.suite.path, getGroups(s)) }) - const summary = table( - ['Result', 'Suite', 'Tests', 'Time', `Passed ${Icon.success}`, `Failed ${Icon.fail}`, `Skipped ${Icon.skip}`], - [Align.Center, Align.Left, Align.Right, Align.Right, Align.Right, Align.Right, Align.Right], - ...suitesSummary - ) - - const suites = tr.suites.map((ts, i) => getSuiteSummary(ts, i)).join('\n') - const suitesSection = `# Test Suites\n\n${suites}` - - return `${headingLine}\n${summary}\n${suitesSection}` + return new TestRunResult(suites, tr.time) } -function getSuiteSummary(ts: TestSuite, index: number): string { - const icon = ts.failed === 0 ? Icon.success : Icon.fail - - const groups = Object.values(ts.groups) +function getGroups(suite: TestSuite): TestGroupResult[] { + const groups = Object.values(suite.groups).filter(grp => grp.tests.length > 0) groups.sort((a, b) => (a.group.line ?? 0) - (b.group.line ?? 0)) - const content = groups - .filter(grp => grp.count > 0) - .map(grp => { - const header = grp.group.name !== null ? `### ${grp.group.name}\n\n` : '' - grp.tests.sort((a, b) => (a.testStart.test.line ?? 0) - (b.testStart.test.line ?? 0)) - const tests = table( - ['Result', 'Test', 'Time'], - [Align.Center, Align.Left, Align.Right], - ...grp.tests.map(tc => { - const name = tc.testStart.test.name - const time = `${tc.time}ms` - const result = getTestCaseIcon(tc) - return [result, name, time] - }) - ) - - return `${header}${tests}\n` - }) - .join('\n') - - const tsName = ts.suite.path - const tsSlug = makeSuiteSlug(index, tsName) - const tsNameLink = `${tsName}` - return `## ${tsNameLink} ${icon}\n\n${content}` -} - -function makeSuiteSlug(index: number, name: string): {id: string; link: string} { - // use "ts-$index-" as prefix to avoid slug conflicts after escaping the paths - return slug(`ts-${index}-${name}`) -} - -function getTestCaseIcon(test: TestCase): string { - if (test.isFailed) return Icon.fail - if (test.isSkipped) return Icon.skip - return Icon.success + return groups.map(group => { + group.tests.sort((a, b) => (a.testStart.test.line ?? 0) - (b.testStart.test.line ?? 0)) + const tests = group.tests.map(t => new TestCaseResult(t.testStart.test.name, t.result, t.time)) + return new TestGroupResult(group.group.name, tests) + }) } function getAnnotations(tr: TestRun, workDir: string, trackedFiles: string[]): Annotation[] { diff --git a/src/parsers/jest-junit/jest-junit-parser.ts b/src/parsers/jest-junit/jest-junit-parser.ts index 275bea7..d719f04 100644 --- a/src/parsers/jest-junit/jest-junit-parser.ts +++ b/src/parsers/jest-junit/jest-junit-parser.ts @@ -1,12 +1,20 @@ import {Annotation, ParseOptions, TestResult} from '../parser-types' import {parseStringPromise} from 'xml2js' -import {JunitReport, TestCase, TestSuite, TestSuites} from './jest-junit-types' -import {Align, Icon, link, table} from '../../utils/markdown-utils' +import {JunitReport, TestCase, TestSuite} from './jest-junit-types' +import {Icon} from '../../utils/markdown-utils' import {normalizeFilePath} from '../../utils/file-utils' -import {slug} from '../../utils/slugger' import {parseAttribute} from '../../utils/xml-utils' +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult +} from '../../report/test-results' +import getReport from '../../report/get-report' + export async function parseJestJunit(content: string, options: ParseOptions): Promise { const junit = (await parseStringPromise(content, { attrValueProcessors: [parseAttribute] @@ -26,47 +34,19 @@ export async function parseJestJunit(content: string, options: ParseOptions): Pr } function getSummary(junit: JunitReport): string { - const stats = junit.testsuites.$ - - const time = `${stats.time.toFixed(3)}s` - const skipped = getSkippedCount(junit.testsuites) - const failed = stats.errors + stats.failures - const passed = stats.tests - failed - skipped - - const headingLine = `**${stats.tests}** tests were completed in **${time}** with **${passed}** passed, **${skipped}** skipped and **${failed}** failed.` - - const suitesSummary = junit.testsuites.testsuite.map((ts, i) => { - const skip = ts.$.skipped - const fail = ts.$.errors + ts.$.failures - const pass = ts.$.tests - fail - skip - const tm = formatTime(ts.$.time) - const result = fail === 0 ? Icon.success : Icon.fail - const tsName = ts.$.name.trim() - const tsAddr = makeSuiteSlug(i, tsName).link - const tsNameLink = link(tsName, tsAddr) - return [result, tsNameLink, ts.$.tests, tm, pass, fail, skip] + const suites = junit.testsuites.testsuite.map(ts => { + const name = ts.$.name.trim() + const time = ts.$.time * 1000 + const sr = new TestSuiteResult(name, getGroups(ts), time) + return sr }) - const summary = table( - ['Result', 'Suite', 'Tests', 'Time', `Passed ${Icon.success}`, `Failed ${Icon.fail}`, `Skipped ${Icon.skip}`], - [Align.Center, Align.Left, Align.Right, Align.Right, Align.Right, Align.Right, Align.Right], - ...suitesSummary - ) - - const suites = junit.testsuites?.testsuite?.map((ts, i) => getSuiteSummary(ts, i)).join('\n') - const suitesSection = `# Test Suites\n\n${suites}` - - return `${headingLine}\n${summary}\n${suitesSection}` -} - -function getSkippedCount(suites: TestSuites): number { - return suites.testsuite.reduce((sum, suite) => sum + suite.$.skipped, 0) + const time = junit.testsuites.$.time * 1000 + const tr = new TestRunResult(suites, time) + return getReport(tr) } -function getSuiteSummary(suite: TestSuite, index: number): string { - const success = !(suite.$?.failures > 0 || suite.$?.errors > 0) - const icon = success ? Icon.success : Icon.fail - +function getGroups(suite: TestSuite): TestGroupResult[] { const groups: {describe: string; tests: TestCase[]}[] = [] for (const tc of suite.testcase) { let grp = groups.find(g => g.describe === tc.$.classname) @@ -77,43 +57,21 @@ function getSuiteSummary(suite: TestSuite, index: number): string { grp.tests.push(tc) } - const content = groups - .map(grp => { - const header = grp.describe !== '' ? `### ${grp.describe.trim()}\n\n` : '' - const tests = table( - ['Result', 'Test', 'Time'], - [Align.Center, Align.Left, Align.Right], - ...grp.tests.map(tc => { - const name = tc.$.name.trim() - const time = formatTime(tc.$.time) - const result = getTestCaseIcon(tc) - return [result, name, time] - }) - ) - - return `${header}${tests}\n` + return groups.map(grp => { + const tests = grp.tests.map(tc => { + const name = tc.$.name.trim() + const result = getTestCaseResult(tc) + const time = tc.$.time * 1000 + return new TestCaseResult(name, result, time) }) - .join('\n') - - const tsName = suite.$.name.trim() - const tsSlug = makeSuiteSlug(index, tsName) - const tsNameLink = `${tsName}` - return `## ${tsNameLink} ${icon}\n\n${content}` -} - -function getTestCaseIcon(test: TestCase): string { - if (test.failure) return Icon.fail - if (test.skipped) return Icon.skip - return Icon.success -} - -function makeSuiteSlug(index: number, name: string): {id: string; link: string} { - // use "ts-$index-" as prefix to avoid slug conflicts after escaping the paths - return slug(`ts-${index}-${name}`) + return new TestGroupResult(grp.describe, tests) + }) } -function formatTime(sec: number): string { - return `${Math.round(sec * 1000)}ms` +function getTestCaseResult(test: TestCase): TestExecutionResult { + if (test.failure) return 'failed' + if (test.skipped) return 'skipped' + return 'success' } function getAnnotations(junit: JunitReport, workDir: string, trackedFiles: string[]): Annotation[] { diff --git a/src/report/get-report.ts b/src/report/get-report.ts new file mode 100644 index 0000000..e80f506 --- /dev/null +++ b/src/report/get-report.ts @@ -0,0 +1,72 @@ +import {TestExecutionResult, TestRunResult, TestSuiteResult} from './test-results' +import {Align, Icon, link, table} from '../utils/markdown-utils' +import {slug} from '../utils/slugger' + +export default function getReport(tr: TestRunResult): string { + const time = `${(tr.time / 1000).toFixed(3)}s` + const headingLine = `**${tr.tests}** tests were completed in **${time}** with **${tr.passed}** passed, **${tr.skipped}** skipped and **${tr.failed}** failed.` + + const suitesSummary = tr.suites.map((s, i) => { + const icon = getResultIcon(s.result) + const tsTime = `${s.time}ms` + const tsName = s.name + const tsAddr = makeSuiteSlug(i, tsName).link + const tsNameLink = link(tsName, tsAddr) + return [icon, tsNameLink, s.tests, tsTime, s.passed, s.failed, s.skipped] + }) + + const summary = table( + ['Result', 'Suite', 'Tests', 'Time', `Passed ${Icon.success}`, `Failed ${Icon.fail}`, `Skipped ${Icon.skip}`], + [Align.Center, Align.Left, Align.Right, Align.Right, Align.Right, Align.Right, Align.Right], + ...suitesSummary + ) + + const suites = tr.suites.map((ts, i) => getSuiteSummary(ts, i)).join('\n') + const suitesSection = `# Test Suites\n\n${suites}` + + return `${headingLine}\n${summary}\n${suitesSection}` +} + +function getSuiteSummary(ts: TestSuiteResult, index: number): string { + const icon = getResultIcon(ts.result) + const content = ts.groups + .map(grp => { + const header = grp.name ? `### ${grp.name}\n\n` : '' + const tests = table( + ['Result', 'Test', 'Time'], + [Align.Center, Align.Left, Align.Right], + ...grp.tests.map(tc => { + const name = tc.name + const time = `${tc.time}ms` + const result = getResultIcon(tc.result) + return [result, name, time] + }) + ) + + return `${header}${tests}\n` + }) + .join('\n') + + const tsName = ts.name + const tsSlug = makeSuiteSlug(index, tsName) + const tsNameLink = `${tsName}` + return `## ${tsNameLink} ${icon}\n\n${content}` +} + +function makeSuiteSlug(index: number, name: string): {id: string; link: string} { + // use "ts-$index-" as prefix to avoid slug conflicts after escaping the paths + return slug(`ts-${index}-${name}`) +} + +function getResultIcon(result: TestExecutionResult): string { + switch (result) { + case 'success': + return Icon.success + case 'skipped': + return Icon.skip + case 'failed': + return Icon.fail + default: + return '' + } +} diff --git a/src/report/test-results.ts b/src/report/test-results.ts new file mode 100644 index 0000000..84d0af4 --- /dev/null +++ b/src/report/test-results.ts @@ -0,0 +1,77 @@ +export class TestRunResult { + constructor(readonly suites: TestSuiteResult[], private totalTime?: number) {} + + get tests(): number { + return this.suites.reduce((sum, g) => sum + g.tests, 0) + } + + get passed(): number { + return this.suites.reduce((sum, g) => sum + g.passed, 0) + } + get failed(): number { + return this.suites.reduce((sum, g) => sum + g.failed, 0) + } + get skipped(): number { + return this.suites.reduce((sum, g) => sum + g.skipped, 0) + } + + get time(): number { + return this.totalTime ?? this.suites.reduce((sum, g) => sum + g.time, 0) + } + + get result(): TestExecutionResult { + return this.suites.some(t => t.result === 'failed') ? 'failed' : 'success' + } +} + +export class TestSuiteResult { + constructor(readonly name: string, readonly groups: TestGroupResult[], private totalTime?: number) {} + + get tests(): number { + return this.groups.reduce((sum, g) => sum + g.tests.length, 0) + } + + 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) + } + + get result(): TestExecutionResult { + return this.groups.some(t => t.result === 'failed') ? 'failed' : 'success' + } +} + +export class TestGroupResult { + constructor(readonly name: string | undefined, readonly tests: TestCaseResult[]) {} + + get passed(): number { + return this.tests.reduce((sum, t) => (t.result === 'success' ? sum + 1 : sum), 0) + } + get failed(): number { + return this.tests.reduce((sum, t) => (t.result === 'failed' ? sum + 1 : sum), 0) + } + get skipped(): number { + return this.tests.reduce((sum, t) => (t.result === 'skipped' ? sum + 1 : sum), 0) + } + get time(): number { + return this.tests.reduce((sum, t) => sum + t.time, 0) + } + + get result(): TestExecutionResult { + return this.tests.some(t => t.result === 'failed') ? 'failed' : 'success' + } +} + +export class TestCaseResult { + constructor(readonly name: string, readonly result: TestExecutionResult, readonly time: number) {} +} + +export type TestExecutionResult = 'success' | 'skipped' | 'failed' | undefined