diff --git a/CHANGELOG.md b/CHANGELOG.md index 8580270fecd0..532ec8cacdc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[@jest/reporters]` Improve `GitHubActionsReporter`s annotation format ([#12826](https://github.com/facebook/jest/pull/12826)) + ### Fixes ### Chore & Maintenance @@ -14,7 +16,7 @@ ### Features -- `[jest-circus]` Add `failing` test modifier that inverts the behaviour of tests ([#12610](https://github.com/facebook/jest/pull/12610)) +- `[jest-circus]` Add `failing` test modifier that inverts the behavior of tests ([#12610](https://github.com/facebook/jest/pull/12610)) - `[jest-environment-node, jest-environment-jsdom]` Allow specifying `customExportConditions` ([#12774](https://github.com/facebook/jest/pull/12774)) ### Fixes diff --git a/packages/jest-message-util/src/index.ts b/packages/jest-message-util/src/index.ts index 0f6f38c58c47..b26ab2310aa2 100644 --- a/packages/jest-message-util/src/index.ts +++ b/packages/jest-message-util/src/index.ts @@ -234,11 +234,11 @@ const removeInternalStackEntries = ( }); }; -const formatPaths = ( - config: StackTraceConfig, - relativeTestPath: string | null, +export const formatPath = ( line: string, -) => { + config: StackTraceConfig, + relativeTestPath: string | null = null, +): string => { // Extract the file path from the trace line. const match = line.match(/(^\s*at .*?\(?)([^()]+)(:[0-9]+:[0-9]+\)?.*$)/); if (!match) { @@ -317,7 +317,7 @@ export const formatStackTrace = ( .filter(Boolean) .map( line => - STACK_INDENT + formatPaths(config, relativeTestPath, trimPaths(line)), + STACK_INDENT + formatPath(trimPaths(line), config, relativeTestPath), ) .join('\n'); diff --git a/packages/jest-reporters/package.json b/packages/jest-reporters/package.json index 2d6036b1d744..4bb12026928a 100644 --- a/packages/jest-reporters/package.json +++ b/packages/jest-reporters/package.json @@ -29,6 +29,7 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.1.3", + "jest-message-util": "^28.1.0", "jest-util": "^28.1.0", "jest-worker": "^28.1.0", "slash": "^3.0.0", diff --git a/packages/jest-reporters/src/GitHubActionsReporter.ts b/packages/jest-reporters/src/GitHubActionsReporter.ts index 7cff405fe962..7d244d728852 100644 --- a/packages/jest-reporters/src/GitHubActionsReporter.ts +++ b/packages/jest-reporters/src/GitHubActionsReporter.ts @@ -6,54 +6,79 @@ */ import stripAnsi = require('strip-ansi'); -import type { - AggregatedResult, - TestContext, - TestResult, -} from '@jest/test-result'; +import type {Test, TestResult} from '@jest/test-result'; +import type {Config} from '@jest/types'; +import { + formatPath, + getStackTraceLines, + getTopFrame, + separateMessageFromStack, +} from 'jest-message-util'; import BaseReporter from './BaseReporter'; -const lineAndColumnInStackTrace = /^.*?:([0-9]+):([0-9]+).*$/; +type AnnotationOptions = { + file?: string; + line?: number | string; + message: string; + title: string; + type: 'error' | 'warning'; +}; -function replaceEntities(s: string): string { - // https://github.com/actions/toolkit/blob/b4639928698a6bfe1c4bdae4b2bfdad1cb75016d/packages/core/src/command.ts#L80-L85 - const substitutions: Array<[RegExp, string]> = [ - [/%/g, '%25'], - [/\r/g, '%0D'], - [/\n/g, '%0A'], - ]; - return substitutions.reduce((acc, sub) => acc.replace(...sub), s); -} +const titleSeparator = ' \u203A '; export default class GitHubActionsReporter extends BaseReporter { static readonly filename = __filename; - override onRunComplete( - _testContexts?: Set, - aggregatedResults?: AggregatedResult, - ): void { - const messages = getMessages(aggregatedResults?.testResults); + onTestFileResult({context}: Test, {testResults}: TestResult): void { + testResults.forEach(result => { + const title = [...result.ancestorTitles, result.title].join( + titleSeparator, + ); + + result.retryReasons?.forEach((retryReason, index) => { + this.#createAnnotation({ + ...this.#getMessageDetails(retryReason, context.config), + title: `RETRY ${index + 1}: ${title}`, + type: 'warning', + }); + }); - for (const message of messages) { - this.log(message); - } + result.failureMessages.forEach(failureMessage => { + this.#createAnnotation({ + ...this.#getMessageDetails(failureMessage, context.config), + title, + type: 'error', + }); + }); + }); } -} -function getMessages(results: Array | undefined) { - if (!results) return []; - - return results.flatMap(({testFilePath, testResults}) => - testResults - .filter(r => r.status === 'failed') - .flatMap(r => r.failureMessages) - .map(m => stripAnsi(m)) - .map(m => replaceEntities(m)) - .map(m => lineAndColumnInStackTrace.exec(m)) - .filter((m): m is RegExpExecArray => m !== null) - .map( - ([message, line, col]) => - `\n::error file=${testFilePath},line=${line},col=${col}::${message}`, - ), - ); + #getMessageDetails(failureMessage: string, config: Config.ProjectConfig) { + const {message, stack} = separateMessageFromStack(failureMessage); + + const stackLines = getStackTraceLines(stack); + const topFrame = getTopFrame(stackLines); + + const normalizedStackLines = stackLines.map(line => + formatPath(line, config), + ); + const messageText = [message, ...normalizedStackLines].join('\n'); + + return { + file: topFrame?.file, + line: topFrame?.line, + message: messageText, + }; + } + + #createAnnotation({file, line, message, title, type}: AnnotationOptions) { + message = stripAnsi( + // copied from: https://github.com/actions/toolkit/blob/main/packages/core/src/command.ts + message.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'), + ); + + this.log( + `\n::${type} file=${file},line=${line},title=${title}::${message}`, + ); + } } diff --git a/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.js b/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.js deleted file mode 100644 index b7939b579458..000000000000 --- a/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -'use strict'; - -let GitHubActionsReporter; - -const write = process.stderr.write; -const globalConfig = { - rootDir: 'root', - watch: false, -}; - -let results = []; - -function requireReporter() { - jest.isolateModules(() => { - GitHubActionsReporter = require('../GitHubActionsReporter').default; - }); -} - -beforeEach(() => { - process.stderr.write = result => results.push(result); -}); - -afterEach(() => { - results = []; - process.stderr.write = write; -}); - -const aggregatedResults = { - numFailedTestSuites: 1, - numFailedTests: 1, - numPassedTestSuites: 0, - numTotalTestSuites: 1, - numTotalTests: 1, - snapshot: { - added: 0, - didUpdate: false, - failure: false, - filesAdded: 0, - filesRemoved: 0, - filesRemovedList: [], - filesUnmatched: 0, - filesUpdated: 0, - matched: 0, - total: 0, - unchecked: 0, - uncheckedKeysByFile: [], - unmatched: 0, - updated: 0, - }, - startTime: 0, - success: false, - testResults: [ - { - numFailingTests: 1, - numPassingTests: 0, - numPendingTests: 0, - numTodoTests: 0, - openHandles: [], - perfStats: { - end: 1234, - runtime: 1234, - slow: false, - start: 0, - }, - skipped: false, - snapshot: { - added: 0, - fileDeleted: false, - matched: 0, - unchecked: 0, - uncheckedKeys: [], - unmatched: 0, - updated: 0, - }, - testFilePath: '/home/runner/work/jest/jest/some.test.js', - testResults: [ - { - ancestorTitles: [Array], - duration: 7, - failureDetails: [Array], - failureMessages: [ - ` - Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n - \n - Expected: \u001b[32m\"b\"\u001b[39m\n - Received: \u001b[31m\"a\"\u001b[39m\n - at Object. (/home/runner/work/jest/jest/some.test.js:4:17)\n - at Object.asyncJestTest (/home/runner/work/jest/jest/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:106:37)\n - at /home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:45:12\n - at new Promise ()\n - at mapper (/home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:28:19)\n - at /home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:75:41\n - at processTicksAndRejections (internal/process/task_queues.js:93:5) - `, - ], - fullName: 'asserts that a === b', - location: null, - numPassingAsserts: 0, - status: 'failed', - title: 'asserts that a === b', - }, - ], - }, - ], -}; - -test('reporter extracts the correct filename, line, and column', () => { - requireReporter(); - const testReporter = new GitHubActionsReporter(globalConfig); - testReporter.onRunComplete(new Set(), aggregatedResults); - expect(results.join('').replace(/\\/g, '/')).toMatchSnapshot(); -}); diff --git a/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts b/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts new file mode 100644 index 000000000000..ddc81279417f --- /dev/null +++ b/packages/jest-reporters/src/__tests__/GitHubActionsReporter.test.ts @@ -0,0 +1,135 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Test, TestCaseResult, TestResult} from '@jest/test-result'; +import GitHubActionsReporter from '../GitHubActionsReporter'; + +process.stderr.write = jest.fn(); + +afterEach(() => { + jest.clearAllMocks(); +}); + +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: [ + { + ...testCaseResult, + failureMessages: [expectationsErrorMessage], + }, + ], + } as TestResult); + + expect(jest.mocked(process.stderr.write)).toBeCalledTimes(1); + expect(jest.mocked(process.stderr.write).mock.calls[0]).toMatchSnapshot(); + }); + + test('when a test has reference error', () => { + reporter.onTestFileResult( + {...testMeta, path: '/user/project/__tests__/example.test.js:25:12'}, + { + testResults: [ + { + ...testCaseResult, + failureMessages: [referenceErrorMessage], + }, + ], + } as TestResult, + ); + + expect(jest.mocked(process.stderr.write)).toBeCalledTimes(1); + expect(jest.mocked(process.stderr.write).mock.calls[0]).toMatchSnapshot(); + }); + + test('when test is wrapped in describe block', () => { + reporter.onTestFileResult(testMeta, { + testResults: [ + { + ...testCaseResult, + ancestorTitles: ['describe'], + }, + ], + } as TestResult); + + expect(jest.mocked(process.stderr.write)).toBeCalledTimes(1); + expect(jest.mocked(process.stderr.write).mock.calls[0]).toMatchSnapshot(); + }); +}); + +describe('logs warning annotation before logging errors', () => { + test('when test result includes retry reasons', () => { + reporter.onTestFileResult(testMeta, { + testResults: [ + { + ...testCaseResult, + failureMessages: [retryErrorMessage], + retryReasons: [retryErrorMessage], + }, + ], + } as TestResult); + + expect(jest.mocked(process.stderr.write)).toBeCalledTimes(2); + expect(jest.mocked(process.stderr.write).mock.calls).toMatchSnapshot(); + }); +}); diff --git a/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.js.snap b/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.js.snap deleted file mode 100644 index 0deced8ea4cf..000000000000 --- a/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`reporter extracts the correct filename, line, and column 1`] = ` -" -::error file=/home/runner/work/jest/jest/some.test.js,line=4,col=17::%0A Error: expect(received).toBe(expected) // Object.is equality%0A%0A %0A%0A Expected: "b"%0A%0A Received: "a"%0A%0A at Object. (/home/runner/work/jest/jest/some.test.js:4:17)%0A%0A at Object.asyncJestTest (/home/runner/work/jest/jest/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:106:37)%0A%0A at /home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:45:12%0A%0A at new Promise ()%0A%0A at mapper (/home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:28:19)%0A%0A at /home/runner/work/jest/jest/node_modules/jest-jasmine2/build/queueRunner.js:75:41%0A%0A at processTicksAndRejections (internal/process/task_queues.js:93:5)%0A -" -`; diff --git a/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap b/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap new file mode 100644 index 000000000000..4201d36f388d --- /dev/null +++ b/packages/jest-reporters/src/__tests__/__snapshots__/GitHubActionsReporter.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`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) +", +] +`; + +exports[`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) +", +] +`; + +exports[`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) +", +] +`; + +exports[`logs warning annotation before logging errors when test result includes retry reasons 1`] = ` +Array [ + Array [ + " +::warning file=/user/project/__tests__/example.test.js,line=19,title=RETRY 1: example test::expect(received).toBeFalsy()%0A%0AReceived: true%0A%0A at Object.toBeFalsy (__tests__/example.test.js:19:20) +", + ], + Array [ + " +::error file=/user/project/__tests__/example.test.js,line=19,title=example test::expect(received).toBeFalsy()%0A%0AReceived: true%0A%0A at Object.toBeFalsy (__tests__/example.test.js:19:20) +", + ], +] +`; diff --git a/packages/jest-reporters/tsconfig.json b/packages/jest-reporters/tsconfig.json index 406051cc2489..813ba257479e 100644 --- a/packages/jest-reporters/tsconfig.json +++ b/packages/jest-reporters/tsconfig.json @@ -8,6 +8,7 @@ "exclude": ["./**/__tests__/**/*"], "references": [ {"path": "../jest-console"}, + {"path": "../jest-message-util"}, {"path": "../jest-resolve"}, {"path": "../jest-test-result"}, {"path": "../jest-transform"}, diff --git a/yarn.lock b/yarn.lock index 39c121a6f13d..050470a7b40c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2767,6 +2767,7 @@ __metadata: istanbul-lib-report: ^3.0.0 istanbul-lib-source-maps: ^4.0.0 istanbul-reports: ^3.1.3 + jest-message-util: ^28.1.0 jest-resolve: ^28.1.0 jest-util: ^28.1.0 jest-worker: ^28.1.0