defmodule Mneme.Server do @moduledoc false # Other than being the interface between a test and the Patcher, the # server is primarily responsible for managing IO. Because tests can # run asynchronously, we have to carefully control output so that test # results are not written to the terminal while we're prompting the # user for input. # # To do this, we replace ExUnit's default formatter with our own (see # Mneme.ExUnitFormatter) that delegates to ExUnit's formatter and # allows us to capture the formatter output. It additionall notifies # this server of test events so that we can flush IO at the right time. # # Mneme options are additionally received via test tags, which we get # via the formatter. It's possible that an auto-assertion runs before # the formatter notifies us that the test has started (via a # :test_started event), in which case we need to delay the assertion # until we have the test's tags. use GenServer alias Mneme.Assertion alias Mneme.Options alias Mneme.Patcher defstruct [ :patch_state, :io_pid, :current_module, to_patch: [], stats: %{ counter: 0, new: 0, updated: 0, skipped: 0, rejected: 0, wip: 0 } ] @type t :: %__MODULE__{ patch_state: any(), io_pid: pid(), current_module: module(), to_patch: [{any(), from :: pid()}], stats: %{ counter: non_neg_integer(), new: non_neg_integer(), updated: non_neg_integer(), skipped: non_neg_integer(), rejected: non_neg_integer(), wip: non_neg_integer() } } @doc """ Returns `true` if the server has started. """ def started?, do: !!GenServer.whereis(__MODULE__) @doc """ Start a Mneme server. """ def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Register a new assertion. This will not return until the test has been reported as started. """ def register_assertion(assertion) do GenServer.call(__MODULE__, {:register_assertion, assertion}, :infinity) end @doc """ Await the result of an assertion patch. """ def patch_assertion(assertion) do GenServer.call(__MODULE__, {:patch_assertion, assertion}, :infinity) end @doc false def on_formatter_init do {:ok, io_pid} = StringIO.open("") Process.group_leader(self(), io_pid) GenServer.call(__MODULE__, {:capture_formatter, io_pid}, :infinity) end @doc false def on_formatter_event(message) do GenServer.call(__MODULE__, {:formatter, message}, :infinity) end @impl true def init(_opts) do {:ok, %__MODULE__{patch_state: Patcher.init()}} end @impl true def handle_call({:register_assertion, %Assertion{stage: :new} = assertion}, from, state) do state = Map.update!(state, :to_patch, &[{assertion, from} | &1]) {:noreply, state, {:continue, :process_next}} end def handle_call({:register_assertion, %Assertion{stage: :update} = assertion}, from, state) do %{options: opts} = assertion if opts.force_update || opts.target == :ex_unit do state = Map.update!(state, :to_patch, &[{assertion, from} | &1]) {:noreply, state, {:continue, :process_next}} else {:reply, {:ok, assertion}, state, {:continue, :process_next}} end end def handle_call({:patch_assertion, assertion}, from, state) do state = Map.update!(state, :to_patch, &[{assertion, from} | &1]) {:noreply, state, {:continue, :process_next}} end def handle_call({:capture_formatter, io_pid}, _from, state) do {:reply, :ok, state |> Map.put(:io_pid, io_pid)} end def handle_call({:formatter, {:test_started, test}}, _from, state) do opts = Options.options(test.tags) state = inc_stat(state, :wip, if: opts[:wip]) {:reply, :ok, state, {:continue, :process_next}} end def handle_call({:formatter, {:module_finished, %{name: mod}}}, _from, state) do if state.current_module == mod do state = state |> Map.put(:current_module, nil) |> flush_io() {:reply, :ok, state, {:continue, :process_next}} else {:reply, :ok, state} end end def handle_call({:formatter, {:suite_finished, _}}, _from, state) do {:reply, :ok, state |> finalize() |> flush_io()} end def handle_call({:formatter, _msg}, _from, %{current_module: nil} = state) do {:reply, :ok, flush_io(state)} end def handle_call({:formatter, _msg}, _from, state) do {:reply, :ok, state} end @impl true def handle_continue(:process_next, state) do case pop_to_patch(state) do {next, state} -> {:noreply, do_patch_assertion(state, next)} nil -> {:noreply, state} end end defp do_patch_assertion(state, {assertion, from}) do {state, counter} = inc_and_return_stat(state, :counter) {reply, patch_state} = Patcher.patch!(state.patch_state, assertion, counter) GenServer.reply(from, reply) case {reply, assertion.stage} do {{:ok, _}, :new} -> inc_stat(state, :new) {{:ok, _}, :update} -> inc_stat(state, :updated) {{:error, :skipped}, _} -> inc_stat(state, :skipped) {{:error, :rejected}, _} -> inc_stat(state, :rejected) _ -> state end |> Map.put(:patch_state, patch_state) |> Map.put(:current_module, assertion.context.module) end defp flush_io(%{io_pid: io_pid} = state) do output = StringIO.flush(io_pid) if output != "", do: IO.write(output) state end defp pop_to_patch(state), do: pop_to_patch(state, []) defp pop_to_patch(%{to_patch: []}, _acc), do: nil defp pop_to_patch(%{to_patch: [next | rest]} = state, acc) do {%Assertion{context: %{module: module}}, _from} = next if current_module?(state, module) do {next, %{state | to_patch: acc ++ rest}} else pop_to_patch(%{state | to_patch: rest}, [next | acc]) end end defp current_module?(%{current_module: nil}, _), do: true defp current_module?(%{current_module: mod}, mod), do: true defp current_module?(_state, _mod), do: false defp finalize(%{stats: stats} = state) do case Patcher.finalize!(state.patch_state) do :ok -> :ok {:error, {:not_saved, files}} -> ensure_exit_with_error!(:not_saved, files) end if stats.skipped > 0 do ensure_exit_with_error!() end print_summary(stats) state end defp ensure_exit_with_error!(:not_saved, files) do ensure_exit_with_error!(fn -> message = [ "Could not save the following files (possibly because their content changed):\n\n", Enum.map(files, &[" * ", &1, "\n"]) ] ["\n", Owl.Data.tag(["[Mneme] ", message], :red)] |> Owl.IO.puts() end) end defp ensure_exit_with_error!(fun \\ nil) do System.at_exit(fn _ -> fun && fun.() exit_status = ExUnit.configuration() |> Keyword.fetch!(:exit_status) exit({:shutdown, exit_status}) end) end defp inc_stat(state, stat, opts \\ [if: true]) defp inc_stat(state, stat, if: true) do Map.update!(state, :stats, fn stats -> Map.update!(stats, stat, &(&1 + 1)) end) end defp inc_stat(state, _, if: false), do: state defp inc_and_return_stat(state, stat) do state = inc_stat(state, stat) {state, state.stats[stat]} end defp print_summary(stats) do formatted = for stat <- [:new, :updated, :rejected, :skipped, :wip], stats[stat] != 0 do format_stat(stat, stats[stat]) end unless formatted == [] do Owl.IO.puts([ "\n\n[Mneme] ", Enum.intersperse(formatted, ", ") ]) end end defp format_stat(:new, n), do: Owl.Data.tag("#{n} new", :green) defp format_stat(:updated, n), do: Owl.Data.tag("#{n} updated", :green) defp format_stat(:rejected, n), do: Owl.Data.tag("#{n} rejected", :red) defp format_stat(:skipped, n), do: Owl.Data.tag("#{n} skipped", :yellow) defp format_stat(:wip, n), do: Owl.Data.tag("#{n} wip", :magenta) end