defmodule Pages.Driver.LiveView do
# @related [drivers](lib/pages/driver.ex)
@moduledoc """
A page driver for interacting with Phoenix.LiveView pages.
"""
@behaviour Pages.Driver
alias HtmlQuery, as: Hq
alias Phoenix.LiveViewTest
defstruct ~w[conn live rendered]a
@type t() :: %__MODULE__{
conn: Plug.Conn.t(),
live: any(),
rendered: binary() | nil
}
def new(%Plug.Conn{} = conn),
do: new(conn, conn.request_path)
def new(%Plug.Conn{} = conn, request_path) when is_binary(request_path),
do: new(conn, new_live(conn, request_path))
def new(%Plug.Conn{} = conn, {:ok, live, rendered}),
do: __struct__(conn: conn, live: live, rendered: rendered)
def new(%Plug.Conn{} = conn, {:error, {:live_redirect, %{to: new_path}}}),
do: new(conn, new_path)
def new(%Plug.Conn{} = conn, {:error, {:redirect, %{to: new_path}}}),
do: new(conn, new_path)
def new(%Plug.Conn{} = conn, {:error, :nosession}),
do: Pages.new(conn)
# # #
@doc "Called from `Pages.click/4` when the given page is a LiveView."
@spec click(Pages.Driver.t(), Pages.http_method(), Pages.text_filter() | nil, Hq.Css.selector()) :: Pages.Driver.t()
@impl Pages.Driver
def click(%__MODULE__{} = page, :get, maybe_title, selector) do
page.live
|> LiveViewTest.element(Hq.Css.selector(selector), maybe_title)
|> LiveViewTest.render_click()
|> handle_rendered_result(page)
end
def click(%__MODULE__{} = page, :post, maybe_title, selector),
do: Pages.Driver.Conn.click(page, :post, maybe_title, selector)
@doc "Called from `Pages.rerender/1` when the given page is a LiveView."
@spec rerender(Pages.Driver.t()) :: Pages.Driver.t()
@impl Pages.Driver
def rerender(page),
do: %{page | rendered: LiveViewTest.render(page.live)}
@doc "Called from `Paged.render_change/3` when the given page is a LiveView."
@spec render_change(Pages.Driver.t(), Hq.Css.selector(), Enum.t()) :: Pages.Driver.t()
@impl Pages.Driver
def render_change(%__MODULE__{} = page, selector, value) do
page.live
|> LiveViewTest.element(Hq.Css.selector(selector))
|> LiveViewTest.render_change(value)
|> handle_rendered_result(page)
end
@doc "Called from `Paged.render_hook/3` when the given page is a LiveView."
@spec render_hook(Pages.Driver.t(), binary(), Pages.attrs_t(), keyword()) :: Pages.Driver.t()
@impl Pages.Driver
def render_hook(%__MODULE__{} = page, event, value_attrs, options) do
case Keyword.get(options, :target) do
nil -> page.live
target -> page.live |> LiveViewTest.element(target)
end
|> LiveViewTest.render_hook(event, value_attrs)
|> handle_rendered_result(page)
end
@doc "Called from `Paged.render_upload/4` when the given page is a LiveView."
@spec render_upload(Pages.Driver.t(), Pages.live_view_upload(), binary(), integer()) :: Pages.Driver.t()
@impl Pages.Driver
def render_upload(%__MODULE__{} = page, %Phoenix.LiveViewTest.Upload{} = upload, entry_name, percent) do
upload
|> LiveViewTest.render_upload(entry_name, percent)
|> handle_rendered_result(page)
end
@doc """
Perform a live redirect to the given path.
This is not implemented in `Pages` due to its specificity to LiveView and LiveViewTest.
"""
@spec live_redirect(Pages.Driver.t(), binary()) :: Pages.Driver.t()
def live_redirect(page, destination_path),
do: page.live |> Phoenix.LiveViewTest.live_redirect(to: destination_path) |> handle_rendered_result(page)
@doc "Called from `Pages.submit_form/2` when the given page is a LiveView."
@spec submit_form(Pages.Driver.t(), Hq.Css.selector()) :: Pages.Driver.t()
@impl Pages.Driver
def submit_form(%__MODULE__{} = page, selector) do
page.live
|> LiveViewTest.form(Hq.Css.selector(selector))
|> LiveViewTest.render_submit()
|> handle_rendered_result(page)
end
@doc "Called from `Pages.submit_form/4` and `Pages.submit_form/5` when the given page is a LiveView."
@spec submit_form(Pages.Driver.t(), Hq.Css.selector(), atom(), Pages.attrs_t()) ::
Pages.Driver.t()
@spec submit_form(Pages.Driver.t(), Hq.Css.selector(), atom(), Pages.attrs_t(), Pages.attrs_t()) ::
Pages.Driver.t()
@impl Pages.Driver
def submit_form(%__MODULE__{} = page, selector, schema, form_attrs, hidden_attrs \\ %{}) do
params = [{schema, Map.new(form_attrs)}]
hidden_params = [{schema, Map.new(hidden_attrs)}]
page.live
|> LiveViewTest.form(Hq.Css.selector(selector), params)
|> LiveViewTest.render_submit(hidden_params)
|> handle_rendered_result(page)
|> maybe_trigger_action(params)
end
@doc "Called from `Pages.update_form/4` when the given page is a LiveView."
@spec update_form(Pages.Driver.t(), Hq.Css.selector(), atom(), Pages.attrs_t(), keyword()) ::
Pages.Driver.t()
@impl Pages.Driver
def update_form(%__MODULE__{} = page, selector, schema, attrs, opts \\ []) do
params =
%{schema => Map.new(attrs)}
|> then(fn params ->
case Keyword.get(opts, :target) do
nil -> params
target -> Map.put(params, :_target, target)
end
end)
page.live
|> LiveViewTest.form(Hq.Css.selector(selector))
|> LiveViewTest.render_change(params)
|> handle_rendered_result(page)
|> maybe_trigger_action(params)
end
@doc """
Initialize a `live` with the given path.
This is called from `Pages.visit/2` when the conn indicates that the pages is a LiveView,
and should only be called directly if the parent function does not work for some reason.
"""
@spec visit(Pages.Driver.t(), binary()) :: Pages.Driver.t()
@impl Pages.Driver
def visit(%__MODULE__{} = page, path) do
case new_live(page.conn, path) do
{:error, {:live_redirect, %{to: new_path}}} -> new(page.conn, new_path)
{:error, {:redirect, %{to: new_path}}} -> Pages.new(page.conn) |> Pages.visit(new_path)
{:ok, view, html} -> %__MODULE__{conn: page.conn, live: view, rendered: html}
end
end
@doc """
Find a child component, and pass it as a new Page into the given function.
Rerenders the top-level page upon completion. See `Pages.with_child_component/3`.
"""
@impl Pages.Driver
def with_child_component(%__MODULE__{live: view} = page, child_id, fun) when is_function(fun, 1) do
child = Phoenix.LiveViewTest.find_live_child(view, child_id)
if !child,
do: raise(Pages.Error, "Expected to find a child component with id `#{child_id}`, but found nil")
fun.(%{page | live: child})
Pages.rerender(page)
end
# # #
defp new_live(conn, path) do
cond do
is_binary(path) ->
conn
|> Phoenix.ConnTest.ensure_recycled()
|> Pages.Shim.__dispatch(:get, path)
|> then(&Pages.Shim.__retain_connect_params(&1, conn))
|> Phoenix.LiveViewTest.__live__(path)
is_nil(path) ->
conn
|> Phoenix.ConnTest.ensure_recycled()
|> then(&Pages.Shim.__retain_connect_params(&1, conn))
|> Phoenix.LiveViewTest.__live__()
true ->
raise RuntimeError, "path must be nil or a binary, got: #{inspect(path)}"
end
end
defp handle_rendered_result(rendered_result, %__MODULE__{} = page) do
case rendered_result do
rendered when is_binary(rendered) ->
%{page | rendered: rendered}
{:error, {:live_redirect, opts}} ->
endpoint = Pages.Shim.__endpoint()
{conn, to} = Phoenix.LiveViewTest.__follow_redirect__(page.conn, endpoint, nil, opts)
conn = Pages.Shim.__retain_connect_params(conn, page.conn)
new(conn, to)
{:error, {:redirect, %{to: new_path}}} ->
Pages.new(page.conn) |> Pages.visit(new_path)
{:ok, live, html} ->
%{page | live: live, rendered: html}
end
end
defp maybe_trigger_action(%__MODULE__{} = page, params) do
case page |> Hq.find("[phx-trigger-action]") do
element when not is_nil(element) ->
page.live
|> Phoenix.LiveViewTest.form("form[phx-trigger-action]", params)
|> Pages.Shim.__follow_trigger_action(page.conn)
|> Pages.new()
_ ->
page
end
end
defimpl String.Chars, for: Pages.Driver.LiveView do
def to_string(%Pages.Driver.LiveView{rendered: rendered}) when not is_nil(rendered),
do: rendered
def to_string(%Pages.Driver.LiveView{live: live}) when not is_nil(live),
do: live |> Phoenix.LiveViewTest.render()
end
end