defmodule GithubActionsFormatter do @moduledoc """ An `ExUnit.Formatter` that emits [GitHub Actions workflow commands][cmds] so that failing tests show up as inline annotations within the PR diff. ExUnit.start(formatters: [ExUnit.CLIFormatter, GithubActionsFormatter]) Auto-detects `GITHUB_ACTIONS=true`; override with `config :github_actions_formatter, enabled: true`. [cmds]: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions """ @behaviour GenServer @impl true def init(_), do: {:ok, []} @impl true def handle_cast({:test_finished, %ExUnit.Test{state: {kind, _}} = test}, state) when kind in [:failed, :invalid] do with true <- enabled?(), line when is_binary(line) <- annotation(test) do IO.puts(line) end {:noreply, state} end def handle_cast(_, state), do: {:noreply, state} defp enabled? do Application.get_env( :github_actions_formatter, :enabled, System.get_env("GITHUB_ACTIONS") == "true" ) end @doc false def annotation(%ExUnit.Test{state: {_kind, failures}} = test) do with file when is_binary(file) <- test.tags[:file], line when is_integer(line) <- test.tags[:line] do message = ExUnit.Formatter.format_test_failure(test, failures, 1, 80, fn _, m -> m end) title = "#{inspect(test.module)} #{test.name}" "::error file=#{prop(Path.relative_to_cwd(file))},line=#{line},title=#{prop(title)}::#{body(message)}" else _ -> nil end end defp prop(value), do: value |> escape() |> String.replace(":", "%3A") |> String.replace(",", "%2C") defp body(value), do: escape(value) defp escape(value) do value |> to_string() |> String.replace("%", "%25") |> String.replace("\r", "%0D") |> String.replace("\n", "%0A") end end