Current section

Files

Jump to
phoenix_test lib phoenix_test live.ex
Raw

lib/phoenix_test/live.ex

defmodule PhoenixTest.Live do
@moduledoc false
import Phoenix.LiveViewTest
import PhoenixTest.SessionHelpers, only: [scope_selector: 2]
alias PhoenixTest.ActiveForm
alias PhoenixTest.Assertions
alias PhoenixTest.ConnHandler
alias PhoenixTest.Element.Button
alias PhoenixTest.Element.Field
alias PhoenixTest.Element.Form
alias PhoenixTest.Element.Select
alias PhoenixTest.EndpointHelpers
alias PhoenixTest.FileUpload
alias PhoenixTest.FormData
alias PhoenixTest.FormPayload
alias PhoenixTest.Html
alias PhoenixTest.LiveViewBindings
alias PhoenixTest.LiveViewTimeout
alias PhoenixTest.Locators
alias PhoenixTest.Operation
alias PhoenixTest.Query
alias PhoenixTest.SessionHelpers
defstruct view: nil,
watcher: nil,
conn: nil,
active_form: ActiveForm.new(),
within: :none,
current_path: "",
current_operation: nil
def build(conn) do
{:ok, view, _html} = EndpointHelpers.live(conn)
current_path = ConnHandler.build_current_path(conn)
{:ok, watcher} = start_watcher(view)
%__MODULE__{view: view, watcher: watcher, conn: conn, current_path: current_path}
end
def build_live_child(%__MODULE__{} = session, selector) do
"#" <> child_id = selector
child_view = find_live_child(session.view, child_id)
{:ok, watcher} = start_watcher(child_view)
%{session | view: child_view, watcher: watcher}
end
defp start_watcher(view) do
ExUnit.Callbacks.start_supervised({PhoenixTest.LiveViewWatcher, %{caller: self(), view: view}},
id: make_ref()
)
end
def current_path(session), do: session.current_path
def render_page_title(%{view: view}) do
page_title(view)
end
def render_html(%{view: view, within: within}) do
html =
view
|> render()
|> Html.parse_fragment()
case within do
:none -> html
selector when is_binary(selector) -> Html.all(html, selector)
end
end
def click_link(session, selector \\ "a", text) do
session = set_operation(session, :click_link)
selector = scope_selector(selector, session.within)
with {:found, link} <- PhoenixTest.Element.Link.find(session.current_operation.html, selector, text),
true <- PhoenixTest.Element.Link.has_data_method?(link) do
%{session.conn | resp_body: session.current_operation.html}
|> PhoenixTest.Static.build()
|> PhoenixTest.Static.click_with_data_method(link)
else
_ ->
session.view
|> element(selector, text)
|> render_click()
|> maybe_redirect(session)
end
end
def click_button(session, text) do
locator = Locators.button(text: text)
session = set_operation(session, :click_button)
button =
session.current_operation.html
|> Query.find_by_role!(locator)
|> Button.build()
handle_click_button(session, button)
end
def click_button(session, selector, text) do
session = set_operation(session, :click_button)
html = session.current_operation.html
button = Button.find!(html, selector, text)
handle_click_button(session, button)
end
defp handle_click_button(session, button) do
html = session.current_operation.html
click_action = Button.phx_click_action(button)
cond do
Button.disabled?(button) ->
raise ArgumentError, """
Cannot click element with selector #{inspect(button.selector)} and text
#{inspect(button.text)} because it is disabled.
"""
click_action == :dispatch_change and Button.belongs_to_form?(button, html) ->
trigger_button_dispatch_change(session, button)
click_action == :render_click ->
session.view
|> element(scope_selector(button.selector, session.within), button.text)
|> render_click()
|> maybe_redirect(session)
Button.submits_form?(button, html) ->
active_form = session.active_form
additional_data = FormData.add_data(FormData.new(), button)
form = Button.parent_form!(button, html)
form_data =
if active_form.selector == form.selector do
FormData.merge(form.form_data, active_form.form_data)
else
form.form_data
end
session
|> Map.put(:active_form, ActiveForm.new())
|> submit_form(form.selector, form_data, additional_data)
Button.has_data_method?(button) ->
%{session.conn | resp_body: html}
|> PhoenixTest.Static.build()
|> PhoenixTest.Static.click_with_data_method(button)
true ->
raise ArgumentError, """
Expected element with selector #{inspect(button.selector)} and text
#{inspect(button.text)} to have a valid `phx-click` attribute or belong to a `form` element.
"""
end
end
defp trigger_button_dispatch_change(session, button) do
html = session.current_operation.html
form = Button.parent_form!(button, html)
existing_form_data =
if session.active_form.selector == form.selector do
FormData.merge(form.form_data, session.active_form.form_data)
else
form.form_data
end
form_data =
FormData.add_data(existing_form_data, button)
payload =
form_data
|> FormPayload.new()
|> Map.merge(
case button.name do
nil -> %{}
name -> %{"_target" => name}
end
)
session.view
|> element(scope_selector(form.selector, session.within))
|> render_change(payload)
|> maybe_redirect(session)
end
def within(session, selector, fun) when is_binary(selector) and is_function(fun, 1) do
if id_selector?(selector) && targets_nested_live_view?(session, selector) do
session
|> build_live_child(selector)
|> fun.()
else
SessionHelpers.within(session, selector, fun)
end
end
defp id_selector?(selector) do
case selector do
"#" <> _ -> true
_ -> false
end
end
defp targets_nested_live_view?(session, selector) do
session
|> render_html()
|> Query.find!(selector)
|> LiveViewBindings.phx_session?()
end
def fill_in(session, label, opts) do
selectors = ["input:not([type='hidden'])", "textarea"]
fill_in(session, selectors, label, opts)
end
def fill_in(session, input_selector, label, opts) do
{value, opts} = Keyword.pop!(opts, :with)
session = set_operation(session, :fill_in)
session.current_operation.html
|> Field.find_input!(input_selector, label, opts)
|> Map.put(:value, to_string(value))
|> then(&fill_in_field_data(session, &1))
end
def select(session, option, opts) do
select(session, "select", option, opts)
end
def select(session, input_selector, option, opts) do
session = set_operation(session, :select)
html = session.current_operation.html
{label, opts} = Keyword.pop!(opts, :from)
field = Select.find_select_option!(session.current_operation.html, input_selector, label, option, opts)
cond do
Select.belongs_to_form?(field, html) ->
fill_in_field_data(session, field)
Select.phx_click_options?(field) ->
Enum.reduce(field.value, session, fn value, session ->
session.view
|> element(field |> Select.select_option_selector(value) |> scope_selector(session.within))
|> render_click()
|> maybe_redirect(session)
end)
true ->
raise ArgumentError, """
Expected select with selector #{inspect(field.selector)} to have a valid `phx-click` attribute on options or to belong to a `form` element.
"""
end
end
def check(session, label, opts) do
check(session, "input[type='checkbox']", label, opts)
end
def check(session, input_selector, label, opts) do
session = set_operation(session, :check)
html = session.current_operation.html
field = Field.find_checkbox!(html, input_selector, label, opts)
cond do
Field.phx_click?(field) ->
session.view
|> element(scope_selector(field.selector, session.within))
|> render_click()
|> maybe_redirect(session)
Field.belongs_to_form?(field, html) ->
fill_in_field_data(session, field)
true ->
raise ArgumentError, """
Expected checkbox with selector #{inspect(field.selector)} to have a valid `phx-click` attribute or belong to a `form` element.
"""
end
end
def uncheck(session, label, opts) do
uncheck(session, "input[type='checkbox']", label, opts)
end
def uncheck(session, input_selector, label, opts) do
session = set_operation(session, :uncheck)
html = session.current_operation.html
field = Field.find_checkbox!(html, input_selector, label, opts)
cond do
Field.phx_click?(field) and Field.phx_value?(field) ->
session.view
|> element(scope_selector(field.selector, session.within))
|> render_click()
|> maybe_redirect(session)
Field.phx_click?(field) ->
event = Html.attribute(field.parsed, "phx-click")
session.view
|> render_click(event, %{})
|> maybe_redirect(session)
Field.belongs_to_form?(field, html) ->
html
|> Field.find_hidden_uncheckbox!(input_selector, label, opts)
|> then(&fill_in_field_data(session, &1))
true ->
raise ArgumentError, """
Expected checkbox with selector #{inspect(field.selector)} to have a valid `phx-click` attribute or belong to a `form` element.
"""
end
end
def choose(session, label, opts) do
choose(session, "input[type='radio']", label, opts)
end
def choose(session, input_selector, label, opts) do
session = set_operation(session, :choose)
html = session.current_operation.html
field = Field.find_input!(html, input_selector, label, opts)
cond do
Field.phx_click?(field) ->
session.view
|> element(scope_selector(field.selector, session.within))
|> render_click()
|> maybe_redirect(session)
Field.belongs_to_form?(field, html) ->
fill_in_field_data(session, field)
true ->
raise ArgumentError, """
Expected radio input with selector #{inspect(field.selector)} to have a valid `phx-click` attribute or belong to a `form` element.
"""
end
end
def upload(session, label, path, opts) do
upload(session, "input[type='file']", label, path, opts)
end
def upload(session, input_selector, label, path, opts) do
session = set_operation(session, :upload)
html = session.current_operation.html
field = Field.find_input!(html, input_selector, label, opts)
file_stat = File.stat!(path)
file_name = Path.basename(path)
form = Field.parent_form!(field, html)
live_upload_name = String.to_existing_atom(field.name)
mime_type = FileUpload.mime_type(path)
entry = %{
last_modified: file_stat.mtime,
name: file_name,
content: File.read!(path),
size: file_stat.size,
type: mime_type
}
upload_progress_result =
session.view
|> EndpointHelpers.file_input(session.conn, form.selector, live_upload_name, [entry])
|> render_upload(file_name)
|> maybe_throw_upload_errors(session, file_name, live_upload_name)
session.view
|> form(form.selector)
|> render_change(%{"_target" => field.name})
|> progress_redirect_or_change_result(upload_progress_result, session)
end
defp progress_redirect_or_change_result(_change_result, {:error, _} = upload_progress_result, session) do
maybe_redirect(upload_progress_result, session)
end
defp progress_redirect_or_change_result(change_result, _upload_progress_result, session) do
maybe_redirect(change_result, session)
end
defp maybe_throw_upload_errors({:error, [[_id, error]]}, session, file_name, live_upload_name) do
case error do
:not_accepted -> raise ArgumentError, message: not_accepted_error_msg(session, file_name, live_upload_name)
:too_many_files -> raise ArgumentError, message: too_many_files_error_msg(session, file_name, live_upload_name)
:too_large -> raise ArgumentError, message: too_large_error_msg(session, file_name, live_upload_name)
end
end
defp maybe_throw_upload_errors(session_or_redirect, _session, _file_name, _live_upload_name) do
session_or_redirect
end
defp not_accepted_error_msg(session, file_name, live_upload_name) do
allowed_list =
session.conn.assigns.uploads[live_upload_name].acceptable_exts
|> MapSet.to_list()
|> Enum.join(", ")
"""
Unsupported file type.
You were trying to upload "#{file_name}",
but the only file types specified in `allow_upload` are [#{allowed_list}].
"""
end
defp too_many_files_error_msg(session, file_name, live_upload_name) do
%{name: name, max_entries: max_entries} = session.conn.assigns.uploads[live_upload_name]
"""
Too many files uploaded.
While attempting to upload "#{file_name}", you've exceeded #{max_entries} file(s). If this is intentional,
consider updating `allow_upload(:#{name}, max_entries: #{max_entries})`.
"""
end
defp too_large_error_msg(session, file_name, live_upload_name) do
%{name: name, max_file_size: max_file_size} = session.conn.assigns.uploads[live_upload_name]
"""
File too large.
While attempting to upload "#{file_name}", you've exceeded the maximum file size of #{max_file_size} bytes. If this is intentional,
consider updating `allow_upload(:#{name}, max_file_size: #{max_file_size})`.
"""
end
defp fill_in_field_data(session, field) do
html = session.current_operation.html
Field.validate_name!(field)
form = Field.parent_form!(field, html)
session =
Map.update!(session, :active_form, fn active_form ->
if active_form.selector == form.selector do
ActiveForm.add_form_data(active_form, field)
else
[id: form.id, selector: form.selector]
|> ActiveForm.new()
|> ActiveForm.add_form_data(field)
end
end)
maybe_trigger_phx_change(session, form, field)
end
defp maybe_trigger_phx_change(session, form, field) do
cond do
Field.phx_change?(field) ->
trigger_input_phx_change(session, form, field)
Form.phx_change?(form) ->
trigger_form_phx_change(session, form, field)
true ->
session
end
end
defp trigger_input_phx_change(session, form, field) do
data_to_submit =
session
|> merged_form_data(form)
|> FormData.filter(&(&1.name == field.name))
payload =
data_to_submit
|> FormPayload.new()
|> Map.put("_target", field.name)
session.view
|> element(scope_selector(field.selector, session.within))
|> render_change(payload)
|> maybe_redirect(session)
end
defp trigger_form_phx_change(session, form, field) do
data_to_submit = merged_form_data(session, form)
additional_data = %{"_target" => field.name}
session.view
|> form(form.selector, FormPayload.new(data_to_submit))
|> render_change(additional_data)
|> maybe_redirect(session)
end
defp merged_form_data(session, %Form{} = form) do
form.form_data
|> FormData.merge(session.active_form.form_data)
|> FormData.filter(fn %{name: name} -> name in Form.form_element_names(form) end)
end
def submit(session) do
active_form = session.active_form
unless ActiveForm.active?(active_form), do: raise(no_active_form_error())
selector = active_form.selector
session
|> set_operation(:submit)
|> submit_form(selector, active_form.form_data)
end
defp no_active_form_error do
%ArgumentError{
message: "There's no active form. Fill in a form with `fill_in`, `select`, etc."
}
end
def submit_form(session, selector, form_data, additional_data \\ FormData.new()) do
form = Form.find!(session.current_operation.html, selector)
form_data = remove_data_for_fields_that_have_been_removed(form_data, form)
form_data = FormData.merge(form.form_data, form_data)
additional_data =
if form.submit_button do
FormData.new()
|> FormData.add_data(form.submit_button)
|> FormData.merge(additional_data)
else
additional_data
end
cond do
Form.phx_submit?(form) ->
session.view
|> form(selector, FormPayload.new(form_data))
|> render_submit(FormPayload.new(additional_data))
|> maybe_redirect(session)
Form.has_action?(form) ->
session.conn
|> PhoenixTest.Static.build()
|> PhoenixTest.Static.submit_form(selector, form_data)
true ->
raise ArgumentError,
"Expected form with selector #{inspect(selector)} to have a `phx-submit` or `action` defined."
end
end
defp remove_data_for_fields_that_have_been_removed(form_data, form) do
element_names = Form.form_element_names(form)
FormData.filter(form_data, fn %{name: name} ->
name in element_names
end)
end
def open_browser(%{view: view} = session, open_fun \\ &Phoenix.LiveViewTest.open_browser/1) do
open_fun.(view)
session
end
def unwrap(%{view: view} = session, fun) when is_function(fun, 1) do
view
|> fun.()
|> maybe_redirect(session)
end
def assert_has(session, selector, opts) when is_list(opts) do
{timeout, opts} = Keyword.pop(opts, :timeout, 0)
LiveViewTimeout.with_timeout(session, timeout, fn session ->
Assertions.assert_has(session, selector, opts)
end)
end
def refute_has(session, selector, opts) when is_list(opts) do
{timeout, opts} = Keyword.pop(opts, :timeout, 0)
LiveViewTimeout.with_timeout(session, timeout, fn session ->
Assertions.refute_has(session, selector, opts)
end)
end
def handle_redirect(session, redirect_tuple) do
maybe_redirect({:error, redirect_tuple}, session)
end
defp maybe_redirect({:error, {kind, %{to: path}}} = result, session) when kind in [:redirect, :live_redirect] do
session = %{session | current_path: path}
case result do
{:error, {:live_redirect, opts}} ->
session.conn
|> ConnHandler.recycle_all_headers()
|> EndpointHelpers.follow_live_redirect(opts)
|> maybe_redirect(session)
{:error, {:redirect, opts}} ->
session.conn
|> ConnHandler.recycle_all_headers()
|> EndpointHelpers.follow_redirect(opts)
|> ConnHandler.visit()
end
end
defp maybe_redirect({:ok, view, _}, session) do
%{session | view: view}
end
defp maybe_redirect({:ok, conn}, _session) do
ConnHandler.visit(conn)
end
defp maybe_redirect(html, session) when is_binary(html) do
case Form.find(html, "form[phx-trigger-action]") do
:not_found ->
maybe_put_patch_path(session)
{:found, form} ->
active_form = session.active_form
active_form? = form.selector == active_form.selector
form_data = FormData.merge(form.form_data, if(active_form?, do: active_form.form_data, else: FormData.new()))
%{session.conn | resp_body: html}
|> PhoenixTest.Static.build()
|> PhoenixTest.Static.submit_form(form.selector, form_data)
{:found_many, _} ->
raise ArgumentError, "Found multiple forms with phx-trigger-action."
end
end
defp maybe_put_patch_path(session) do
case fetch_patch_path(session.view) do
:no_path ->
session
path when is_binary(path) ->
%{session | current_path: path}
end
end
defp fetch_patch_path(view) do
assert_patch(view, 0)
rescue
ArgumentError -> :no_path
end
defp set_operation(session, name, rendered_html \\ nil) do
html = rendered_html || render_html(session)
Map.put(session, :current_operation, Operation.new(name, html))
end
end
defimpl PhoenixTest.Driver, for: PhoenixTest.Live do
alias PhoenixTest.Assertions
alias PhoenixTest.ConnHandler
alias PhoenixTest.Live
def visit(session, path) do
ConnHandler.visit(session.conn, path)
end
def reload_page(session) do
ConnHandler.visit(session.conn, current_path(session))
end
defdelegate render_page_title(session), to: Live
defdelegate render_html(session), to: Live
defdelegate click_link(session, text), to: Live
defdelegate click_link(session, selector, text), to: Live
defdelegate click_button(session, text), to: Live
defdelegate click_button(session, selector, text), to: Live
defdelegate within(session, selector, fun), to: Live
defdelegate fill_in(session, label, opts), to: Live
defdelegate fill_in(session, input_selector, label, opts), to: Live
defdelegate select(session, option, opts), to: Live
defdelegate select(session, input_selector, option, opts), to: Live
defdelegate check(session, label, opts), to: Live
defdelegate check(session, input_selector, label, opts), to: Live
defdelegate uncheck(session, label, opts), to: Live
defdelegate uncheck(session, input_selector, label, opts), to: Live
defdelegate choose(session, label, opts), to: Live
defdelegate choose(session, input_selector, label, opts), to: Live
defdelegate upload(session, label, path, opts), to: Live
defdelegate upload(session, input_selector, label, path, opts), to: Live
defdelegate submit(session), to: Live
defdelegate open_browser(session), to: Live
defdelegate open_browser(session, open_fun), to: Live
defdelegate unwrap(session, fun), to: Live
defdelegate current_path(session), to: Live
defdelegate assert_has(session, selector), to: Assertions
defdelegate assert_has(session, selector, opts), to: Live
defdelegate refute_has(session, selector), to: Assertions
defdelegate refute_has(session, selector, opts), to: Live
defdelegate assert_path(session, path), to: Assertions
defdelegate assert_path(session, path, opts), to: Assertions
defdelegate refute_path(session, path), to: Assertions
defdelegate refute_path(session, path, opts), to: Assertions
end