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