From 8221653d34c73c8f05b05f109584e2a6ea23acaf Mon Sep 17 00:00:00 2001 From: Matteo Dell'Acqua <82184604+MatteoH2O1999@users.noreply.github.com> Date: Tue, 22 Nov 2022 10:36:48 +0100 Subject: [PATCH] Port implementation for Github Actions logs folding Ported from https://github.com/MatteoH2O1999/github-actions-jest-reporter --- packages/jest-core/src/TestScheduler.ts | 13 +- .../src/GithubActionsLogsReporter.ts | 327 ++++++++++++++++++ packages/jest-reporters/src/index.ts | 1 + 3 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 packages/jest-reporters/src/GithubActionsLogsReporter.ts diff --git a/packages/jest-core/src/TestScheduler.ts b/packages/jest-core/src/TestScheduler.ts index ad96154c2fbb..58a090adbdbc 100644 --- a/packages/jest-core/src/TestScheduler.ts +++ b/packages/jest-core/src/TestScheduler.ts @@ -12,6 +12,7 @@ import { CoverageReporter, DefaultReporter, GitHubActionsReporter, + GithubActionsLogsReporter, BaseReporter as JestReporter, NotifyReporter, Reporter, @@ -342,9 +343,15 @@ class TestScheduler { switch (reporter) { case 'default': summary = true; - verbose - ? this.addReporter(new VerboseReporter(this._globalConfig)) - : this.addReporter(new DefaultReporter(this._globalConfig)); + if (verbose) { + this.addReporter(new VerboseReporter(this._globalConfig)); + } else { + GITHUB_ACTIONS + ? this.addReporter( + new GithubActionsLogsReporter(this._globalConfig), + ) + : this.addReporter(new DefaultReporter(this._globalConfig)); + } break; case 'github-actions': GITHUB_ACTIONS && this.addReporter(new GitHubActionsReporter()); diff --git a/packages/jest-reporters/src/GithubActionsLogsReporter.ts b/packages/jest-reporters/src/GithubActionsLogsReporter.ts new file mode 100644 index 000000000000..7ff26254919d --- /dev/null +++ b/packages/jest-reporters/src/GithubActionsLogsReporter.ts @@ -0,0 +1,327 @@ +import chalk = require('chalk'); +import type { + AggregatedResult, + AssertionResult, + Test, + TestContext, + TestResult, +} from '@jest/test-result'; +import DefaultReporter from './DefaultReporter'; + +type performaceInfo = { + end: number; + runtime: number; + slow: boolean; + start: number; +}; + +type resultTreeLeaf = { + name: string; + passed: boolean; + duration: number; + children: Array; +}; + +type resultTreeNode = { + name: string; + passed: boolean; + children: Array; +}; + +type resultTree = { + children: Array; + name: string; + passed: boolean; + performanceInfo: performaceInfo; +}; + +export default class GithubActionsLogsReporter extends DefaultReporter { + override onTestResult( + test: Test, + testResult: TestResult, + aggregatedResults: AggregatedResult, + ): void { + this.__printFullResult(test.context, testResult); + if (this.__isLastTestSuite(aggregatedResults)) { + this.log(''); + if (this.__printFailedTestLogs(test, aggregatedResults)) { + this.log(''); + } + } + } + + __isLastTestSuite(results: AggregatedResult): boolean { + const passedTestSuites = results.numPassedTestSuites; + const failedTestSuites = results.numFailedTestSuites; + const totalTestSuites = results.numTotalTestSuites; + const computedTotal = passedTestSuites + failedTestSuites; + if (computedTotal < totalTestSuites) { + return false; + } else if (computedTotal === totalTestSuites) { + return true; + } else { + throw new Error( + `Sum(${computedTotal}) of passed (${passedTestSuites}) and failed (${failedTestSuites}) test suites is greater than the total number of test suites (${totalTestSuites}). Please report the bug at https://github.com/MatteoH2O1999/github-actions-jest-reporter/issues`, + ); + } + } + + __printFullResult(context: TestContext, results: TestResult): void { + const rootDir = context.config.rootDir; + let testDir = results.testFilePath.replace(rootDir, ''); + testDir = testDir.slice(1, testDir.length); + const resultTree = this.__getResultTree( + results.testResults, + testDir, + results.perfStats, + ); + this.__printResultTree(resultTree); + } + + __arrayEqual(a1: Array, a2: Array): boolean { + if (a1.length !== a2.length) { + return false; + } + for (let index = 0; index < a1.length; index++) { + const element = a1[index]; + if (element !== a2[index]) { + return false; + } + } + return true; + } + + __arrayChild(a1: Array, a2: Array): boolean { + if (a1.length - a2.length !== 1) { + return false; + } + for (let index = 0; index < a2.length; index++) { + const element = a2[index]; + if (element !== a1[index]) { + return false; + } + } + return true; + } + + __getResultTree( + suiteResult: Array, + testPath: string, + suitePerf: performaceInfo, + ): resultTree { + const root: resultTree = { + children: [], + name: testPath, + passed: true, + performanceInfo: suitePerf, + }; + const branches: Array> = []; + suiteResult.forEach(element => { + if (element.ancestorTitles.length === 0) { + let passed = true; + if (element.status === 'failed') { + root.passed = false; + passed = false; + } else if (element.status !== 'passed') { + throw new Error( + `Expected status to be 'failed' or 'passed', got ${element.status}`, + ); + } + if (!element.duration || isNaN(element.duration)) { + throw new Error('Expected duration to be a number, got NaN'); + } + root.children.push({ + children: [], + duration: Math.max(element.duration, 1), + name: element.title, + passed, + }); + } else { + let alreadyInserted = false; + for (let index = 0; index < branches.length; index++) { + if ( + this.__arrayEqual( + branches[index], + element.ancestorTitles.slice(0, 1), + ) + ) { + alreadyInserted = true; + break; + } + } + if (!alreadyInserted) { + branches.push(element.ancestorTitles.slice(0, 1)); + } + } + }); + branches.forEach(element => { + const newChild = this.__getResultChildren(suiteResult, element); + if (!newChild.passed) { + root.passed = false; + } + root.children.push(newChild); + }); + return root; + } + + __getResultChildren( + suiteResult: Array, + ancestors: Array, + ): resultTreeNode { + const node: resultTreeNode = { + children: [], + name: ancestors[ancestors.length - 1], + passed: true, + }; + const branches: Array> = []; + suiteResult.forEach(element => { + let passed = true; + let duration = element.duration; + if (!duration || isNaN(duration)) { + duration = 1; + } + if (this.__arrayEqual(element.ancestorTitles, ancestors)) { + if (element.status === 'failed') { + node.passed = false; + passed = false; + } + node.children.push({ + children: [], + duration, + name: element.title, + passed, + }); + } else if ( + this.__arrayChild( + element.ancestorTitles.slice(0, ancestors.length + 1), + ancestors, + ) + ) { + let alreadyInserted = false; + for (let index = 0; index < branches.length; index++) { + if ( + this.__arrayEqual( + branches[index], + element.ancestorTitles.slice(0, ancestors.length + 1), + ) + ) { + alreadyInserted = true; + break; + } + } + if (!alreadyInserted) { + branches.push(element.ancestorTitles.slice(0, ancestors.length + 1)); + } + } + }); + branches.forEach(element => { + const newChild = this.__getResultChildren(suiteResult, element); + if (!newChild.passed) { + node.passed = false; + } + node.children.push(newChild); + }); + return node; + } + + __printResultTree(resultTree: resultTree): void { + let perfMs; + if (resultTree.performanceInfo.slow) { + perfMs = ` (${chalk.red.inverse( + `${resultTree.performanceInfo.runtime} ms`, + )})`; + } else { + perfMs = ` (${resultTree.performanceInfo.runtime} ms)`; + } + if (resultTree.passed) { + this.__startGroup( + `${chalk.bold.green.inverse('PASS')} ${resultTree.name}${perfMs}`, + ); + resultTree.children.forEach(child => { + this.__recursivePrintResultTree(child, true, 1); + }); + this.__endGroup(); + } else { + this.log( + ` ${chalk.bold.red.inverse('FAIL')} ${resultTree.name}${perfMs}`, + ); + resultTree.children.forEach(child => { + this.__recursivePrintResultTree(child, false, 1); + }); + } + } + + __recursivePrintResultTree( + resultTree: resultTreeNode | resultTreeLeaf, + alreadyGrouped: boolean, + depth: number, + ): void { + if (resultTree.children.length === 0) { + if (!('duration' in resultTree)) { + throw new Error('Expected a leaf. Got a node.'); + } + let numberSpaces = depth; + if (!alreadyGrouped) { + numberSpaces++; + } + const spaces = ' '.repeat(numberSpaces); + let resultSymbol; + if (resultTree.passed) { + resultSymbol = chalk.green('\u2713'); + } else { + resultSymbol = chalk.red('\u00D7'); + } + this.log( + `${spaces + resultSymbol} ${resultTree.name} (${ + resultTree.duration + } ms)`, + ); + } else { + if (resultTree.passed) { + if (alreadyGrouped) { + this.log(' '.repeat(depth) + resultTree.name); + resultTree.children.forEach(child => { + this.__recursivePrintResultTree(child, true, depth + 1); + }); + } else { + this.__startGroup(' '.repeat(depth) + resultTree.name); + resultTree.children.forEach(child => { + this.__recursivePrintResultTree(child, true, depth + 1); + }); + this.__endGroup(); + } + } else { + this.log(' '.repeat(depth + 1) + resultTree.name); + resultTree.children.forEach(child => { + this.__recursivePrintResultTree(child, false, depth + 1); + }); + } + } + } + + __printFailedTestLogs(context: Test, testResults: AggregatedResult): boolean { + const rootDir = context.context.config.rootDir; + const results = testResults.testResults; + let written = false; + results.forEach(result => { + let testDir = result.testFilePath; + testDir = testDir.replace(rootDir, ''); + testDir = testDir.slice(1, testDir.length); + if (result.failureMessage) { + written = true; + this.__startGroup(`Errors thrown in ${testDir}`); + this.log(result.failureMessage); + this.__endGroup(); + } + }); + return written; + } + + __startGroup(title: string): void { + this.log(`::group::${title}`); + } + + __endGroup(): void { + this.log('::endgroup::'); + } +} diff --git a/packages/jest-reporters/src/index.ts b/packages/jest-reporters/src/index.ts index 61f3cde17739..ef52469cfc6d 100644 --- a/packages/jest-reporters/src/index.ts +++ b/packages/jest-reporters/src/index.ts @@ -30,6 +30,7 @@ export {default as GitHubActionsReporter} from './GitHubActionsReporter'; export {default as NotifyReporter} from './NotifyReporter'; export {default as SummaryReporter} from './SummaryReporter'; export {default as VerboseReporter} from './VerboseReporter'; +export {default as GithubActionsLogsReporter} from './GithubActionsLogsReporter'; export type { Reporter, ReporterOnStartOptions,