lib/excessibility/capture_html.ex

defmodule Excessibility.CaptureHTML do
  @moduledoc """
  Functionality to capture LiveView screenshots for debugging.
  """

  use Wallaby.DSL

  import Phoenix.ConnTest, only: [html_response: 2]

  alias Phoenix.LiveViewTest.DOM
  alias Phoenix.LiveViewTest.Element, as: LiveElement
  alias Phoenix.LiveViewTest.View
  alias Plug.Conn
  alias Wallaby.Element
  alias Wallaby.Session

  @output_path Application.compile_env(
                 :excessibility,
                 :excessibility_output_path,
                 "test/excessibility"
               )
  @snapshots_path "#{@output_path}/html_snapshots"

  @doc """
  Captures an HTML snapshot of a LiveView (View or Element), EuUnit Conn, or Wallaby Session under test.

  ## Examples

      defmodule MyAppWeb.PageLiveTest do
        use MyAppWeb, :live_view
        import Excessibility.CaptureHTML

        test "does the thing", %{conn: conn} end
          {:ok, view, _} = live(conn, "/")

          assert view
                 |> capture_html!()
                 |> render() =~ "The thing is done!"
        end
      end
  """
  def capture_html!(conn_or_session, env, module, opts)
      when is_struct(conn_or_session, Conn)
      when is_struct(conn_or_session, Session) do
    filename = get_filename(env, module)

    conn_or_session
    |> get_html()
    |> maybe_wrap_html()
    |> write_html_file(filename)

    if Keyword.get(opts, :open_browser?, false) do
      open_with_system_cmd(filename)
    end

    conn_or_session
  end

  def capture_html!(view_or_element, env, module, opts)
      when is_struct(view_or_element, View)
      when is_struct(view_or_element, LiveElement) do
    html = render_tree(view_or_element)
    filename = get_filename(env, module)

    view_or_element
    |> maybe_wrap_html(html)
    |> write_html_file(filename)

    if Keyword.get(opts, :open_browser?, false) do
      open_with_system_cmd(filename)
    end

    view_or_element
  end

  defp get_html(%Element{} = _conn_or_session) do
    raise ArgumentError,
      message: ~s"""
      html_snapshot/1 cannot be called with a Wallaby %Element{}
      Instead you can try something like:
      find(element, fn el ->
        el
        |> click_the_thing()
        |> assert_the_stuff()
      end)
      |> Excessibility.html_snapshot()
      """
  end

  defp get_html(%Conn{} = conn) do
    html_response(conn, 200)
  end

  defp get_html(%Session{} = session) do
    Wallaby.Browser.page_source(session)
  end

  defp get_filename(env, module) do
    String.replace("#{get_module_name(module)}_#{env.line}.html", " ", "_")
  end

  defp get_module_name(module) do
    module |> Atom.to_string() |> String.downcase() |> String.splitter(".") |> Enum.take(-1)
  end

  defp maybe_wrap_html(html) do
    build_path = Mix.Project.app_path()
    static_path = Path.join([build_path, "priv", "static"])

    html
    |> Floki.parse_document!()
    |> Floki.traverse_and_update(fn
      {"script", _, _} -> nil
      {"a", _, _} = link -> link
      {el, attrs, children} -> {el, maybe_prefix_static_path(attrs, static_path), children}
      el -> el
    end)
  end

  defp maybe_wrap_html(view_or_element, content) do
    {html, static_path} = call(view_or_element, :html)

    head =
      case DOM.maybe_one(html, "head") do
        {:ok, head} -> head
        _ -> {"head", [], []}
      end

    case_result =
      case Floki.attribute(content, "data-phx-main") do
        ["true" | _] ->
          # If we are rendering the main LiveView,
          # we return the full page html.
          html

        _ ->
          # Otherwise we build a basic html structure around the
          # view_or_element content.
          [
            {"html", [],
             [
               head,
               {"body", [],
                [
                  content
                ]}
             ]}
          ]
      end

    Floki.traverse_and_update(case_result, fn
      {"a", _, _} = link -> link
      {el, attrs, children} -> {el, maybe_prefix_static_path(attrs, static_path), children}
      el -> el
    end)
  end

  defp maybe_prefix_static_path(attrs, nil), do: attrs

  defp maybe_prefix_static_path(attrs, static_path) do
    Enum.map(attrs, fn
      {"src", path} -> {"src", prefix_static_path(path, static_path)}
      {"href", path} -> {"href", prefix_static_path(path, static_path)}
      attr -> attr
    end)
  end

  defp prefix_static_path(<<"//" <> _::binary>> = url, _prefix), do: url

  defp prefix_static_path(<<"/" <> _::binary>> = path, prefix), do: "file://#{Path.join([prefix, path])}"

  defp prefix_static_path(url, _), do: url

  defp write_html_file(html, filename) do
    html = Floki.raw_html(html, pretty: true)

    [File.cwd!(), "#{@snapshots_path}", filename]
    |> Path.join()
    |> File.write(html, [:write])
  end

  defp open_with_system_cmd(path) do
    cmd =
      case :os.type() do
        {:unix, :darwin} -> "open"
        {:unix, _} -> "xdg-open"
        {:win32, _} -> "start"
      end

    path = Path.join([File.cwd!(), "#{@snapshots_path}", path])
    System.cmd(cmd, [path])
  end

  defp render_tree(%View{} = view) do
    render_tree(view, {proxy_topic(view), "render", view.target})
  end

  defp render_tree(%LiveElement{} = element) do
    render_tree(element, element)
  end

  defp render_tree(view_or_element, topic_or_element) do
    call(view_or_element, {:render_element, :find_element, topic_or_element})
  end

  defp call(view_or_element, tuple) do
    GenServer.call(proxy_pid(view_or_element), tuple, 30_000)
  catch
    :exit, {{:shutdown, {kind, opts}}, _} when kind in [:redirect, :live_redirect] ->
      {:error, {kind, opts}}

    :exit, {{exception, stack}, _} ->
      exit({{exception, stack}, {__MODULE__, :call, [view_or_element]}})
  else
    :ok -> :ok
    {:ok, result} -> result
    {:raise, exception} -> raise exception
  end

  defp proxy_pid(%{proxy: {_ref, _topic, pid}}), do: pid
  defp proxy_topic(%{proxy: {_ref, topic, _pid}}), do: topic
end