lib/excessibility.ex

defmodule Excessibility do
  import Phoenix.ConnTest, only: [html_response: 2]
  alias Plug.Conn
  use Wallaby.DSL
  alias Wallaby.Session
  alias Phoenix.LiveViewTest.{DOM, View}
  alias Phoenix.LiveViewTest.Element, as: LiveElement

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

  @moduledoc """
  Documentation for `Excessibility`.
  """

  @doc """
  Using the Excessibility macro will require the file for you.

  ## Examples

      use Excessibility

  """

  defmacro __using__(_opts) do
    quote do
      require Excessibility
    end
  end

  @doc """
  The html_snapshot macro can be called to produce an HTML snapshot of any of the following:
    - A Phoenix Conn
    - A Wallaby Session
    - A LiveViewTest View
    - A LiveViewTest Element
  These snapshots can then be used later to run pa11y against by calling the mix task..It will return the thing that was passed in so that you can include it in a pipeline.
  You can optionally pass a second argument of true to open the file in your browser for development purposes.

  ## Examples

      iex> Excessibility.html_snapshot(source, opts \\ [])
          source
  """

  defmacro html_snapshot(source, opts \\ []) do
    quote do
      Excessibility.html_snapshot(unquote(source), __ENV__, __MODULE__, unquote(opts))
    end
  end

  def html_snapshot(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)

    get_html(conn_or_session)
    |> 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 html_snapshot(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
    "#{module |> get_module_name()}_#{env.line}.html"
    |> String.replace(" ", "_")
  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)

    IO.inspect(html)

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

    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(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)

    Path.join([File.cwd!(), "#{@snapshots_path}", filename])
    |> 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
    try 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
  end

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