Skip to content

Commit

Permalink
add custom ExUnit formatter in debug adapter
Browse files Browse the repository at this point in the history
return more test metadata
do not filter returned tests by `test` and `doctest` test_type
Fixes elixir-lsp/vscode-elixir-ls#396
  • Loading branch information
lukaszsamson committed Jan 14, 2024
1 parent 7b3344c commit 4b9be76
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 26 deletions.
238 changes: 238 additions & 0 deletions apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
defmodule ElixirLS.DebugAdapter.ExUnitFormatter do
use GenServer
alias ElixirLS.DebugAdapter.Output

@width 80

def start_link(args) do
GenServer.start_link(__MODULE__, Keyword.delete(args, :name),
name: Keyword.get(args, :name, __MODULE__)
)
end

@impl true
def init(_args) do
{:ok,
%{
failure_counter: 0
}}
end

@impl true
def terminate(reason, _state) do
case reason do
:normal ->
:ok

:shutdown ->
:ok

{:shutdown, _} ->
:ok

_other ->
message = Exception.format_exit(reason)

Output.telemetry(
"dap_server_error",
%{
"elixir_ls.dap_process" => inspect(__MODULE__),
"elixir_ls.dap_server_error" => message
},
%{}
)

Output.debugger_important("Terminating #{__MODULE__}: #{message}")
end

:ok
end

@impl true
def handle_cast({:suite_started, _opts}, state) do
# the suite has started with the specified options to the runner
# we don't need to do anything
{:noreply, state}
end

def handle_cast({:suite_finished, _times_us}, state) do
# the suite has finished. Returns several measurements in microseconds for running the suite
# not interesting
{:noreply, state}
end

def handle_cast({:module_started, %ExUnit.TestModule{}}, state) do
# a test module has started
# we report on individual tests
{:noreply, state}
end

def handle_cast({:module_finished, %ExUnit.TestModule{}}, state) do
# a test module has finished
# we report on individual tests
{:noreply, state}
end

def handle_cast({:test_started, test = %ExUnit.Test{}}, state) do
# a test has started
case test.state do
nil ->
# initial state
Output.ex_unit_event(%{
"event" => "test_started",
"type" => test.tags.test_type,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file
})

{:skipped, _} ->
# Skipped via @tag :skip
Output.ex_unit_event(%{
"event" => "test_skipped",
"type" => test.tags.test_type,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file
})

{:excluded, _} ->
# Excluded via :exclude filters
Output.ex_unit_event(%{
"event" => "test_excluded",
"type" => test.tags.test_type,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file
})

_ ->
:ok
end

{:noreply, state}
end

def handle_cast({:test_finished, test = %ExUnit.Test{}}, state) do
# a test has finished
state =
case test.state do
nil ->
# Passed
Output.ex_unit_event(%{
"event" => "test_passed",
"type" => test.tags.test_type,
"time" => test.time,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file
})

state

{:excluded, _} ->
# Excluded via :exclude filters
state

{:failed, failures} ->
# Failed
formatter_cb = fn _key, value -> value end

message =
ExUnit.Formatter.format_test_failure(
test,
failures,
state.failure_counter + 1,
@width,
formatter_cb
)

Output.ex_unit_event(%{
"event" => "test_failed",
"type" => test.tags.test_type,
"time" => test.time,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file,
"message" => message
})

%{state | failure_counter: state.failure_counter + 1}

{:invalid, test_module = %ExUnit.TestModule{state: {:failed, failures}}} ->
# Invalid (when setup_all fails)
formatter_cb = fn _key, value -> value end

message =
ExUnit.Formatter.format_test_all_failure(
test_module,
failures,
state.failure_counter + 1,
@width,
formatter_cb
)

Output.ex_unit_event(%{
"event" => "test_errored",
"type" => test.tags.test_type,
"name" => test_name(test),
"describe" => test.tags.describe,
"module" => inspect(test.module),
"file" => test.tags.file,
"message" => message
})

%{state | failure_counter: state.failure_counter + 1}

{:skipped, _} ->
# Skipped via @tag :skip
state
end

{:noreply, state}
end

def handle_cast({:sigquit, _tests}, state) do
# the VM is going to shutdown. It receives the test cases (or test module in case of setup_all) still running
# we probably don't need to do anything
{:noreply, state}
end

def handle_cast(:max_failures_reached, state) do
# undocumented event - we probably don't need to do anything
{:noreply, state}
end

def handle_cast({:case_started, _test_case}, state) do
# deprecated event, ignore
# TODO remove when we require elixir 2.0
{:noreply, state}
end

def handle_cast({:case_finished, _test_case}, state) do
# deprecated event, ignore
# TODO remove when we require elixir 2.0
{:noreply, state}
end

# TODO extract to common module
defp test_name(test = %ExUnit.Test{}) do
describe = test.tags.describe
# drop test prefix
test_name = drop_test_prefix(test.name, test.tags.test_type)

if describe != nil do
test_name |> String.replace_prefix(describe <> " ", "")
else
test_name
end
end

defp drop_test_prefix(test_name, kind),
do: test_name |> Atom.to_string() |> String.replace_prefix(Atom.to_string(kind) <> " ", "")
end
4 changes: 4 additions & 0 deletions apps/debug_adapter/lib/debug_adapter/output.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ defmodule ElixirLS.DebugAdapter.Output do
send_event(server, "output", %{"category" => "stderr", "output" => maybe_append_newline(str)})
end

def ex_unit_event(server \\ __MODULE__, data) when is_map(data) do
send_event(server, "output", %{"category" => "ex_unit", "output" => "", "data" => data})
end

def telemetry(server \\ __MODULE__, event, properties, measurements)
when is_binary(event) and is_map(properties) and is_map(measurements) do
elixir_release =
Expand Down
60 changes: 34 additions & 26 deletions apps/language_server/lib/language_server/ex_unit_test_tracer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,10 @@ defmodule ElixirLS.LanguageServer.ExUnitTestTracer do
test_info
|> Enum.group_by(fn %ExUnit.Test{tags: tags} -> {tags.describe, tags.describe_line} end)
|> Enum.map(fn {{describe, describe_line}, tests} ->
grouped_tests = Enum.group_by(tests, fn %ExUnit.Test{tags: tags} -> tags.test_type end)

tests =
grouped_tests
|> Map.get(:test, [])
|> Enum.map(fn %ExUnit.Test{tags: tags} = test ->
for %ExUnit.Test{tags: tags} = test <- tests do
# drop test prefix
test_name = drop_test_prefix(test.name)
test_name = drop_test_prefix(test.name, tags.test_type)

test_name =
if describe != nil do
Expand All @@ -129,26 +125,29 @@ defmodule ElixirLS.LanguageServer.ExUnitTestTracer do
test_name
end

selected_tags =
for {tag, value} <- tags, tag in [:async, :test_type, :doctest, :doctest_line] do
"#{tag}:#{format_tag(tag, value)}"
end

doctest_module_path =
case tags[:doctest] do
nil ->
nil

module ->
if Code.ensure_loaded?(module) do
to_string(module.module_info(:compile)[:source])
end
end

%{
name: test_name,
type: tags.test_type,
line: tags.line - 1
line: tags.line - 1,
doctest_module_path: doctest_module_path,
tags: selected_tags
}
end)

tests =
case grouped_tests do
%{doctest: [doctest | _]} ->
test_meta = %{
name: "doctest #{inspect(doctest.tags.doctest)}",
line: doctest.tags.line - 1,
type: :doctest
}

[test_meta | tests]

_ ->
tests
end

%{
Expand All @@ -168,9 +167,18 @@ defmodule ElixirLS.LanguageServer.ExUnitTestTracer do
:ok
end

defp drop_test_prefix(test_name) when is_atom(test_name),
do: test_name |> Atom.to_string() |> drop_test_prefix
defp drop_test_prefix(test_name, kind),
do: test_name |> Atom.to_string() |> String.replace_prefix(Atom.to_string(kind) <> " ", "")

defp format_tag(tag, value) when tag in [:doctest, :module] do
inspect(value)
end

defp drop_test_prefix("test " <> rest), do: rest
defp drop_test_prefix(test_name), do: test_name
defp format_tag(:doctest_line, value) do
to_string(value - 1)
end

defp format_tag(_tag, value) do
to_string(value)
end
end

0 comments on commit 4b9be76

Please sign in to comment.