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