Repository: facebook/jest
File: packages/jest-reporters/src/GitHubActionsReporter.ts (TypeScript)

Code before:

import stripAnsi = require('strip-ansi');
import type {
  AggregatedResult,
  TestContext,
  TestResult,
} from '@jest/test-result';
import BaseReporter from './BaseReporter';

const lineAndColumnInStackTrace = /^.*?:([0-9]+):([0-9]+).*$/;

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);
}

export default class GitHubActionsReporter extends BaseReporter {
  static readonly filename = __filename;

  override onRunComplete(
    _testContexts?: Set<TestContext>,
    aggregatedResults?: AggregatedResult,
  ): void {
    const messages = getMessages(aggregatedResults?.testResults);

    for (const message of messages) {
      this.log(message);
    }
  }
}

function getMessages(results: Array<TestResult> | 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}`,
      ),

Review comment (bug):
`onTestFileResult` is needed to annotate retries. I was trying to use `onTestCaseResult` first, but it reports retried tests as failures. (By the way, strangely `TestContext` from `onTestCaseResult` has wrong `rootDir`, but all is good with `onTestFileResult`.)

Code after:

import stripAnsi = require('strip-ansi');
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';

type AnnotationOptions = {
  file?: string;
  line?: number | string;
  message: string;
  title: string;
  type: 'error' | 'warning';
};

const titleSeparator = ' \u203A ';

export default class GitHubActionsReporter extends BaseReporter {
  static readonly filename = __filename;

  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',
        });
      });

      result.failureMessages.forEach(failureMessage => {
        this.#createAnnotation({
          ...this.#getMessageDetails(failureMessage, context.config),
          title,
          type: 'error',
        });
      });
    });
  }

  #getMessageDetails(failureMessage: string, config: Config.ProjectConfig) {
    const {message, stack} = separateMessageFromStack(failureMessage);