Packages

Detects new tests in git diff and runs them repeatedly to catch flaky tests

Current section

Files

Jump to
ex_flaky lib flaky parser.ex
Raw

lib/flaky/parser.ex

defmodule Flaky.Parser do
@moduledoc """
Pure functions for parsing git diffs and test output.
"""
@doc """
Parses git diff output and extracts new test names grouped by file.
## Examples
iex> diff = \"""
...> +++ b/test/foo_test.exs
...> + test "does something" do
...> \"""
iex> Flaky.Parser.parse_diff_for_test_names(diff)
%{"test/foo_test.exs" => ["does something"]}
"""
@spec parse_diff_for_test_names(String.t()) :: %{String.t() => list(String.t())}
def parse_diff_for_test_names(diff_output) do
diff_output
|> String.split("\n")
|> Enum.reduce({nil, %{}}, fn line, {current_file, acc} ->
cond do
String.starts_with?(line, "+++ b/test/") && String.ends_with?(line, "_test.exs") ->
file = String.trim_leading(line, "+++ b/")
{file, Map.put_new(acc, file, [])}
current_file != nil && is_new_test_line?(line) ->
name = extract_test_name(line)
{current_file, Map.update!(acc, current_file, &[name | &1])}
true ->
{current_file, acc}
end
end)
|> elem(1)
|> Map.new(fn {file, names} -> {file, Enum.reverse(names)} end)
end
@doc """
Checks if a diff line represents a new test definition.
## Examples
iex> Flaky.Parser.is_new_test_line?("+ test \\"my test\\" do")
true
iex> Flaky.Parser.is_new_test_line?("- test \\"my test\\" do")
false
iex> Flaky.Parser.is_new_test_line?(" test \\"my test\\" do")
false
"""
@spec is_new_test_line?(String.t()) :: boolean()
def is_new_test_line?(line) do
String.starts_with?(line, "+") &&
Regex.match?(~r/^\+\s*(test)\s+"/, line)
end
@doc """
Extracts the test name from a diff line.
## Examples
iex> Flaky.Parser.extract_test_name("+ test \\"my test\\" do")
"my test"
iex> Flaky.Parser.extract_test_name("invalid")
"unknown"
"""
@spec extract_test_name(String.t()) :: String.t()
def extract_test_name(line) do
case Regex.run(~r/^\+\s*(?:test)\s+"([^"]+)"/, line) do
[_, name] -> name
_ -> "unknown"
end
end
@doc """
Groups test targets by their file path.
## Examples
iex> Flaky.Parser.group_by_file(["test/foo_test.exs:10", "test/foo_test.exs:20", "test/bar_test.exs:5"])
[{"test/bar_test.exs", ["test/bar_test.exs:5"]}, {"test/foo_test.exs", ["test/foo_test.exs:10", "test/foo_test.exs:20"]}]
"""
@spec group_by_file(list(String.t())) :: list({String.t(), list(String.t())})
def group_by_file(test_targets) do
test_targets
|> Enum.group_by(fn target ->
target |> String.split(":") |> hd()
end)
|> Enum.map(fn {file, targets} -> {file, targets} end)
end
@doc """
Converts a test target path to a log filename.
## Examples
iex> Flaky.Parser.target_to_filename("test/foo/bar_test.exs:42")
"foo_bar_test_42.log"
iex> Flaky.Parser.target_to_filename("test/simple_test.exs:10")
"simple_test_10.log"
"""
@spec target_to_filename(String.t()) :: String.t()
def target_to_filename(target) do
target
|> String.replace("test/", "")
|> String.replace("/", "_")
|> String.replace(":", "_")
|> String.replace(".exs", "")
|> Kernel.<>(".log")
end
@doc """
Generates seed arguments for mix test.
## Examples
iex> Flaky.Parser.seed_args(nil)
[]
iex> Flaky.Parser.seed_args(12345)
["--seed", "12345"]
"""
@spec seed_args(integer() | nil) :: list(String.t())
def seed_args(nil), do: []
def seed_args(seed), do: ["--seed", to_string(seed)]
@doc """
Parses test output to find which targets actually failed.
## Examples
iex> output = " 1) test/foo_test.exs:10\\n some error"
iex> Flaky.Parser.parse_failed_tests(output, ["test/foo_test.exs:10", "test/foo_test.exs:20"])
["test/foo_test.exs:10"]
"""
@spec parse_failed_tests(String.t(), list(String.t())) :: list(String.t())
def parse_failed_tests(output, targets) do
failed_locations =
Regex.scan(~r/(test\/[^\s:]+_test\.exs):(\d+)/, output)
|> Enum.map(fn [_full, file, line] -> "#{file}:#{line}" end)
|> Enum.uniq()
Enum.filter(targets, fn target -> target in failed_locations end)
end
@doc """
Counts the number of "Finished in" occurrences in test output.
## Examples
iex> Flaky.Parser.count_finished("Finished in 0.1s\\nFinished in 0.2s")
2
iex> Flaky.Parser.count_finished("no matches here")
0
"""
@spec count_finished(String.t()) :: non_neg_integer()
def count_finished(data) do
data
|> String.split("\n")
|> Enum.count(&String.contains?(&1, "Finished in"))
end
@doc """
Finds the error section in log lines, starting from the first failure marker.
## Examples
iex> lines = ["some output", " 1) test failure", "details", "more details"]
iex> Flaky.Parser.find_error_section(lines)
[" 1) test failure", "details", "more details"]
"""
@spec find_error_section(list(String.t())) :: list(String.t())
def find_error_section(lines) do
start_index =
Enum.find_index(lines, fn line ->
String.contains?(line, " 1)") or String.contains?(line, "** (")
end)
case start_index do
nil -> Enum.take(lines, 10)
idx -> Enum.slice(lines, idx, 20)
end
end
@type test_info :: %{file: String.t(), line: pos_integer(), name: String.t()}
@type line_resolver :: (String.t(), String.t() -> pos_integer())
@doc """
Parses git diff output and returns a list of new tests with their locations.
Takes a line resolver function that finds the line number for a test name in a file.
This allows injecting different implementations for testing vs production.
## Examples
iex> diff = \"""
...> +++ b/test/foo_test.exs
...> + test "my test" do
...> \"""
iex> resolver = fn _file, _name -> 42 end
iex> Flaky.Parser.find_new_tests_from_diff(diff, resolver)
[%{file: "test/foo_test.exs", line: 42, name: "my test"}]
"""
@spec find_new_tests_from_diff(String.t(), line_resolver()) :: list(test_info())
def find_new_tests_from_diff(diff_output, line_resolver) do
diff_output
|> parse_diff_for_test_names()
|> resolve_line_numbers(line_resolver)
end
@spec resolve_line_numbers(%{String.t() => list(String.t())}, line_resolver()) ::
list(test_info())
defp resolve_line_numbers(test_names_by_file, line_resolver) do
Enum.flat_map(test_names_by_file, fn {file, names} ->
Enum.map(names, fn name ->
line = line_resolver.(file, name)
%{file: file, line: line, name: name}
end)
end)
end
end