Current section

Files

Jump to
al_check lib check modified_tests.ex
Raw

lib/check/modified_tests.ex

defmodule Check.ModifiedTests do
@moduledoc """
Detects modified tests on the current branch vs base branch.
- If setup/setup_all/describe changed → runs the whole file
- Otherwise → runs only the specific modified test lines
"""
def run(test_opts \\ %{}) do
config = load_config()
case Check.Config.base_branch(config, warn: true, log: true) do
nil ->
{1, "Could not detect base branch. Set \"base_branch\" in .check.json"}
base_branch ->
log_committed_only(base_branch)
case get_modified_test_files(base_branch) do
{:error, msg} ->
{1, msg}
{:ok, []} ->
IO.puts([
IO.ANSI.format([:yellow, "Warning: no modified test files found on this branch"])
])
{0, ""}
{:ok, modified_files} ->
test_targets = Enum.flat_map(modified_files, &targets_for_file(&1, base_branch))
run_tests(test_targets, test_opts)
end
end
end
defp load_config do
case Check.Config.load() do
{:ok, config} -> config
_ -> %{}
end
end
defp log_committed_only(base_branch) do
IO.puts([
IO.ANSI.format([
:light_black,
" Note: only committed changes vs #{base_branch} are considered; uncommitted/working-tree changes are ignored"
])
])
end
defp get_modified_test_files(base_branch) do
range = Check.Git.committed_diff_range(base_branch)
case System.cmd(
"git",
["diff", "--name-only", "--diff-filter=d"] ++
range ++ ["--", ":(glob)test/**/*_test.exs"],
stderr_to_stdout: true
) do
{output, 0} ->
{:ok, output |> String.split("\n", trim: true) |> Enum.filter(&File.exists?/1)}
{error, _} ->
msg = "git diff failed: #{String.trim(error)}"
IO.puts([IO.ANSI.format([:red, msg])])
{:error, msg}
end
end
defp targets_for_file(file, base_branch) do
changed_lines = get_changed_lines(file, base_branch)
if Enum.empty?(changed_lines) do
[]
else
lines = File.read!(file) |> String.split("\n")
classify_and_run(file, changed_lines, lines)
end
end
defp classify_and_run(file, changed_lines, lines) do
has_module_setup? =
Enum.any?(changed_lines, fn line_num ->
line = Enum.at(lines, line_num - 1, "")
setup_line?(line) and find_enclosing_describe(lines, line_num) == nil
end)
if has_module_setup? do
IO.puts([
IO.ANSI.format([:yellow, " #{file} (module-level setup changed, running whole file)"])
])
[file]
else
targets =
changed_lines
|> Enum.map(fn line_num -> target_for_line(file, line_num, lines) end)
|> List.flatten()
|> Enum.uniq()
if Enum.empty?(targets) do
IO.puts([
IO.ANSI.format([:yellow, " #{file} (module-level change, running whole file)"])
])
[file]
else
Enum.each(targets, fn t -> IO.puts([IO.ANSI.format([:cyan, " #{t}"])]) end)
targets
end
end
end
defp target_for_line(file, line_num, lines) do
line = Enum.at(lines, line_num - 1, "")
cond do
setup_line?(line) -> target_from_describe(file, lines, line_num)
String.match?(line, ~r/^\s*describe\b/) -> ["#{file}:#{line_num}"]
true -> target_from_test_or_describe(file, lines, line_num)
end
end
defp target_from_describe(file, lines, line_num) do
case find_enclosing_describe(lines, line_num) do
nil -> []
desc_line -> ["#{file}:#{desc_line}"]
end
end
defp target_from_test_or_describe(file, lines, line_num) do
case find_enclosing_test(lines, line_num) do
nil -> target_from_describe(file, lines, line_num)
test_line -> ["#{file}:#{test_line}"]
end
end
defp setup_line?(line), do: String.match?(line, ~r/^\s*(setup|setup_all)\b/)
defp get_changed_lines(file, base_branch) do
range = Check.Git.committed_diff_range(base_branch)
{output, _status} =
System.cmd("git", ["diff", "-U0"] ++ range ++ ["--", file], stderr_to_stdout: true)
# Parse @@ hunk headers: @@ -old,count +new,count @@
~r/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|> Regex.scan(output)
|> Enum.flat_map(fn
[_, start, ""] ->
[String.to_integer(start)]
[_, start, count] ->
s = String.to_integer(start)
c = String.to_integer(count)
if c == 0, do: [], else: Enum.to_list(s..(s + c - 1))
[_, start] ->
[String.to_integer(start)]
end)
end
def find_enclosing_describe(lines, line_num) do
line_num..1//-1
|> Enum.find(fn num ->
line = Enum.at(lines, num - 1, "")
String.match?(line, ~r/^\s*describe\s+["(]/)
end)
end
# Walk backwards but stop at describe boundary to avoid crossing into another block
def find_enclosing_test(lines, line_num) do
line_num..1//-1
|> Enum.reduce_while(nil, fn num, _acc ->
line = Enum.at(lines, num - 1, "")
cond do
String.match?(line, ~r/^\s*test\s+["(]/) -> {:halt, num}
String.match?(line, ~r/^\s*describe\s+["(]/) -> {:halt, nil}
true -> {:cont, nil}
end
end)
end
defp run_tests(targets, test_opts) do
extra = extra_args(test_opts)
args = ["test" | targets] ++ extra
extra_str = Enum.join(extra, " ")
IO.puts([
IO.ANSI.format([
:cyan,
"\nTest command: mix test [#{length(targets)} tests] #{extra_str}\n"
])
])
port = Check.Port.open("mix", args)
Check.Runner.stream_and_capture_port(port)
end
# Coverage is intentionally not reported here: modified_tests runs only the
# selected test lines, so per-file coverage would be misleadingly low.
# See Check.ModifiedTestModules for the whole-file path that does report it.
defp extra_args(opts) do
test_args = if opts[:test_args], do: String.split(opts[:test_args]), else: []
repeat = if opts[:repeat], do: ["--repeat-until-failure", to_string(opts[:repeat])], else: []
test_args ++ repeat
end
end