diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ba35679346..e334667cf3c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `[expect, jest-circus, @jest/types]` Implement `numPassingAsserts` of testResults to track the number of passing asserts in a test ([#13795](https://github.com/facebook/jest/pull/13795)) - `[jest-core]` Add newlines to JSON output ([#13817](https://github.com/facebook/jest/pull/13817)) +- `[@jest/reporters]` New functionality for Github Actions Reporter: automatic log folding ([#13626](https://github.com/facebook/jest/pull/13626)) ### Fixes diff --git a/docs/Configuration.md b/docs/Configuration.md index 0a071c26c713..eebddd813855 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1278,12 +1278,12 @@ export default config; #### GitHub Actions Reporter -If included in the list, the built-in GitHub Actions Reporter will annotate changed files with test failure messages: +If included in the list, the built-in GitHub Actions Reporter will annotate changed files with test failure messages and (if used with `'silent: false'`) print logs with github group features for easy navigation. Note that `'default'` should not be used in this case as `'github-actions'` will handle that already, so remember to also include `'summary'`. If you wish to use it only for annotations simply leave only the reporter without options as the default value of `'silent'` is `'true'`: ```js tab /** @type {import('jest').Config} */ const config = { - reporters: ['default', 'github-actions'], + reporters: [['github-actions', {silent: false}], 'summary'], }; module.exports = config; @@ -1293,7 +1293,7 @@ module.exports = config; import type {Config} from 'jest'; const config: Config = { - reporters: ['default', 'github-actions'], + reporters: [['github-actions', {silent: false}], 'summary'], }; export default config; diff --git a/packages/jest-core/src/TestScheduler.ts b/packages/jest-core/src/TestScheduler.ts index ad96154c2fbb..38dc47adedd4 100644 --- a/packages/jest-core/src/TestScheduler.ts +++ b/packages/jest-core/src/TestScheduler.ts @@ -347,7 +347,10 @@ class TestScheduler { : this.addReporter(new DefaultReporter(this._globalConfig)); break; case 'github-actions': - GITHUB_ACTIONS && this.addReporter(new GitHubActionsReporter()); + GITHUB_ACTIONS && + this.addReporter( + new GitHubActionsReporter(this._globalConfig, options), + ); break; case 'summary': summary = true; diff --git a/packages/jest-reporters/src/GitHubActionsReporter.ts b/packages/jest-reporters/src/GitHubActionsReporter.ts index 7d244d728852..7d439b182111 100644 --- a/packages/jest-reporters/src/GitHubActionsReporter.ts +++ b/packages/jest-reporters/src/GitHubActionsReporter.ts @@ -5,8 +5,15 @@ * LICENSE file in the root directory of this source tree. */ +import chalk = require('chalk'); import stripAnsi = require('strip-ansi'); -import type {Test, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + AssertionResult, + Test, + TestContext, + TestResult, +} from '@jest/test-result'; import type {Config} from '@jest/types'; import { formatPath, @@ -26,10 +33,68 @@ type AnnotationOptions = { const titleSeparator = ' \u203A '; +type PerformanceInfo = { + 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: PerformanceInfo; +}; + export default class GitHubActionsReporter extends BaseReporter { static readonly filename = __filename; + private readonly options: {silent: boolean}; + + constructor( + _globalConfig: Config.GlobalConfig, + reporterOptions: {silent?: boolean} = {silent: true}, + ) { + super(); + this.options = { + silent: + typeof reporterOptions.silent === 'boolean' + ? reporterOptions.silent + : true, + }; + } + + override onTestResult( + test: Test, + testResult: TestResult, + aggregatedResults: AggregatedResult, + ): void { + this.generateAnnotations(test, testResult); + if (!this.options.silent) { + this.printFullResult(test.context, testResult); + } + if (this.isLastTestSuite(aggregatedResults)) { + this.printFailedTestLogs(test, aggregatedResults); + } + } - onTestFileResult({context}: Test, {testResults}: TestResult): void { + private generateAnnotations( + {context}: Test, + {testResults}: TestResult, + ): void { testResults.forEach(result => { const title = [...result.ancestorTitles, result.title].join( titleSeparator, @@ -81,4 +146,276 @@ export default class GitHubActionsReporter extends BaseReporter { `\n::${type} file=${file},line=${line},title=${title}::${message}`, ); } + + private 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/facebook/jest/issues`, + ); + } + } + + private 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); + } + + private 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; + } + + private 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; + } + + private getResultTree( + suiteResult: Array, + testPath: string, + suitePerf: PerformanceInfo, + ): 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 !== 'passed') { + root.passed = false; + passed = false; + } + const duration = element.duration || 1; + root.children.push({ + children: [], + duration, + 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; + } + + private 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 !== 'passed') { + 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; + } + + private 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); + }); + } + } + + private 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); + }); + } + } + } + + private 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) { + if (!written) { + this.log(''); + written = true; + } + this.startGroup(`Errors thrown in ${testDir}`); + this.log(result.failureMessage); + this.endGroup(); + } + }); + return written; + } + + private startGroup(title: string): void { + this.log(`::group::${title}`); + } + + private endGroup(): void { + this.log('::endgroup::'); + } } diff --git a/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts b/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts index 19ced0720956..a78c9aa00db2 100644 --- a/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts +++ b/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts @@ -5,133 +5,550 @@ * LICENSE file in the root directory of this source tree. */ -import type {Test, TestCaseResult, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + AssertionResult, + Test, + TestCaseResult, + TestResult, +} from '@jest/test-result'; +import type {Config} from '@jest/types'; import GitHubActionsReporter from '../GitHubActionsReporter'; +afterEach(() => { + jest.clearAllMocks(); +}); + const mockedStderrWrite = jest .spyOn(process.stderr, 'write') .mockImplementation(() => true); -afterEach(() => { - jest.clearAllMocks(); -}); +describe('annotations', () => { + const reporter = new GitHubActionsReporter({} as Config.GlobalConfig); + + const testMeta = { + context: {config: {rootDir: '/user/project'}}, + } as Test; + + const expectationsErrorMessage = + 'Error: \x1B[2mexpect(\x1B[22m\x1B[31mreceived\x1B[39m\x1B[2m).\x1B[22mtoBe\x1B[2m(\x1B[22m\x1B[32mexpected\x1B[39m\x1B[2m) // Object.is equality\x1B[22m\n' + + '\n' + + 'Expected: \x1B[32m1\x1B[39m\n' + + 'Received: \x1B[31m10\x1B[39m\n' + + ' at Object.toBe (/user/project/__tests__/example.test.js:20:14)\n' + + ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + + ' at new Promise ()\n' + + ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + + ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + + ' at processTicksAndRejections (node:internal/process/task_queues:95:5)\n' + + ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + + ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + + ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)\n' + + ' at runAndTransformResultsToJestFormat (/user/project/jest/packages/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:135:21)'; + + const referenceErrorMessage = + 'ReferenceError: abc is not defined\n' + + ' at Object.abc (/user/project/__tests__/example.test.js:25:12)\n' + + ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + + ' at new Promise ()\n' + + ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + + ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + + ' at processTicksAndRejections (node:internal/process/task_queues:95:5)\n' + + ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + + ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + + ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:90:9)\n' + + ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)'; + + const retryErrorMessage = + 'Error: \x1B[2mexpect(\x1B[22m\x1B[31mreceived\x1B[39m\x1B[2m).\x1B[22mtoBeFalsy\x1B[2m()\x1B[22m\n' + + '\n' + + 'Received: \x1B[31mtrue\x1B[39m\n' + + ' at Object.toBeFalsy (/user/project/__tests__/example.test.js:19:20)\n' + + ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + + ' at new Promise ()\n' + + ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + + ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + + ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + + ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + + ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:90:9)\n' + + ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)\n' + + ' at runAndTransformResultsToJestFormat (/user/project/jest/packages/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:135:21)'; + + const testCaseResult = { + ancestorTitles: [] as Array, + failureMessages: [expectationsErrorMessage], + title: 'example test', + } as TestCaseResult; + + describe('logs error annotation', () => { + test('when an expectation fails to pass', () => { + reporter['generateAnnotations'](testMeta, { + testResults: [ + { + ...testCaseResult, + failureMessages: [expectationsErrorMessage], + }, + ], + } as TestResult); -const reporter = new GitHubActionsReporter(); - -const testMeta = { - context: {config: {rootDir: '/user/project'}}, -} as Test; - -const expectationsErrorMessage = - 'Error: \x1B[2mexpect(\x1B[22m\x1B[31mreceived\x1B[39m\x1B[2m).\x1B[22mtoBe\x1B[2m(\x1B[22m\x1B[32mexpected\x1B[39m\x1B[2m) // Object.is equality\x1B[22m\n' + - '\n' + - 'Expected: \x1B[32m1\x1B[39m\n' + - 'Received: \x1B[31m10\x1B[39m\n' + - ' at Object.toBe (/user/project/__tests__/example.test.js:20:14)\n' + - ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + - ' at new Promise ()\n' + - ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + - ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + - ' at processTicksAndRejections (node:internal/process/task_queues:95:5)\n' + - ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + - ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + - ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)\n' + - ' at runAndTransformResultsToJestFormat (/user/project/jest/packages/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:135:21)'; - -const referenceErrorMessage = - 'ReferenceError: abc is not defined\n' + - ' at Object.abc (/user/project/__tests__/example.test.js:25:12)\n' + - ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + - ' at new Promise ()\n' + - ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + - ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + - ' at processTicksAndRejections (node:internal/process/task_queues:95:5)\n' + - ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + - ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + - ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:90:9)\n' + - ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)'; - -const retryErrorMessage = - 'Error: \x1B[2mexpect(\x1B[22m\x1B[31mreceived\x1B[39m\x1B[2m).\x1B[22mtoBeFalsy\x1B[2m()\x1B[22m\n' + - '\n' + - 'Received: \x1B[31mtrue\x1B[39m\n' + - ' at Object.toBeFalsy (/user/project/__tests__/example.test.js:19:20)\n' + - ' at Promise.then.completed (/user/project/jest/packages/jest-circus/build/utils.js:333:28)\n' + - ' at new Promise ()\n' + - ' at callAsyncCircusFn (/user/project/jest/packages/jest-circus/build/utils.js:259:10)\n' + - ' at _callCircusTest (/user/project/jest/packages/jest-circus/build/run.js:276:40)\n' + - ' at _runTest (/user/project/jest/packages/jest-circus/build/run.js:208:3)\n' + - ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:96:9)\n' + - ' at _runTestsForDescribeBlock (/user/project/jest/packages/jest-circus/build/run.js:90:9)\n' + - ' at run (/user/project/jest/packages/jest-circus/build/run.js:31:3)\n' + - ' at runAndTransformResultsToJestFormat (/user/project/jest/packages/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:135:21)'; - -const testCaseResult = { - ancestorTitles: [] as Array, - failureMessages: [expectationsErrorMessage], - title: 'example test', -} as TestCaseResult; - -describe('logs error annotation', () => { - test('when an expectation fails to pass', () => { - reporter.onTestFileResult(testMeta, { - testResults: [ + expect(mockedStderrWrite).toHaveBeenCalledTimes(1); + expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); + }); + + test('when a test has reference error', () => { + reporter['generateAnnotations']( + {...testMeta, path: '/user/project/__tests__/example.test.js:25:12'}, { - ...testCaseResult, - failureMessages: [expectationsErrorMessage], - }, - ], - } as TestResult); + testResults: [ + { + ...testCaseResult, + failureMessages: [referenceErrorMessage], + }, + ], + } as TestResult, + ); + + expect(mockedStderrWrite).toHaveBeenCalledTimes(1); + expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); + }); - expect(mockedStderrWrite).toHaveBeenCalledTimes(1); - expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); + test('when test is wrapped in describe block', () => { + reporter['generateAnnotations'](testMeta, { + testResults: [ + { + ...testCaseResult, + ancestorTitles: ['describe'], + }, + ], + } as TestResult); + + expect(mockedStderrWrite).toHaveBeenCalledTimes(1); + expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); + }); }); - test('when a test has reference error', () => { - reporter.onTestFileResult( - {...testMeta, path: '/user/project/__tests__/example.test.js:25:12'}, - { + describe('logs warning annotation before logging errors', () => { + test('when test result includes retry reasons', () => { + reporter['generateAnnotations'](testMeta, { testResults: [ { ...testCaseResult, - failureMessages: [referenceErrorMessage], + failureMessages: [retryErrorMessage], + retryReasons: [retryErrorMessage], }, ], - } as TestResult, - ); + } as TestResult); - expect(mockedStderrWrite).toHaveBeenCalledTimes(1); - expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); + expect(mockedStderrWrite).toHaveBeenCalledTimes(2); + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); }); +}); - test('when test is wrapped in describe block', () => { - reporter.onTestFileResult(testMeta, { - testResults: [ +describe('logs', () => { + test('can be instantiated', () => { + const gha = new GitHubActionsReporter({} as Config.GlobalConfig); + expect(gha).toBeTruthy(); + expect(gha).toBeInstanceOf(GitHubActionsReporter); + }); + + describe('Result tree generation', () => { + test('failed single test without describe', () => { + const testResults = [ { - ...testCaseResult, - ancestorTitles: ['describe'], + ancestorTitles: [], + duration: 10, + status: 'failed', + title: 'test', }, - ], - } as TestResult); + ] as unknown as Array; + const suitePerf = { + end: 30, + runtime: 20, + slow: false, + start: 10, + }; + const expectedResults = { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: false, + }, + ], + name: '/', + passed: false, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); - expect(mockedStderrWrite).toHaveBeenCalledTimes(1); - expect(mockedStderrWrite.mock.calls[0]).toMatchSnapshot(); - }); -}); + const generated = gha['getResultTree'](testResults, '/', suitePerf); + + expect(mockedStderrWrite).not.toHaveBeenCalled(); + expect(generated).toEqual(expectedResults); + }); + + test('passed single test without describe', () => { + const testResults = [ + { + ancestorTitles: [], + duration: 10, + status: 'passed', + title: 'test', + }, + ] as unknown as Array; + const suitePerf = { + end: 30, + runtime: 20, + slow: false, + start: 10, + }; + const expectedResults = { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: true, + }, + ], + name: '/', + passed: true, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + const generated = gha['getResultTree'](testResults, '/', suitePerf); + + expect(mockedStderrWrite).not.toHaveBeenCalled(); + expect(generated).toEqual(expectedResults); + }); + + test('failed single test inside describe', () => { + const testResults = [ + { + ancestorTitles: ['Test describe'], + duration: 10, + status: 'failed', + title: 'test', + }, + ] as unknown as Array; + const suitePerf = { + end: 30, + runtime: 20, + slow: false, + start: 10, + }; + const expectedResults = { + children: [ + { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: false, + }, + ], + name: 'Test describe', + passed: false, + }, + ], + name: '/', + passed: false, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + const generated = gha['getResultTree'](testResults, '/', suitePerf); + + expect(mockedStderrWrite).not.toHaveBeenCalled(); + expect(generated).toEqual(expectedResults); + }); -describe('logs warning annotation before logging errors', () => { - test('when test result includes retry reasons', () => { - reporter.onTestFileResult(testMeta, { - testResults: [ + test('passed single test inside describe', () => { + const testResults = [ { - ...testCaseResult, - failureMessages: [retryErrorMessage], - retryReasons: [retryErrorMessage], + ancestorTitles: ['Test describe'], + duration: 10, + status: 'passed', + title: 'test', }, - ], - } as TestResult); + ] as unknown as Array; + const suitePerf = { + end: 30, + runtime: 20, + slow: false, + start: 10, + }; + const expectedResults = { + children: [ + { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: true, + }, + ], + name: 'Test describe', + passed: true, + }, + ], + name: '/', + passed: true, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + const generated = gha['getResultTree'](testResults, '/', suitePerf); + + expect(mockedStderrWrite).not.toHaveBeenCalled(); + expect(generated).toEqual(expectedResults); + }); + }); + + describe('Result tree output', () => { + test('failed single test without describe', () => { + const generatedTree = { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: false, + }, + ], + name: '/', + passed: false, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + gha['printResultTree'](generatedTree); + + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); + + test('passed single test without describe', () => { + const generatedTree = { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: true, + }, + ], + name: '/', + passed: true, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + gha['printResultTree'](generatedTree); + + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); + + test('failed single test inside describe', () => { + const generatedTree = { + children: [ + { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: false, + }, + ], + name: 'Test describe', + passed: false, + }, + ], + name: '/', + passed: false, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + gha['printResultTree'](generatedTree); + + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); + + test('passed single test inside describe', () => { + const generatedTree = { + children: [ + { + children: [ + { + children: [], + duration: 10, + name: 'test', + passed: true, + }, + ], + name: 'Test describe', + passed: true, + }, + ], + name: '/', + passed: true, + performanceInfo: { + end: 30, + runtime: 20, + slow: false, + start: 10, + }, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + + gha['printResultTree'](generatedTree); + + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); + }); + + describe('Reporter interface', () => { + test('onTestResult not last', () => { + const mockTest = { + context: { + config: { + rootDir: '/testDir', + }, + }, + }; + const mockTestResult = { + perfStats: { + runtime: 20, + slow: false, + }, + testFilePath: '/testDir/test1.js', + testResults: [ + { + ancestorTitles: [], + duration: 10, + status: 'passed', + title: 'test1', + }, + ], + }; + const mockResults = { + numFailedTestSuites: 1, + numPassedTestSuites: 1, + numTotalTestSuites: 3, + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + gha['generateAnnotations'] = jest.fn(); + + gha.onTestResult( + mockTest as Test, + mockTestResult as unknown as TestResult, + mockResults as AggregatedResult, + ); + + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); + + test('onTestResult last', () => { + const mockTest = { + context: { + config: { + rootDir: '/testDir', + }, + }, + }; + const mockTestResult = { + failureMessage: 'Failure message', + perfStats: { + runtime: 20, + slow: false, + }, + testFilePath: '/testDir/test1.js', + testResults: [ + { + ancestorTitles: [], + duration: 10, + status: 'passed', + title: 'test1', + }, + ], + }; + const mockResults = { + numFailedTestSuites: 1, + numPassedTestSuites: 2, + numTotalTestSuites: 3, + testResults: [mockTestResult], + }; + const gha = new GitHubActionsReporter({} as Config.GlobalConfig, { + silent: false, + }); + gha['generateAnnotations'] = jest.fn(); + + gha.onTestResult( + mockTest as Test, + mockTestResult as unknown as TestResult, + mockResults as unknown as AggregatedResult, + ); - expect(mockedStderrWrite).toHaveBeenCalledTimes(2); - expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + expect(mockedStderrWrite.mock.calls).toMatchSnapshot(); + }); }); }); diff --git a/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap b/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap index 4201d36f388d..1010136fe994 100644 --- a/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap +++ b/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`logs error annotation when a test has reference error 1`] = ` +exports[`annotations logs error annotation when a test has reference error 1`] = ` Array [ " ::error file=/user/project/__tests__/example.test.js,line=25,title=example test::ReferenceError: abc is not defined%0A%0A at Object.abc (__tests__/example.test.js:25:12) @@ -8,7 +8,7 @@ Array [ ] `; -exports[`logs error annotation when an expectation fails to pass 1`] = ` +exports[`annotations logs error annotation when an expectation fails to pass 1`] = ` Array [ " ::error file=/user/project/__tests__/example.test.js,line=20,title=example test::expect(received).toBe(expected) // Object.is equality%0A%0AExpected: 1%0AReceived: 10%0A%0A at Object.toBe (__tests__/example.test.js:20:14) @@ -16,7 +16,7 @@ Array [ ] `; -exports[`logs error annotation when test is wrapped in describe block 1`] = ` +exports[`annotations logs error annotation when test is wrapped in describe block 1`] = ` Array [ " ::error file=/user/project/__tests__/example.test.js,line=20,title=describe › example test::expect(received).toBe(expected) // Object.is equality%0A%0AExpected: 1%0AReceived: 10%0A%0A at Object.toBe (__tests__/example.test.js:20:14) @@ -24,7 +24,7 @@ Array [ ] `; -exports[`logs warning annotation before logging errors when test result includes retry reasons 1`] = ` +exports[`annotations logs warning annotation before logging errors when test result includes retry reasons 1`] = ` Array [ Array [ " @@ -38,3 +38,121 @@ Array [ ], ] `; + +exports[`logs Reporter interface onTestResult last 1`] = ` +Array [ + Array [ + "::group::PASS test1.js (20 ms) +", + ], + Array [ + " ✓ test1 (10 ms) +", + ], + Array [ + "::endgroup:: +", + ], + Array [ + " +", + ], + Array [ + "::group::Errors thrown in test1.js +", + ], + Array [ + "Failure message +", + ], + Array [ + "::endgroup:: +", + ], +] +`; + +exports[`logs Reporter interface onTestResult not last 1`] = ` +Array [ + Array [ + "::group::PASS test1.js (20 ms) +", + ], + Array [ + " ✓ test1 (10 ms) +", + ], + Array [ + "::endgroup:: +", + ], +] +`; + +exports[`logs Result tree output failed single test inside describe 1`] = ` +Array [ + Array [ + " FAIL / (20 ms) +", + ], + Array [ + " Test describe +", + ], + Array [ + " × test (10 ms) +", + ], +] +`; + +exports[`logs Result tree output failed single test without describe 1`] = ` +Array [ + Array [ + " FAIL / (20 ms) +", + ], + Array [ + " × test (10 ms) +", + ], +] +`; + +exports[`logs Result tree output passed single test inside describe 1`] = ` +Array [ + Array [ + "::group::PASS / (20 ms) +", + ], + Array [ + " Test describe +", + ], + Array [ + " ✓ test (10 ms) +", + ], + Array [ + "::endgroup:: +", + ], +] +`; + +exports[`logs Result tree output passed single test without describe 1`] = ` +Array [ + Array [ + "::group::PASS / (20 ms) +", + ], + Array [ + " ✓ test (10 ms) +", + ], + Array [ + "::endgroup:: +", + ], +] +`; diff --git a/website/versioned_docs/version-29.4/Configuration.md b/website/versioned_docs/version-29.4/Configuration.md index 0a071c26c713..eebddd813855 100644 --- a/website/versioned_docs/version-29.4/Configuration.md +++ b/website/versioned_docs/version-29.4/Configuration.md @@ -1278,12 +1278,12 @@ export default config; #### GitHub Actions Reporter -If included in the list, the built-in GitHub Actions Reporter will annotate changed files with test failure messages: +If included in the list, the built-in GitHub Actions Reporter will annotate changed files with test failure messages and (if used with `'silent: false'`) print logs with github group features for easy navigation. Note that `'default'` should not be used in this case as `'github-actions'` will handle that already, so remember to also include `'summary'`. If you wish to use it only for annotations simply leave only the reporter without options as the default value of `'silent'` is `'true'`: ```js tab /** @type {import('jest').Config} */ const config = { - reporters: ['default', 'github-actions'], + reporters: [['github-actions', {silent: false}], 'summary'], }; module.exports = config; @@ -1293,7 +1293,7 @@ module.exports = config; import type {Config} from 'jest'; const config: Config = { - reporters: ['default', 'github-actions'], + reporters: [['github-actions', {silent: false}], 'summary'], }; export default config;