lib/foundry/test_scenario/runtime_capture.ex

defmodule Foundry.TestScenario.RuntimeCapture do
  @moduledoc false

  @trace_key :foundry_test_scenario_trace
  @context_key :foundry_test_scenario_trace_context

  def capture(context, fun) when is_map(context) and is_function(fun, 0) do
    metadata = trace_metadata(context)
    previous_trace = Process.get(@trace_key)
    Process.put(@trace_key, %{metadata: metadata, events: [], sequence: 0})

    try do
      result = fun.()
      drain_liveview_events()
      flush_trace(:ok)
      result
    catch
      kind, reason ->
        drain_liveview_events()
        flush_trace({kind, reason})
        :erlang.raise(kind, reason, __STACKTRACE__)
    after
      restore_previous_trace(previous_trace)
    end
  end

  def current_trace_id do
    case Process.get(@trace_key) do
      %{metadata: metadata} -> trace_id(metadata)
      _ -> nil
    end
  end

  def with_current_trace_context(fun) when is_function(fun, 0) do
    previous_context = Process.get(@context_key)

    try do
      case current_trace_id() do
        trace_id when is_binary(trace_id) ->
          Process.put(@context_key, %{trace_id: trace_id})

        _ ->
          :ok
      end

      fun.()
    after
      restore_previous_context(previous_context)
    end
  end

  def trace_call(attrs, fun) when is_map(attrs) and is_function(fun, 0) do
    with_current_trace_context(fn ->
      if persist_trace_call?(attrs) do
        trace_node(Map.get(attrs, :node_id), attrs)
      end

      fun.()
    end)
  end

  def trace_node(node_id, event_attrs \\ %{}) when is_binary(node_id) and is_map(event_attrs) do
    case Process.get(@trace_key) do
      %{events: events, sequence: seq} = trace ->
        event =
          event_attrs
          |> Map.put_new(:node_id, node_id)
          |> Map.put_new(:status, :passed)
          |> Map.put_new(:provenance, :executed)
          |> Map.put_new(:sequence, seq + 1)
          |> Map.put_new(:focus_node_id, Map.get(event_attrs, :node_id, node_id))

        Process.put(@trace_key, %{trace | events: [event | events], sequence: seq + 1})
        :ok

      _ ->
        :ok
    end
  end

  defp persist_trace_call?(%{module_function: module_function}) when is_binary(module_function) do
    not duplicate_ash_runtime_call?(module_function)
  end

  defp persist_trace_call?(%{"module_function" => module_function})
       when is_binary(module_function) do
    not duplicate_ash_runtime_call?(module_function)
  end

  defp persist_trace_call?(%{node_id: node_id}) when is_binary(node_id), do: true
  defp persist_trace_call?(%{"node_id" => node_id}) when is_binary(node_id), do: true
  defp persist_trace_call?(_attrs), do: false

  defp duplicate_ash_runtime_call?(module_function) do
    module_function in [
      "Ash.create",
      "Ash.read",
      "Ash.read_one",
      "Ash.get",
      "Ash.update",
      "Ash.destroy"
    ]
  end

  defp flush_trace(outcome) do
    case Process.get(@trace_key) do
      %{metadata: metadata, events: events} when events != [] ->
        trace_dir = Path.join(File.cwd!(), ".foundry/scenario_traces")
        File.mkdir_p!(trace_dir)

        payload =
          metadata
          |> Map.put(:outcome, normalize_outcome(outcome))
          |> Map.put(:captured_at, DateTime.utc_now() |> DateTime.to_iso8601())
          |> Map.put(:events, Enum.reverse(events))

        file_name = trace_file_name(metadata)

        File.write!(Path.join(trace_dir, file_name), Jason.encode!(payload, pretty: true))

      _ ->
        :ok
    end
  end

  defp trace_metadata(context) do
    source_module = context.module |> Atom.to_string() |> String.trim_leading("Elixir.")
    describe_name = context[:describe] || "Scenario"
    test_name = normalize_test_name(context[:test])

    %{
      scenario_id: scenario_id(source_module, describe_name),
      source_module: source_module,
      describe_name: describe_name,
      test_name: test_name,
      file: context[:file],
      line: context[:line]
    }
  end

  defp trace_id(metadata) do
    metadata
    |> Map.take([:scenario_id, :test_name, :line])
    |> :erlang.term_to_binary()
    |> Base.url_encode64(padding: false)
  end

  defp normalize_test_name(nil), do: "scenario"

  defp normalize_test_name(test_name) do
    test_name
    |> to_string()
    |> String.replace_prefix("test ", "")
    |> String.replace_prefix("property ", "")
  end

  defp scenario_id(source_module, describe_name) do
    suffix =
      describe_name
      |> to_string()
      |> String.downcase()
      |> String.replace(~r/[^a-z0-9]+/u, "_")
      |> String.trim("_")

    "#{source_module}.#{suffix}"
  end

  defp normalize_outcome(:ok), do: "ok"
  defp normalize_outcome({kind, reason}), do: "#{kind}:#{Exception.format_banner(kind, reason)}"

  defp safe_segment(value) do
    value
    |> to_string()
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9]+/u, "_")
    |> String.trim("_")
  end

  defp trace_file_name(metadata) do
    "#{safe_segment(metadata.scenario_id)}--#{trace_hash(metadata)}.json"
  end

  defp trace_hash(metadata) do
    metadata
    |> Map.take([:scenario_id, :test_name, :line])
    |> :erlang.term_to_binary()
    |> :erlang.phash2()
    |> Integer.to_string(36)
  end

  defp restore_previous_trace(nil), do: Process.delete(@trace_key)
  defp restore_previous_trace(previous_trace), do: Process.put(@trace_key, previous_trace)
  defp restore_previous_context(nil), do: Process.delete(@context_key)
  defp restore_previous_context(previous_context), do: Process.put(@context_key, previous_context)

  defp drain_liveview_events do
    case Process.get(@trace_key) do
      %{metadata: metadata} ->
        metadata
        |> trace_id()
        |> drain_pending_events()

        :ok

      _ ->
        :ok
    end

    drain_mailbox_events()
  end

  defp persist_event(event_attrs, trace, seq) do
    event =
      event_attrs
      |> Map.put_new(:status, :passed)
      |> Map.put_new(:provenance, :executed)
      |> Map.put_new(:sequence, seq + 1)
      |> Map.put_new(:focus_node_id, Map.get(event_attrs, :node_id))

    updated_trace = %{trace | events: [event | trace.events], sequence: seq + 1}
    Process.put(@trace_key, updated_trace)
    updated_trace
  end

  defp drain_mailbox_events do
    receive do
      {:foundry_ash_event, event_attrs} ->
        case Process.get(@trace_key) do
          %{sequence: seq} = trace ->
            event_attrs
            |> Map.put_new(:node_id, Map.get(event_attrs, :node_id))
            |> persist_event(trace, seq)

          _ ->
            :ok
        end

        drain_mailbox_events()
    after
      0 -> :ok
    end
  end

  defp drain_pending_events(trace_id, attempts_left \\ 6)

  defp drain_pending_events(_trace_id, attempts_left) when attempts_left <= 0 do
    drain_available_events(nil)
    :ok
  end

  defp drain_pending_events(trace_id, attempts_left) do
    drained_count = drain_available_events(trace_id)

    if drained_count == 0 do
      Process.sleep(5)
    end

    drain_pending_events(trace_id, attempts_left - 1)
  end

  defp drain_available_events(trace_id) do
    buffered_count = drain_buffered_events(trace_id)
    mailbox_count = drain_mailbox_events_count()
    buffered_count + mailbox_count
  end

  defp drain_buffered_events(nil), do: 0

  defp drain_buffered_events(trace_id) do
    trace_id
    |> Foundry.TestScenario.EventBuffer.take()
    |> Enum.reduce(0, fn event_attrs, count ->
      case Process.get(@trace_key) do
        %{sequence: seq} = trace ->
          event_attrs
          |> Map.put_new(:node_id, Map.get(event_attrs, :node_id))
          |> persist_event(trace, seq)

          count + 1

        _ ->
          count
      end
    end)
  end

  defp drain_mailbox_events_count do
    receive do
      {:foundry_ash_event, event_attrs} ->
        count =
          case Process.get(@trace_key) do
            %{sequence: seq} = trace ->
              event_attrs
              |> Map.put_new(:node_id, Map.get(event_attrs, :node_id))
              |> persist_event(trace, seq)

              1

            _ ->
              0
          end

        count + drain_mailbox_events_count()
    after
      0 -> 0
    end
  end
end