From 4b9be76529b7a094172f721ea47ead7e9a34443c Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jan 2024 21:45:38 +0100 Subject: [PATCH] add custom ExUnit formatter in debug adapter return more test metadata do not filter returned tests by `test` and `doctest` test_type Fixes https://github.com/elixir-lsp/vscode-elixir-ls/issues/396 --- .../lib/debug_adapter/exunit_formatter.ex | 238 ++++++++++++++++++ .../debug_adapter/lib/debug_adapter/output.ex | 4 + .../language_server/ex_unit_test_tracer.ex | 60 +++-- 3 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex diff --git a/apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex b/apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex new file mode 100644 index 000000000..7b45916a5 --- /dev/null +++ b/apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex @@ -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 diff --git a/apps/debug_adapter/lib/debug_adapter/output.ex b/apps/debug_adapter/lib/debug_adapter/output.ex index 63f5cc9ad..9984b4472 100644 --- a/apps/debug_adapter/lib/debug_adapter/output.ex +++ b/apps/debug_adapter/lib/debug_adapter/output.ex @@ -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 = diff --git a/apps/language_server/lib/language_server/ex_unit_test_tracer.ex b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex index d968d5dcf..37c3523ce 100644 --- a/apps/language_server/lib/language_server/ex_unit_test_tracer.ex +++ b/apps/language_server/lib/language_server/ex_unit_test_tracer.ex @@ -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 @@ -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 %{ @@ -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