Skip to main content

lib/quickbeam/cover.ex

defmodule QuickBEAM.Cover do
  @moduledoc """
  JavaScript coverage tool for `mix test --cover`.

  Reports line-level coverage for all JS/TS code executed through
  QuickBEAM runtimes during the test suite, alongside standard
  Elixir coverage.

  ## Setup

      # mix.exs
      def project do
        [
          ...,
          test_coverage: [tool: QuickBEAM.Cover]
        ]
      end

  Then run:

      $ mix test --cover

  Elixir coverage works as normal (delegates to Erlang's `:cover`).
  JS coverage is collected automatically from all QuickBEAM runtimes
  that start during the test run.

  ## Options

  Accepts all standard `:test_coverage` options, plus:

    * `:js` — keyword list of JS-specific options:
      * `:ignore` — file patterns to exclude (default: `["node_modules/**"]`)

  ## Using with excoveralls

  If you already use excoveralls, add JS coverage as a sidecar:

      # test/test_helper.exs
      QuickBEAM.Cover.start()
      ExUnit.after_suite(fn _ -> QuickBEAM.Cover.stop() end)

  JS coverage is written to `cover/js_lcov.info`.
  """

  alias Mix.Tasks.Test.Coverage

  @table __MODULE__

  @dialyzer {:nowarn_function, start: 2}
  @doc "Mix test coverage callback. Called by ExUnit when `test_coverage: [tool: QuickBEAM.Cover]` is set."
  def start(compile_path, opts) when is_binary(compile_path) do
    erlang_callback =
      try do
        Coverage.start(compile_path, opts)
      catch
        kind, reason ->
          IO.warn("Elixir coverage setup failed: #{inspect({kind, reason})}")
          nil
      end

    start()

    fn ->
      if erlang_callback do
        try do
          erlang_callback.()
        catch
          _, _ -> :ok
        end
      end

      finish_coverage(opts)
    end
  end

  defp finish_coverage(opts) do
    js_opts = Keyword.get(opts, :js, [])
    output = Keyword.get(opts, :output, "cover")
    summary_opts = Keyword.get(opts, :summary, threshold: 90)

    data =
      stop(
        output: output,
        ignore: Keyword.get(js_opts, :ignore, ["node_modules/**"])
      )

    if summary_opts != false do
      threshold =
        if is_list(summary_opts),
          do: Keyword.get(summary_opts, :threshold, 90),
          else: 90

      print_summary(data, threshold)
    end
  end

  @spec start() :: :ok
  def start do
    :ets.new(@table, [:named_table, :public, :set])
    :persistent_term.put({__MODULE__, :enabled}, true)
    :ok
  rescue
    ArgumentError -> :ok
  end

  @spec stop(keyword()) :: map()
  def stop(opts \\ []) do
    output = Keyword.get(opts, :output, "cover")
    ignore = Keyword.get(opts, :ignore, ["node_modules/**"])

    data = results(ignore: ignore)

    if map_size(data) > 0 do
      File.mkdir_p!(output)
      export_lcov(Path.join(output, "js_lcov.info"), data)
    end

    :persistent_term.erase({__MODULE__, :enabled})

    if :ets.info(@table) != :undefined do
      :ets.delete(@table)
    end

    data
  rescue
    ArgumentError -> %{}
  end

  @spec enabled?() :: boolean()
  def enabled? do
    :persistent_term.get({__MODULE__, :enabled}, false)
  end

  @doc "Record JS coverage data collected from a runtime."
  @spec record(map()) :: :ok
  def record(coverage_map) when is_map(coverage_map) do
    if :ets.info(@table) != :undefined do
      Enum.each(coverage_map, &record_file/1)
    end

    :ok
  end

  defp record_file({filename, lines}) when is_map(lines) do
    Enum.each(lines, fn {line, count} ->
      line = if is_binary(line), do: String.to_integer(line), else: line
      count = if is_integer(count), do: max(count, 0), else: 0
      :ets.update_counter(@table, {filename, line}, {2, count}, {{filename, line}, 0})
    end)
  end

  @spec results(keyword()) :: map()
  def results(opts \\ []) do
    ignore = Keyword.get(opts, :ignore, [])

    if :ets.info(@table) == :undefined do
      %{}
    else
      :ets.tab2list(@table)
      |> Enum.group_by(
        fn {{filename, _line}, _count} -> filename end,
        fn {{_filename, line}, count} -> {line, count} end
      )
      |> Enum.reject(fn {filename, _} -> ignored?(filename, ignore) end)
      |> Map.new(fn {filename, lines} -> {filename, Map.new(lines)} end)
    end
  end

  @spec export_lcov(Path.t(), map()) :: :ok
  def export_lcov(path, data) do
    File.mkdir_p!(Path.dirname(path))
    File.write!(path, to_lcov(data))
  end

  @spec export_istanbul(Path.t(), map()) :: :ok
  def export_istanbul(path, data) do
    File.mkdir_p!(Path.dirname(path))
    File.write!(path, :json.encode(to_istanbul(data)))
  end

  defp ignored?(_filename, []), do: false

  defp ignored?(filename, patterns) do
    Enum.any?(patterns, fn pattern ->
      regex =
        pattern
        |> String.replace(".", "\\.")
        |> String.replace("**", "\0")
        |> String.replace("*", "[^/]*")
        |> String.replace("\0", ".*")

      String.match?(filename, ~r/^#{regex}$/)
    end)
  end

  defp to_lcov(data) do
    data
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(fn {filename, lines} ->
      sorted = Enum.sort_by(lines, &elem(&1, 0))
      covered = Enum.count(sorted, fn {_, c} -> c > 0 end)

      [
        "SF:",
        filename,
        "\n",
        Enum.map(sorted, fn {line, count} ->
          ["DA:", to_string(line), ",", to_string(count), "\n"]
        end),
        "LH:",
        to_string(covered),
        "\n",
        "LF:",
        to_string(length(sorted)),
        "\n",
        "end_of_record\n"
      ]
    end)
    |> IO.iodata_to_binary()
  end

  defp to_istanbul(data) do
    Map.new(data, fn {filename, lines} ->
      sorted = Enum.sort_by(lines, &elem(&1, 0))

      statement_map =
        sorted
        |> Enum.with_index()
        |> Map.new(fn {{line, _}, idx} ->
          {to_string(idx),
           %{
             "start" => %{"line" => line, "column" => 0},
             "end" => %{"line" => line, "column" => 999}
           }}
        end)

      s =
        sorted
        |> Enum.with_index()
        |> Map.new(fn {{_, count}, idx} -> {to_string(idx), count} end)

      {filename,
       %{
         "path" => filename,
         "statementMap" => statement_map,
         "fnMap" => %{},
         "branchMap" => %{},
         "s" => s,
         "f" => %{},
         "b" => %{}
       }}
    end)
  end

  defp print_summary(data, threshold) do
    if map_size(data) > 0, do: do_print_summary(data, threshold)
  end

  defp do_print_summary(data, threshold) do
    IO.puts("")

    rows =
      data
      |> Enum.sort_by(&elem(&1, 0))
      |> Enum.map(fn {filename, lines} ->
        total = map_size(lines)
        covered = Enum.count(lines, fn {_, c} -> c > 0 end)
        pct = if total > 0, do: covered / total * 100.0, else: 100.0
        {filename, pct}
      end)

    max_name =
      rows |> Enum.map(fn {n, _} -> String.length(n) end) |> Enum.max(fn -> 4 end) |> max(4)

    IO.puts(String.pad_leading("Percentage", 10) <> " | File")
    IO.puts(String.duplicate("-", 11) <> "|" <> String.duplicate("-", max_name + 2))

    Enum.each(rows, fn {filename, pct} ->
      color = if pct >= threshold, do: :green, else: :red
      pct_str = :io_lib.format(~c"~8.2f%", [pct]) |> IO.iodata_to_binary()

      IO.ANSI.format([color, pct_str, :reset, " | ", filename])
      |> IO.iodata_to_binary()
      |> IO.puts()
    end)

    all_counts = Enum.flat_map(data, fn {_, lines} -> Map.values(lines) end)

    total_pct =
      if all_counts == [],
        do: 100.0,
        else: Enum.count(all_counts, &(&1 > 0)) / length(all_counts) * 100.0

    total_color = if total_pct >= threshold, do: :green, else: :red

    IO.puts(String.duplicate("-", 11) <> "|" <> String.duplicate("-", max_name + 2))

    IO.ANSI.format([
      total_color,
      :io_lib.format(~c"~8.2f%", [total_pct]) |> IO.iodata_to_binary(),
      :reset,
      " | Total (JavaScript)"
    ])
    |> IO.iodata_to_binary()
    |> IO.puts()
  end
end