|
| 1 | +defmodule QuickBEAM.Cover do |
| 2 | + @moduledoc """ |
| 3 | + JavaScript coverage tool for `mix test --cover`. |
| 4 | +
|
| 5 | + Reports line-level coverage for all JS/TS code executed through |
| 6 | + QuickBEAM runtimes during the test suite, alongside standard |
| 7 | + Elixir coverage. |
| 8 | +
|
| 9 | + ## Setup |
| 10 | +
|
| 11 | + # mix.exs |
| 12 | + def project do |
| 13 | + [ |
| 14 | + ..., |
| 15 | + test_coverage: [tool: QuickBEAM.Cover] |
| 16 | + ] |
| 17 | + end |
| 18 | +
|
| 19 | + Then run: |
| 20 | +
|
| 21 | + $ mix test --cover |
| 22 | +
|
| 23 | + Elixir coverage works as normal (delegates to Erlang's `:cover`). |
| 24 | + JS coverage is collected automatically from all QuickBEAM runtimes |
| 25 | + that start during the test run. |
| 26 | +
|
| 27 | + ## Options |
| 28 | +
|
| 29 | + Accepts all standard `:test_coverage` options, plus: |
| 30 | +
|
| 31 | + * `:js` — keyword list of JS-specific options: |
| 32 | + * `:ignore` — file patterns to exclude (default: `["node_modules/**"]`) |
| 33 | +
|
| 34 | + ## Using with excoveralls |
| 35 | +
|
| 36 | + If you already use excoveralls, add JS coverage as a sidecar: |
| 37 | +
|
| 38 | + # test/test_helper.exs |
| 39 | + QuickBEAM.Cover.start() |
| 40 | + ExUnit.after_suite(fn _ -> QuickBEAM.Cover.stop() end) |
| 41 | +
|
| 42 | + JS coverage is written to `cover/js_lcov.info`. |
| 43 | + """ |
| 44 | + |
| 45 | + @table __MODULE__ |
| 46 | + |
| 47 | + @doc false |
| 48 | + def start(compile_path, opts) when is_binary(compile_path) do |
| 49 | + erlang_callback = Mix.Tasks.Test.Coverage.start(compile_path, opts) |
| 50 | + |
| 51 | + start() |
| 52 | + |
| 53 | + fn -> |
| 54 | + js_opts = Keyword.get(opts, :js, []) |
| 55 | + output = Keyword.get(opts, :output, "cover") |
| 56 | + summary_opts = Keyword.get(opts, :summary, threshold: 90) |
| 57 | + |
| 58 | + data = |
| 59 | + stop( |
| 60 | + output: output, |
| 61 | + ignore: Keyword.get(js_opts, :ignore, ["node_modules/**"]) |
| 62 | + ) |
| 63 | + |
| 64 | + if summary_opts != false do |
| 65 | + threshold = |
| 66 | + if is_list(summary_opts), |
| 67 | + do: Keyword.get(summary_opts, :threshold, 90), |
| 68 | + else: 90 |
| 69 | + |
| 70 | + print_summary(data, threshold) |
| 71 | + end |
| 72 | + |
| 73 | + if erlang_callback, do: erlang_callback.() |
| 74 | + end |
| 75 | + end |
| 76 | + |
| 77 | + @spec start() :: :ok |
| 78 | + def start do |
| 79 | + :ets.new(@table, [:named_table, :public, :set]) |
| 80 | + :persistent_term.put({__MODULE__, :enabled}, true) |
| 81 | + :ok |
| 82 | + rescue |
| 83 | + ArgumentError -> :ok |
| 84 | + end |
| 85 | + |
| 86 | + @spec stop(keyword()) :: map() |
| 87 | + def stop(opts \\ []) do |
| 88 | + output = Keyword.get(opts, :output, "cover") |
| 89 | + ignore = Keyword.get(opts, :ignore, ["node_modules/**"]) |
| 90 | + |
| 91 | + data = results(ignore: ignore) |
| 92 | + |
| 93 | + if map_size(data) > 0 do |
| 94 | + File.mkdir_p!(output) |
| 95 | + export_lcov(Path.join(output, "js_lcov.info"), data) |
| 96 | + end |
| 97 | + |
| 98 | + :persistent_term.erase({__MODULE__, :enabled}) |
| 99 | + |
| 100 | + if :ets.info(@table) != :undefined do |
| 101 | + :ets.delete(@table) |
| 102 | + end |
| 103 | + |
| 104 | + data |
| 105 | + rescue |
| 106 | + ArgumentError -> %{} |
| 107 | + end |
| 108 | + |
| 109 | + @spec enabled?() :: boolean() |
| 110 | + def enabled? do |
| 111 | + :persistent_term.get({__MODULE__, :enabled}, false) |
| 112 | + end |
| 113 | + |
| 114 | + @doc false |
| 115 | + @spec record(map()) :: :ok |
| 116 | + def record(coverage_map) when is_map(coverage_map) do |
| 117 | + if :ets.info(@table) != :undefined do |
| 118 | + Enum.each(coverage_map, fn {filename, lines} when is_map(lines) -> |
| 119 | + Enum.each(lines, fn {line, count} -> |
| 120 | + line = if is_binary(line), do: String.to_integer(line), else: line |
| 121 | + count = if is_integer(count), do: max(count, 0), else: 0 |
| 122 | + :ets.update_counter(@table, {filename, line}, {2, count}, {{filename, line}, 0}) |
| 123 | + end) |
| 124 | + end) |
| 125 | + end |
| 126 | + |
| 127 | + :ok |
| 128 | + end |
| 129 | + |
| 130 | + @spec results(keyword()) :: map() |
| 131 | + def results(opts \\ []) do |
| 132 | + ignore = Keyword.get(opts, :ignore, []) |
| 133 | + |
| 134 | + if :ets.info(@table) == :undefined do |
| 135 | + %{} |
| 136 | + else |
| 137 | + :ets.tab2list(@table) |
| 138 | + |> Enum.group_by( |
| 139 | + fn {{filename, _line}, _count} -> filename end, |
| 140 | + fn {{_filename, line}, count} -> {line, count} end |
| 141 | + ) |
| 142 | + |> Enum.reject(fn {filename, _} -> ignored?(filename, ignore) end) |
| 143 | + |> Map.new(fn {filename, lines} -> {filename, Map.new(lines)} end) |
| 144 | + end |
| 145 | + end |
| 146 | + |
| 147 | + @spec export_lcov(Path.t(), map()) :: :ok |
| 148 | + def export_lcov(path, data) do |
| 149 | + File.mkdir_p!(Path.dirname(path)) |
| 150 | + File.write!(path, to_lcov(data)) |
| 151 | + end |
| 152 | + |
| 153 | + @spec export_istanbul(Path.t(), map()) :: :ok |
| 154 | + def export_istanbul(path, data) do |
| 155 | + File.mkdir_p!(Path.dirname(path)) |
| 156 | + File.write!(path, :json.encode(to_istanbul(data))) |
| 157 | + end |
| 158 | + |
| 159 | + defp ignored?(_filename, []), do: false |
| 160 | + |
| 161 | + defp ignored?(filename, patterns) do |
| 162 | + Enum.any?(patterns, fn pattern -> |
| 163 | + regex = |
| 164 | + pattern |
| 165 | + |> String.replace(".", "\\.") |
| 166 | + |> String.replace("**", "\0") |
| 167 | + |> String.replace("*", "[^/]*") |
| 168 | + |> String.replace("\0", ".*") |
| 169 | + |
| 170 | + String.match?(filename, ~r/^#{regex}$/) |
| 171 | + end) |
| 172 | + end |
| 173 | + |
| 174 | + defp to_lcov(data) do |
| 175 | + data |
| 176 | + |> Enum.sort_by(&elem(&1, 0)) |
| 177 | + |> Enum.map(fn {filename, lines} -> |
| 178 | + sorted = Enum.sort_by(lines, &elem(&1, 0)) |
| 179 | + covered = Enum.count(sorted, fn {_, c} -> c > 0 end) |
| 180 | + |
| 181 | + [ |
| 182 | + "SF:", |
| 183 | + filename, |
| 184 | + "\n", |
| 185 | + Enum.map(sorted, fn {line, count} -> |
| 186 | + ["DA:", to_string(line), ",", to_string(count), "\n"] |
| 187 | + end), |
| 188 | + "LH:", |
| 189 | + to_string(covered), |
| 190 | + "\n", |
| 191 | + "LF:", |
| 192 | + to_string(length(sorted)), |
| 193 | + "\n", |
| 194 | + "end_of_record\n" |
| 195 | + ] |
| 196 | + end) |
| 197 | + |> IO.iodata_to_binary() |
| 198 | + end |
| 199 | + |
| 200 | + defp to_istanbul(data) do |
| 201 | + Map.new(data, fn {filename, lines} -> |
| 202 | + sorted = Enum.sort_by(lines, &elem(&1, 0)) |
| 203 | + |
| 204 | + statement_map = |
| 205 | + sorted |
| 206 | + |> Enum.with_index() |
| 207 | + |> Map.new(fn {{line, _}, idx} -> |
| 208 | + {to_string(idx), |
| 209 | + %{ |
| 210 | + "start" => %{"line" => line, "column" => 0}, |
| 211 | + "end" => %{"line" => line, "column" => 999} |
| 212 | + }} |
| 213 | + end) |
| 214 | + |
| 215 | + s = |
| 216 | + sorted |
| 217 | + |> Enum.with_index() |
| 218 | + |> Map.new(fn {{_, count}, idx} -> {to_string(idx), count} end) |
| 219 | + |
| 220 | + {filename, |
| 221 | + %{ |
| 222 | + "path" => filename, |
| 223 | + "statementMap" => statement_map, |
| 224 | + "fnMap" => %{}, |
| 225 | + "branchMap" => %{}, |
| 226 | + "s" => s, |
| 227 | + "f" => %{}, |
| 228 | + "b" => %{} |
| 229 | + }} |
| 230 | + end) |
| 231 | + end |
| 232 | + |
| 233 | + defp print_summary(data, threshold) do |
| 234 | + if map_size(data) > 0, do: do_print_summary(data, threshold) |
| 235 | + end |
| 236 | + |
| 237 | + defp do_print_summary(data, threshold) do |
| 238 | + IO.puts("") |
| 239 | + |
| 240 | + rows = |
| 241 | + data |
| 242 | + |> Enum.sort_by(&elem(&1, 0)) |
| 243 | + |> Enum.map(fn {filename, lines} -> |
| 244 | + total = map_size(lines) |
| 245 | + covered = Enum.count(lines, fn {_, c} -> c > 0 end) |
| 246 | + pct = if total > 0, do: covered / total * 100.0, else: 100.0 |
| 247 | + {filename, pct} |
| 248 | + end) |
| 249 | + |
| 250 | + max_name = |
| 251 | + rows |> Enum.map(fn {n, _} -> String.length(n) end) |> Enum.max(fn -> 4 end) |> max(4) |
| 252 | + |
| 253 | + IO.puts(String.pad_leading("Percentage", 10) <> " | File") |
| 254 | + IO.puts(String.duplicate("-", 11) <> "|" <> String.duplicate("-", max_name + 2)) |
| 255 | + |
| 256 | + Enum.each(rows, fn {filename, pct} -> |
| 257 | + color = if pct >= threshold, do: :green, else: :red |
| 258 | + pct_str = :io_lib.format(~c"~8.2f%", [pct]) |> IO.iodata_to_binary() |
| 259 | + |
| 260 | + IO.ANSI.format([color, pct_str, :reset, " | ", filename]) |
| 261 | + |> IO.iodata_to_binary() |
| 262 | + |> IO.puts() |
| 263 | + end) |
| 264 | + |
| 265 | + all_counts = Enum.flat_map(data, fn {_, lines} -> Map.values(lines) end) |
| 266 | + |
| 267 | + total_pct = |
| 268 | + if all_counts == [], |
| 269 | + do: 100.0, |
| 270 | + else: Enum.count(all_counts, &(&1 > 0)) / length(all_counts) * 100.0 |
| 271 | + |
| 272 | + total_color = if total_pct >= threshold, do: :green, else: :red |
| 273 | + |
| 274 | + IO.puts(String.duplicate("-", 11) <> "|" <> String.duplicate("-", max_name + 2)) |
| 275 | + |
| 276 | + IO.ANSI.format([ |
| 277 | + total_color, |
| 278 | + :io_lib.format(~c"~8.2f%", [total_pct]) |> IO.iodata_to_binary(), |
| 279 | + :reset, |
| 280 | + " | Total (JavaScript)" |
| 281 | + ]) |
| 282 | + |> IO.iodata_to_binary() |
| 283 | + |> IO.puts() |
| 284 | + end |
| 285 | +end |
0 commit comments