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