Skip to content

Commit 3a7fd52

Browse files
committed
Add JS line coverage via QuickJS engine patch
Patch QuickJS to track per-line execution via a precomputed pc-to-line lookup table and a hit bitmap on each JSFunctionBytecode. The COVERAGE_HIT macro in the interpreter SWITCH dispatch checks one unlikely branch per opcode — near-zero overhead when coverage is off. New C API: JS_EnableCoverage, JS_GetCoverage, JS_ResetCoverage. Zig NIFs: enable_coverage, get_coverage, reset_coverage. QuickBEAM.Cover implements the Mix test_coverage tool contract: test_coverage: [tool: QuickBEAM.Cover] Runtimes auto-enable coverage in init/1 when the collector is active and drain results in terminate/2. Output: LCOV and Istanbul JSON. QuickBEAM.coverage/1 exposes per-runtime snapshots for debugging.
1 parent 8b30c17 commit 3a7fd52

10 files changed

Lines changed: 806 additions & 7 deletions

File tree

lib/quickbeam.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,17 @@ defmodule QuickBEAM do
270270
QuickBEAM.Runtime.stop(runtime)
271271
end
272272

273+
@doc """
274+
Get current JS coverage data for a runtime.
275+
276+
Returns `{:ok, %{filename => %{line => hit_count}}}`.
277+
Coverage must be enabled via `QuickBEAM.Cover`.
278+
"""
279+
@spec coverage(runtime()) :: {:ok, map()} | {:error, term()}
280+
def coverage(runtime) do
281+
GenServer.call(runtime, :get_coverage, :infinity)
282+
end
283+
273284
@doc """
274285
Evaluate TypeScript code by transforming it to JavaScript first.
275286

lib/quickbeam/cover.ex

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

lib/quickbeam/native.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ defmodule QuickBEAM.Native do
208208
wasm_read_memory: 3,
209209
wasm_write_memory: 3,
210210
wasm_read_global: 2,
211-
wasm_write_global: 3
211+
wasm_write_global: 3,
212+
enable_coverage: 1,
213+
get_coverage: 1,
214+
reset_coverage: 1
212215
]
213216
end

lib/quickbeam/quickbeam.zig

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,57 @@ pub fn memory_usage(resource: RuntimeResource) beam.term {
382382
return beam.term{ .v = e.enif_make_copy(env, ref_term) };
383383
}
384384

385+
pub fn enable_coverage(resource: RuntimeResource) beam.term {
386+
const data = resource.unpack();
387+
const env = beam.context.env orelse return beam.make(.{ .@"error", "no env" }, .{});
388+
389+
const caller_pid = get_caller_pid(env);
390+
const ref_env = beam.alloc_env();
391+
const ref_term = e.enif_make_ref(ref_env);
392+
393+
enqueue(data, .{ .enable_coverage = .{
394+
.caller_pid = caller_pid,
395+
.ref_env = ref_env,
396+
.ref_term = ref_term,
397+
} });
398+
399+
return beam.term{ .v = e.enif_make_copy(env, ref_term) };
400+
}
401+
402+
pub fn get_coverage(resource: RuntimeResource) beam.term {
403+
const data = resource.unpack();
404+
const env = beam.context.env orelse return beam.make(.{ .@"error", "no env" }, .{});
405+
406+
const caller_pid = get_caller_pid(env);
407+
const ref_env = beam.alloc_env();
408+
const ref_term = e.enif_make_ref(ref_env);
409+
410+
enqueue(data, .{ .get_coverage = .{
411+
.caller_pid = caller_pid,
412+
.ref_env = ref_env,
413+
.ref_term = ref_term,
414+
} });
415+
416+
return beam.term{ .v = e.enif_make_copy(env, ref_term) };
417+
}
418+
419+
pub fn reset_coverage(resource: RuntimeResource) beam.term {
420+
const data = resource.unpack();
421+
const env = beam.context.env orelse return beam.make(.{ .@"error", "no env" }, .{});
422+
423+
const caller_pid = get_caller_pid(env);
424+
const ref_env = beam.alloc_env();
425+
const ref_term = e.enif_make_ref(ref_env);
426+
427+
enqueue(data, .{ .reset_coverage = .{
428+
.caller_pid = caller_pid,
429+
.ref_env = ref_env,
430+
.ref_term = ref_term,
431+
} });
432+
433+
return beam.term{ .v = e.enif_make_copy(env, ref_term) };
434+
}
435+
385436
pub fn dom_find(resource: RuntimeResource, selector: []const u8) beam.term {
386437
return dom_op(resource, .find, selector, "");
387438
}

0 commit comments

Comments
 (0)