defmodule AiDebug.LiveViewRecorder do @moduledoc """ Records and replays LiveView events for debugging and testing. This module provides comprehensive event recording with the ability to: - Record all LiveView interactions - Save recordings to disk - Replay recordings with timing - Generate tests from recordings - Analyze interaction patterns """ use GenServer require Logger defstruct [ :id, :target_pid, :start_time, :events, :metadata, :status, :options ] @doc """ Start recording a LiveView session. ## Options - `:target` - PID or module of the LiveView to record - `:include_state` - Include socket state snapshots (default: true) - `:include_dom` - Include DOM snapshots (default: false) - `:max_events` - Maximum events to record (default: 1000) """ def start_recording(target, opts \\ []) do GenServer.start_link(__MODULE__, {target, opts}) end @doc """ Stop recording and return the recorded session. """ def stop_recording(recorder) do GenServer.call(recorder, :stop_recording) end @doc """ Get the current recording without stopping. """ def get_recording(recorder) do GenServer.call(recorder, :get_recording) end @doc """ Save a recording to disk. """ def save_recording(recording, path) do data = :erlang.term_to_binary(recording) File.write!(path, data) {:ok, path} end @doc """ Load a recording from disk. """ def load_recording(path) do data = File.read!(path) {:ok, :erlang.binary_to_term(data)} end @doc """ Replay a recorded session on a LiveView. ## Options - `:speed` - Playback speed multiplier (default: 1.0) - `:skip_delays` - Skip all delays (default: false) - `:stop_on_error` - Stop replay on error (default: true) """ def replay_recording(target_pid, recording, opts \\ []) do speed = Keyword.get(opts, :speed, 1.0) skip_delays = Keyword.get(opts, :skip_delays, false) stop_on_error = Keyword.get(opts, :stop_on_error, true) Task.async(fn -> replay_events(target_pid, recording.events, speed, skip_delays, stop_on_error) end) end @doc """ Generate a test from a recording. """ def generate_test(recording, test_name \\ "recorded_test") do events = recording.events test_code = """ test "#{test_name}" do {:ok, view, _html} = live(conn, "#{recording.metadata.path}") #{generate_test_assertions(events)} end """ {:ok, test_code} end @doc """ Analyze a recording for patterns and insights. """ def analyze_recording(recording) do events = recording.events %{ total_events: length(events), duration_ms: calculate_duration(recording), event_types: count_event_types(events), interaction_flow: extract_interaction_flow(events), performance_metrics: calculate_performance_metrics(events), state_changes: analyze_state_changes(events) } end # GenServer callbacks def init({target, opts}) do target_pid = resolve_target(target) state = %__MODULE__{ id: generate_id(), target_pid: target_pid, start_time: System.monotonic_time(:millisecond), events: [], metadata: collect_metadata(target_pid, opts), status: :recording, options: opts } # Start tracing setup_tracing(target_pid) # Subscribe to LiveView events if is_atom(target) do AiDebug.LiveViewLogger.subscribe(target) end {:ok, state} end def handle_call(:stop_recording, _from, state) do # Stop tracing :erlang.trace(state.target_pid, false, :all) recording = build_recording(state) {:reply, {:ok, recording}, %{state | status: :stopped}} end def handle_call(:get_recording, _from, state) do recording = build_recording(state) {:reply, {:ok, recording}, state} end def handle_info({:trace, pid, :receive, msg}, state) when pid == state.target_pid do case parse_liveview_message(msg) do {:ok, event} -> event = enrich_event(event, state) {:noreply, add_event(state, event)} :ignore -> {:noreply, state} end end def handle_info({:liveview_event, event_type, _module, metadata}, state) do # Handle LiveViewLogger events event = %{ type: :lifecycle, name: event_type, timestamp: System.monotonic_time(:millisecond) - state.start_time, metadata: metadata } {:noreply, add_event(state, event)} end def handle_info(_, state) do {:noreply, state} end # Private functions defp resolve_target(pid) when is_pid(pid), do: pid defp resolve_target(module) when is_atom(module) do case AiDebug.LiveViewDebug.find_liveviews_by_module(module) do [%{pid: pid} | _] -> pid [] -> raise "No LiveView found for module #{module}" end end defp generate_id do :crypto.strong_rand_bytes(16) |> Base.encode16() end defp collect_metadata(target_pid, opts) do info = AiDebug.LiveViewDebug.inspect_liveview(target_pid) %{ module: info.module, path: get_liveview_path(info.module), initial_assigns: info.assigns, components: info.components, recorded_at: DateTime.utc_now(), options: opts } end defp get_liveview_path(module) do # This would need to be implemented based on your router # For now, return a placeholder "/#{module |> Module.split() |> List.last() |> Macro.underscore()}" end defp setup_tracing(target_pid) do :erlang.trace(target_pid, true, [:receive, {:tracer, self()}]) end defp parse_liveview_message({:"$gen_call", from, {:phoenix_live_view, {:event, event, value, metadata}}}) do {:ok, %{ type: :event, name: event, value: value, metadata: metadata, from: from }} end defp parse_liveview_message({:phoenix_live_view, {:event, event, value, metadata}}) do {:ok, %{ type: :event, name: event, value: value, metadata: metadata }} end defp parse_liveview_message(_), do: :ignore defp enrich_event(event, state) do event |> Map.put(:timestamp, System.monotonic_time(:millisecond) - state.start_time) |> maybe_add_state_snapshot(state) |> maybe_add_dom_snapshot(state) end defp maybe_add_state_snapshot(event, state) do if Keyword.get(state.options, :include_state, true) do case AiDebug.LiveViewDebug.inspect_liveview(state.target_pid) do {:ok, info} -> Map.put(event, :state_snapshot, %{ assigns: info.assigns, components: info.components }) _ -> event end else event end end defp maybe_add_dom_snapshot(event, state) do if Keyword.get(state.options, :include_dom, false) do # This would capture DOM via the browser connection # For now, just mark that DOM capture was requested Map.put(event, :dom_requested, true) else event end end defp add_event(state, event) do max_events = Keyword.get(state.options, :max_events, 1000) events = [event | state.events] events = if length(events) > max_events do Enum.take(events, max_events) else events end %{state | events: events} end defp build_recording(state) do %{ id: state.id, events: Enum.reverse(state.events), metadata: state.metadata, duration_ms: System.monotonic_time(:millisecond) - state.start_time, status: state.status } end defp replay_events(target_pid, events, speed, skip_delays, stop_on_error) do last_timestamp = 0 Enum.reduce_while(events, last_timestamp, fn event, last_ts -> # Calculate delay delay = if skip_delays do 0 else round((event.timestamp - last_ts) / speed) end # Wait if needed if delay > 0, do: Process.sleep(delay) # Replay the event result = replay_single_event(target_pid, event) case result do {:ok, _} -> {:cont, event.timestamp} {:error, reason} -> Logger.error("Replay error: #{inspect(reason)}") if stop_on_error do {:halt, {:error, reason}} else {:cont, event.timestamp} end end end) end defp replay_single_event(target_pid, %{type: :event} = event) do AiDebug.LiveViewDebugger.send_event( target_pid, event.name, event.value, wait_for_response: false ) end defp replay_single_event(_target_pid, %{type: :lifecycle}) do # Lifecycle events are not replayable {:ok, :skipped} end defp generate_test_assertions(events) do events |> Enum.filter(& &1.type == :event) |> Enum.map(&generate_single_assertion/1) |> Enum.join("\n ") end defp generate_single_assertion(%{name: name, value: value}) do selector = Map.get(value, "selector", "") val = Map.get(value, "value", %{}) case name do "click" -> "view |> element(\"#{selector}\") |> render_click()" "change" -> "view |> element(\"#{selector}\") |> render_change(#{inspect(val)})" "submit" -> "view |> element(\"#{selector}\") |> render_submit(#{inspect(val)})" _ -> "# TODO: Handle event \"#{name}\"" end end defp calculate_duration(recording) do recording.duration_ms end defp count_event_types(events) do events |> Enum.group_by(& &1.type) |> Enum.map(fn {type, events} -> {type, length(events)} end) |> Enum.into(%{}) end defp extract_interaction_flow(events) do events |> Enum.filter(& &1.type == :event) |> Enum.map(& &1.name) end defp calculate_performance_metrics(events) do response_times = events |> Enum.chunk_every(2, 1) |> Enum.map(fn [e1, e2] -> e2.timestamp - e1.timestamp _ -> nil end) |> Enum.reject(&is_nil/1) %{ avg_response_time: average(response_times), max_response_time: Enum.max(response_times, fn -> 0 end), min_response_time: Enum.min(response_times, fn -> 0 end) } end defp analyze_state_changes(events) do events |> Enum.filter(& Map.has_key?(&1, :state_snapshot)) |> Enum.chunk_every(2, 1) |> Enum.map(fn [e1, e2] -> diff_assigns(e1.state_snapshot.assigns, e2.state_snapshot.assigns) _ -> %{} end) end defp diff_assigns(assigns1, assigns2) do keys = MapSet.union(MapSet.new(Map.keys(assigns1)), MapSet.new(Map.keys(assigns2))) Enum.reduce(keys, %{}, fn key, acc -> v1 = Map.get(assigns1, key) v2 = Map.get(assigns2, key) if v1 != v2 do Map.put(acc, key, %{from: v1, to: v2}) else acc end end) end defp average([]), do: 0 defp average(list), do: Enum.sum(list) / length(list) end