37 | """
38 |
39 | result =
40 | html
41 | |> Html.parse_fragment()
42 | |> Html.element_text()
43 |
44 | assert result == "hello elixir and phoenix test world!"
45 | end
46 |
47 | test "extracts text but excludes select elements and their options" do
48 | html = """
49 |
50 |
Choose an option:
51 |
55 |
More text here
56 |
57 | """
58 |
59 | result =
60 | html
61 | |> Html.parse_fragment()
62 | |> Html.element_text()
63 |
64 | assert result == "Choose an option: More text here"
65 | end
66 |
67 | test "extracts text from label but excludes textarea value" do
68 | html = """
69 |
76 | """
77 |
78 | result =
79 | html
80 | |> Html.parse_fragment()
81 | |> Html.element_text()
82 |
83 | assert result == "Wrapped notes"
84 | end
85 |
86 | test "includes textarea text if it's the top-level element" do
87 | html = """
88 |
91 | """
92 |
93 | result =
94 | html
95 | |> Html.parse_fragment()
96 | |> Html.element_text()
97 |
98 | assert result == "Prefilled notes"
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/lib/phoenix_test/live_view_timeout.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.LiveViewTimeout do
2 | @moduledoc false
3 |
4 | alias ExUnit.AssertionError
5 | alias PhoenixTest.Live
6 | alias PhoenixTest.Static
7 |
8 | def interval_wait_time, do: 100
9 |
10 | def with_timeout(session, timeout, action, fetch_redirect_info \\ &via_assert_redirect/1)
11 |
12 | def with_timeout(%Static{} = session, _timeout, action, _fetch_redirect_info) when is_function(action) do
13 | action.(session)
14 | end
15 |
16 | def with_timeout(%Live{} = session, timeout, action, _fetch_redirect_info) when timeout <= 0 and is_function(action) do
17 | action.(session)
18 | end
19 |
20 | def with_timeout(%Live{} = session, timeout, action, fetch_redirect_info) when is_function(action) do
21 | :ok = PhoenixTest.LiveViewWatcher.watch_view(session.watcher, session.view)
22 | handle_watched_messages_with_timeout(session, timeout, action, fetch_redirect_info)
23 | end
24 |
25 | defp handle_watched_messages_with_timeout(session, timeout, action, fetch_redirect_info) when timeout <= 0 do
26 | action.(session)
27 | catch
28 | :exit, _e ->
29 | check_for_redirect(session, action, fetch_redirect_info)
30 | end
31 |
32 | defp handle_watched_messages_with_timeout(session, timeout, action, fetch_redirect_info) do
33 | wait_time = interval_wait_time()
34 | new_timeout = max(timeout - wait_time, 0)
35 | view_pid = session.view.pid
36 |
37 | receive do
38 | {:watcher, ^view_pid, {:live_view_redirected, redirect_tuple}} ->
39 | session
40 | |> PhoenixTest.Live.handle_redirect(redirect_tuple)
41 | |> with_timeout(new_timeout, action, fetch_redirect_info)
42 |
43 | {:watcher, ^view_pid, :live_view_died} ->
44 | check_for_redirect(session, action, fetch_redirect_info)
45 | after
46 | wait_time ->
47 | with_retry(session, action, &handle_watched_messages_with_timeout(&1, new_timeout, action, fetch_redirect_info))
48 | end
49 | end
50 |
51 | defp with_retry(session, action, retry_fun) when is_function(action) and is_function(retry_fun) do
52 | :ok = Phoenix.LiveView.Channel.ping(session.view.pid)
53 | action.(session)
54 | rescue
55 | AssertionError ->
56 | retry_fun.(session)
57 | catch
58 | :exit, _e ->
59 | retry_fun.(session)
60 | end
61 |
62 | defp check_for_redirect(session, action, fetch_redirect_info) when is_function(action) do
63 | {path, flash} = fetch_redirect_info.(session)
64 |
65 | session
66 | |> PhoenixTest.Live.handle_redirect({:redirect, %{to: path, flash: flash}})
67 | |> then(action)
68 | end
69 |
70 | defp via_assert_redirect(session) do
71 | Phoenix.LiveViewTest.assert_redirect(session.view)
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.MixProject do
2 | use Mix.Project
3 |
4 | @version "0.9.1"
5 | @source_url "https://github.com/germsvel/phoenix_test"
6 | @description """
7 | Write pipeable, fast, and easy-to-read feature tests for your Phoenix apps in
8 | a unified way -- regardless of whether you're testing LiveView pages or static
9 | pages.
10 | """
11 |
12 | def project do
13 | [
14 | app: :phoenix_test,
15 | version: @version,
16 | description: @description,
17 | elixir: "~> 1.15",
18 | start_permanent: Mix.env() == :prod,
19 | deps: deps(),
20 | elixirc_paths: elixirc_paths(Mix.env()),
21 | package: package(),
22 | name: "PhoenixTest",
23 | source_url: @source_url,
24 | docs: docs(),
25 | aliases: aliases(),
26 | preferred_cli_env: [
27 | setup: :test,
28 | "assets.setup": :test,
29 | "assets.build": :test
30 | ]
31 | ]
32 | end
33 |
34 | # Run "mix help compile.app" to learn about applications.
35 | def application do
36 | [
37 | extra_applications: [:logger]
38 | ]
39 | end
40 |
41 | # Run "mix help deps" to learn about dependencies.
42 | defp deps do
43 | [
44 | {:ecto, "~> 3.12", only: :test},
45 | {:esbuild, "~> 0.8", only: :test, runtime: false},
46 | {:ex_doc, "~> 0.31", only: :dev, runtime: false},
47 | {:jason, "~> 1.4"},
48 | {:lazy_html, "~> 0.1.7"},
49 | {:makeup_eex, "~> 0.1.0", only: :dev, runtime: false},
50 | {:makeup_html, "~> 0.1.0", only: :dev, runtime: false},
51 | {:mime, ">= 1.0.0", optional: true},
52 | {:phoenix, ">= 1.7.10"},
53 | {:phoenix_ecto, "~> 4.6", only: :test},
54 | {:phoenix_live_view, "~> 1.0"},
55 | {:plug_cowboy, "~> 2.7", only: :test, runtime: false},
56 | {:benchee, "~> 1.3", only: [:dev, :test]},
57 | {:styler, "~> 0.11", only: [:dev, :test], runtime: false},
58 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false, optional: true}
59 | ]
60 | end
61 |
62 | defp elixirc_paths(:test), do: ["lib", "test/support"]
63 | defp elixirc_paths(_), do: ["lib"]
64 |
65 | defp package do
66 | [
67 | licenses: ["MIT"],
68 | links: %{"Github" => @source_url}
69 | ]
70 | end
71 |
72 | defp docs do
73 | [
74 | main: "PhoenixTest",
75 | extras: [
76 | "CHANGELOG.md": [title: "Changelog"],
77 | "upgrade_guides.md": [title: "Upgrade Guides"]
78 | ]
79 | ]
80 | end
81 |
82 | defp aliases do
83 | [
84 | setup: ["deps.get", "assets.setup", "assets.build"],
85 | "assets.setup": ["esbuild.install --if-missing"],
86 | "assets.build": ["esbuild default"],
87 | benchmark: ["run bench/assertions.exs"]
88 | ]
89 | end
90 |
91 | def cli do
92 | [
93 | preferred_envs: [benchmark: :test]
94 | ]
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/phoenix_test/form_data.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.FormData do
2 | @moduledoc false
3 |
4 | alias PhoenixTest.Element.Button
5 | alias PhoenixTest.Element.Field
6 | alias PhoenixTest.Element.Select
7 |
8 | defstruct data: %{}
9 |
10 | def new, do: %__MODULE__{}
11 |
12 | def add_data(%__MODULE__{} = form_data, {name, value}) do
13 | add_data(form_data, name, value)
14 | end
15 |
16 | def add_data(%__MODULE__{} = form_data, %Button{} = button) do
17 | add_data(form_data, button.name, button.value)
18 | end
19 |
20 | def add_data(%__MODULE__{} = form_data, %Field{} = field) do
21 | add_data(form_data, field.name, field.value)
22 | end
23 |
24 | def add_data(%__MODULE__{} = form_data, %Select{value: values} = field) when is_list(values) do
25 | add_data(form_data, field.name, values)
26 | end
27 |
28 | def add_data(form_data, data) when is_list(data) do
29 | Enum.reduce(data, form_data, fn new_data, acc ->
30 | add_data(acc, new_data)
31 | end)
32 | end
33 |
34 | def add_data(%__MODULE__{} = form_data, name, value) when is_nil(name) or is_nil(value), do: form_data
35 |
36 | def add_data(%__MODULE__{} = form_data, name, value) do
37 | if allows_multiple_values?(name) do
38 | new_data =
39 | Map.update(form_data.data, name, List.wrap(value), fn existing_value ->
40 | if value in existing_value do
41 | existing_value
42 | else
43 | existing_value ++ List.wrap(value)
44 | end
45 | end)
46 |
47 | %__MODULE__{form_data | data: new_data}
48 | else
49 | %__MODULE__{form_data | data: Map.put(form_data.data, name, value)}
50 | end
51 | end
52 |
53 | def merge(%__MODULE__{data: data1}, %__MODULE__{data: data2}) do
54 | data =
55 | Map.merge(data1, data2, fn k, v1, v2 ->
56 | if allows_multiple_values?(k) do
57 | Enum.uniq(v1 ++ v2)
58 | else
59 | v2
60 | end
61 | end)
62 |
63 | %__MODULE__{data: data}
64 | end
65 |
66 | defp allows_multiple_values?(field_name), do: String.ends_with?(field_name, "[]")
67 |
68 | def filter(%__MODULE__{data: data}, fun) do
69 | data =
70 | data
71 | |> Enum.filter(fn {name, value} -> fun.(%{name: name, value: value}) end)
72 | |> Map.new()
73 |
74 | %__MODULE__{data: data}
75 | end
76 |
77 | def empty?(%__MODULE__{data: data}) do
78 | Enum.empty?(data)
79 | end
80 |
81 | def has_data?(%__MODULE__{data: data}, name, value) do
82 | field_data = Map.get(data, name, [])
83 |
84 | value == field_data or value in List.wrap(field_data)
85 | end
86 |
87 | def to_list(%__MODULE__{data: data}) do
88 | data
89 | |> Enum.map(fn
90 | {key, values} when is_list(values) ->
91 | Enum.map(values, &{key, &1})
92 |
93 | {_key, _value} = field ->
94 | field
95 | end)
96 | |> List.flatten()
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/test/phoenix_test/element_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.ElementTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.Element
5 | alias PhoenixTest.Query
6 |
7 | describe "build_selector/2" do
8 | test "builds a selector based on id if id is present" do
9 | data =
10 | Query.find!(
11 | """
12 |
13 | """,
14 | "input"
15 | )
16 |
17 | selector = Element.build_selector(data)
18 |
19 | assert ~s|[id="name"]| = selector
20 | end
21 |
22 | test "builds a composite selector if id isn't present" do
23 | data =
24 | Query.find!(
25 | """
26 |
27 | """,
28 | "input"
29 | )
30 |
31 | selector = Element.build_selector(data)
32 |
33 | assert ~s(input[type="text"][name="name"]) = selector
34 | end
35 |
36 | test "includes simple phx-* attributes when id isn't present" do
37 | data =
38 | Query.find!(
39 | """
40 |
41 | """,
42 | "input"
43 | )
44 |
45 | selector = Element.build_selector(data)
46 |
47 | assert ~s(input[phx-click="save-user"][type="text"][name="name"]) = selector
48 | end
49 |
50 | test "ignores complex `phx-*` LiveView.JS attributes when id isn't present" do
51 | %{ops: data} = Phoenix.LiveView.JS.navigate("/live/page_2")
52 | {:ok, encoded_action} = Jason.encode(data)
53 |
54 | data =
55 | Query.find!(
56 | """
57 |
58 | """,
59 | "input"
60 | )
61 |
62 | selector = Element.build_selector(data)
63 |
64 | assert ~s(input[type="text"][name="name"]) = selector
65 | end
66 | end
67 |
68 | describe "selector_has_id?/2" do
69 | test "returns true if selector has #" do
70 | selector = "#name"
71 |
72 | assert Element.selector_has_id?(selector, "name")
73 | refute Element.selector_has_id?(selector, "nome")
74 | end
75 |
76 | test "returns true if selector has [id=] with single quotes" do
77 | selector = "[id='name']"
78 |
79 | assert Element.selector_has_id?(selector, "name")
80 | refute Element.selector_has_id?(selector, "nome")
81 | end
82 |
83 | test "returns true if selector has [id=] with double quotes" do
84 | selector = ~s|[id="user_name"]|
85 |
86 | assert Element.selector_has_id?(selector, "user_name")
87 | refute Element.selector_has_id?(selector, "user_nome")
88 | end
89 |
90 | test "returns false if selector doesn't have id" do
91 | selector = "[data-role='name']"
92 |
93 | refute Element.selector_has_id?(selector, "name")
94 | refute Element.selector_has_id?(selector, "nome")
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/test/phoenix_test/data_attribute_form_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.DataAttributeFormTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.DataAttributeForm
5 | alias PhoenixTest.Query
6 |
7 | describe "build/1" do
8 | test "builds a form with method, action, csrf_token" do
9 | element =
10 | to_element("""
11 |
12 | Delete
13 |
14 | """)
15 |
16 | form = DataAttributeForm.build(element)
17 |
18 | assert form.method == "put"
19 | assert form.action == "/users/2"
20 | assert form.csrf_token == "token"
21 | end
22 |
23 | test "includes original element passed to build/1" do
24 | element =
25 | to_element("""
26 |
27 | Delete
28 |
29 | """)
30 |
31 | form = DataAttributeForm.build(element)
32 |
33 | assert form.element == element
34 | end
35 |
36 | test "creates form data of what would be hidden inputs in regular form" do
37 | element =
38 | to_element("""
39 |
40 | Delete
41 |
42 | """)
43 |
44 | form = DataAttributeForm.build(element)
45 |
46 | assert form.data["_method"] == "put"
47 | assert form.data["_csrf_token"] == "token"
48 | end
49 | end
50 |
51 | describe "validate!/1" do
52 | test "raises an error if data-method is missing" do
53 | element =
54 | to_element("""
55 |
56 | Delete
57 |
58 | """)
59 |
60 | assert_raise ArgumentError, ~r/missing: data-method/, fn ->
61 | element
62 | |> DataAttributeForm.build()
63 | |> DataAttributeForm.validate!("a", "Delete")
64 | end
65 | end
66 |
67 | test "raises an error if data-to is missing" do
68 | element =
69 | to_element("""
70 |
71 | Delete
72 |
73 | """)
74 |
75 | assert_raise ArgumentError, ~r/missing: data-to/, fn ->
76 | element
77 | |> DataAttributeForm.build()
78 | |> DataAttributeForm.validate!("a", "Delete")
79 | end
80 | end
81 |
82 | test "raises an error if data-csrf is missing" do
83 | element =
84 | to_element("""
85 |
86 | Delete
87 |
88 | """)
89 |
90 | assert_raise ArgumentError, ~r/missing: data-csrf/, fn ->
91 | element
92 | |> DataAttributeForm.build()
93 | |> DataAttributeForm.validate!("a", "Delete")
94 | end
95 | end
96 | end
97 |
98 | defp to_element(html) do
99 | Query.find!(html, "a")
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/test/support/web_app/async_page_live.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.WebApp.AsyncPageLive do
2 | @moduledoc false
3 | use Phoenix.LiveView
4 |
5 | def mount(_, _, socket) do
6 | {:ok,
7 | socket
8 | |> assign(:h2, "Where we test LiveView's async behavior")
9 | |> assign_async(:title, fn ->
10 | Process.sleep(100)
11 | {:ok, %{title: "Title loaded async"}}
12 | end)}
13 | end
14 |
15 | def render(assigns) do
16 | ~H"""
17 | <.async_result :let={title} assign={@title}>
18 | <:loading>Loading title...
19 | <:failed :let={_failure}>there was an error loading the title
20 |
{title}
21 |
22 |
23 |
24 | {@h2}
25 |
26 |
27 |
30 |
31 |
34 |
35 |
38 |
39 |
42 |
43 |
46 | """
47 | end
48 |
49 | def handle_event("change-h2", _, socket) do
50 | Process.send_after(self(), :change_h2, 100)
51 | {:noreply, socket}
52 | end
53 |
54 | def handle_event("async-navigate-quickly", _, socket) do
55 | {:noreply,
56 | start_async(socket, :async_navigate_quickly, fn ->
57 | :ok
58 | end)}
59 | end
60 |
61 | def handle_event("async-navigate", _, socket) do
62 | {:noreply,
63 | start_async(socket, :async_navigate, fn ->
64 | Process.sleep(100)
65 | :ok
66 | end)}
67 | end
68 |
69 | def handle_event("async-navigate-to-async", _, socket) do
70 | {:noreply,
71 | start_async(socket, :async_navigate_to_async, fn ->
72 | Process.sleep(100)
73 | :ok
74 | end)}
75 | end
76 |
77 | def handle_event("async-redirect", _, socket) do
78 | {:noreply,
79 | start_async(socket, :async_redirect, fn ->
80 | Process.sleep(100)
81 | :ok
82 | end)}
83 | end
84 |
85 | def handle_async(:async_navigate_quickly, {:ok, _result}, socket) do
86 | {:noreply, push_navigate(socket, to: "/live/page_2")}
87 | end
88 |
89 | def handle_async(:async_navigate, {:ok, _result}, socket) do
90 | {:noreply, push_navigate(socket, to: "/live/page_2")}
91 | end
92 |
93 | def handle_async(:async_navigate_to_async, {:ok, _result}, socket) do
94 | {:noreply, push_navigate(socket, to: "/live/async_page_2")}
95 | end
96 |
97 | def handle_async(:async_redirect, {:ok, _result}, socket) do
98 | Process.sleep(100)
99 | {:noreply, redirect(socket, to: "/page/index")}
100 | end
101 |
102 | def handle_info(:change_h2, socket) do
103 | {:noreply, assign(socket, :h2, "I've been changed!")}
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/test/phoenix_test/live_view_bindings_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.LiveViewBindingsTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Phoenix.Component
5 | import Phoenix.LiveViewTest
6 |
7 | alias Phoenix.LiveView.JS
8 | alias PhoenixTest.Html
9 | alias PhoenixTest.LiveViewBindings
10 |
11 | describe "phx_click?" do
12 | test "returns true if parsed element has a phx-click handler" do
13 | assigns = %{}
14 |
15 | html =
16 | rendered_to_string(~H"""
17 |
18 | """)
19 |
20 | element = html |> Html.parse_fragment() |> Html.all("input")
21 |
22 | assert LiveViewBindings.phx_click?(element)
23 | end
24 |
25 | test "returns false if field doesn't have a phx-click handler" do
26 | assigns = %{}
27 |
28 | html =
29 | rendered_to_string(~H"""
30 |
31 | """)
32 |
33 | element = html |> Html.parse_fragment() |> Html.all("input")
34 |
35 | refute LiveViewBindings.phx_click?(element)
36 | end
37 |
38 | test "returns true if JS command is a push (LiveViewTest can handle)" do
39 | assigns = %{}
40 |
41 | html =
42 | rendered_to_string(~H"""
43 |
44 | """)
45 |
46 | element = html |> Html.parse_fragment() |> Html.all("input")
47 |
48 | assert LiveViewBindings.phx_click?(element)
49 | end
50 |
51 | test "returns true if JS command is a navigate (LiveViewTest can handle)" do
52 | assigns = %{}
53 |
54 | html =
55 | rendered_to_string(~H"""
56 |
57 | """)
58 |
59 | element = html |> Html.parse_fragment() |> Html.all("input")
60 |
61 | assert LiveViewBindings.phx_click?(element)
62 | end
63 |
64 | test "returns true if JS command is a patch (LiveViewTest can handle)" do
65 | assigns = %{}
66 |
67 | html =
68 | rendered_to_string(~H"""
69 |
70 | """)
71 |
72 | element = html |> Html.parse_fragment() |> Html.all("div")
73 |
74 | assert LiveViewBindings.phx_click?(element)
75 | end
76 |
77 | test "returns false if JS command is a dispatch" do
78 | assigns = %{}
79 |
80 | html =
81 | rendered_to_string(~H"""
82 |
83 | """)
84 |
85 | element = html |> Html.parse_fragment() |> Html.all("input")
86 |
87 | refute LiveViewBindings.phx_click?(element)
88 | end
89 |
90 | test "returns true if JS commands include a push or navigate" do
91 | assigns = %{}
92 |
93 | html =
94 | rendered_to_string(~H"""
95 | JS.dispatch("change")} />
96 | """)
97 |
98 | element = html |> Html.parse_fragment() |> Html.all("input")
99 |
100 | assert LiveViewBindings.phx_click?(element)
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/phoenix_test/live_view_watcher.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.LiveViewWatcher do
2 | @moduledoc false
3 | use GenServer, restart: :transient
4 |
5 | require Logger
6 |
7 | def start_link(opts) do
8 | GenServer.start_link(__MODULE__, opts)
9 | end
10 |
11 | def watch_view(pid, live_view) do
12 | GenServer.cast(pid, {:watch_view, live_view})
13 | end
14 |
15 | def init(%{caller: caller, view: live_view}) do
16 | monitored_views = %{}
17 | {:ok, views} = add_to_monitored_views(monitored_views, live_view)
18 |
19 | {:ok, %{caller: caller, views: views}}
20 | end
21 |
22 | def handle_cast({:watch_view, live_view}, state) do
23 | {:ok, views} = add_to_monitored_views(state.views, live_view)
24 |
25 | {:noreply, %{state | views: views}}
26 | end
27 |
28 | def handle_info({:DOWN, ref, :process, _pid, {:shutdown, {kind, _data} = redirect_tuple}}, state)
29 | when kind in [:redirect, :live_redirect] do
30 | case find_view_by_ref(state, ref) do
31 | {:ok, view} ->
32 | notify_caller(state, view.pid, {:live_view_redirected, redirect_tuple})
33 | state = remove_view(state, view.pid)
34 |
35 | {:noreply, state}
36 |
37 | :not_found ->
38 | {:noreply, state}
39 | end
40 | end
41 |
42 | def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
43 | case find_view_by_ref(state, ref) do
44 | {:ok, view} ->
45 | notify_caller(state, view.pid, :live_view_died)
46 | state = remove_view(state, view.pid)
47 |
48 | {:noreply, state}
49 |
50 | :not_found ->
51 | {:noreply, state}
52 | end
53 | end
54 |
55 | def handle_info(message, state) do
56 | Logger.debug(fn -> "Unhandled LiveViewWatcher message received. Message: #{inspect(message)}" end)
57 |
58 | {:noreply, state}
59 | end
60 |
61 | defp add_to_monitored_views(watched_views, live_view) do
62 | case watched_views[live_view.pid] do
63 | nil ->
64 | view = monitor_view(live_view)
65 | views = Map.put(watched_views, live_view.pid, view)
66 | {:ok, views}
67 |
68 | %{live_view_ref: _live_view_ref} = _already_watched ->
69 | {:ok, watched_views}
70 | end
71 | end
72 |
73 | defp monitor_view(live_view) do
74 | # Monitor the LiveView for exits and redirects
75 | live_view_ref = Process.monitor(live_view.pid)
76 |
77 | %{
78 | pid: live_view.pid,
79 | live_view_ref: live_view_ref
80 | }
81 | end
82 |
83 | defp notify_caller(state, view_pid, message) do
84 | send(state.caller, {:watcher, view_pid, message})
85 | end
86 |
87 | defp find_view_by_ref(state, ref) do
88 | Enum.find_value(state.views, :not_found, fn {_pid, view} ->
89 | if view.live_view_ref == ref, do: {:ok, view}
90 | end)
91 | end
92 |
93 | defp remove_view(state, view_pid) do
94 | case state.views[view_pid] do
95 | nil ->
96 | state
97 |
98 | _view ->
99 | %{state | views: Map.delete(state.views, view_pid)}
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/test/phoenix_test/conn_handler_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.ConnHandlerTest do
2 | use ExUnit.Case, async: true
3 |
4 | import PhoenixTest, only: [assert_has: 3]
5 |
6 | alias PhoenixTest.ConnHandler
7 |
8 | setup do
9 | %{conn: Phoenix.ConnTest.build_conn()}
10 | end
11 |
12 | describe "visit/2" do
13 | test "navigates to LiveView pages", %{conn: conn} do
14 | conn
15 | |> ConnHandler.visit("/live/index")
16 | |> assert_has("h1", text: "LiveView main page")
17 | end
18 |
19 | test "navigates to static pages", %{conn: conn} do
20 | conn
21 | |> ConnHandler.visit("/page/index")
22 | |> assert_has("h1", text: "Main page")
23 | end
24 |
25 | test "follows LiveView mount redirects", %{conn: conn} do
26 | conn
27 | |> ConnHandler.visit("/live/redirect_on_mount/redirect")
28 | |> assert_has("h1", text: "LiveView main page")
29 | |> assert_has("#flash-group", text: "Redirected!")
30 | end
31 |
32 | test "follows push redirects (push navigate)", %{conn: conn} do
33 | conn
34 | |> ConnHandler.visit("/live/redirect_on_mount/push_navigate")
35 | |> assert_has("h1", text: "LiveView main page")
36 | |> assert_has("#flash-group", text: "Navigated!")
37 | end
38 |
39 | test "follows static redirects", %{conn: conn} do
40 | conn
41 | |> ConnHandler.visit("/page/redirect_to_static")
42 | |> assert_has("h1", text: "Main page")
43 | |> assert_has("#flash-group", text: "Redirected!")
44 | end
45 |
46 | test "preserves headers across redirects", %{conn: conn} do
47 | conn
48 | |> Plug.Conn.put_req_header("x-custom-header", "Some-Value")
49 | |> ConnHandler.visit("/live/redirect_on_mount/redirect")
50 | |> assert_has("h1", text: "LiveView main page")
51 | |> then(fn %{conn: conn} ->
52 | assert {"x-custom-header", "Some-Value"} in conn.req_headers
53 | end)
54 | end
55 |
56 | test "raises error if app route doesn't exist", %{conn: conn} do
57 | assert_raise ArgumentError, ~r/path doesn't exist/, fn ->
58 | ConnHandler.visit(conn, "/non_route")
59 | end
60 | end
61 |
62 | test "does not raise error if url is external (typically a redirect)", %{conn: conn} do
63 | assert ConnHandler.visit(conn, "http://google.com/something")
64 | end
65 | end
66 |
67 | describe "visit/1" do
68 | test "raises error if page hasn't been visited yet", %{conn: conn} do
69 | assert_raise ArgumentError, ~r/must visit a page/, fn ->
70 | ConnHandler.visit(conn)
71 | end
72 | end
73 | end
74 |
75 | describe "build_current_path" do
76 | test "returns the conn's current path based on the request path", %{conn: conn} do
77 | current_path =
78 | conn
79 | |> Map.put(:request_path, "/hello")
80 | |> ConnHandler.build_current_path()
81 |
82 | assert current_path == "/hello"
83 | end
84 |
85 | test "includes query params when they are present", %{conn: conn} do
86 | current_path =
87 | conn
88 | |> Map.put(:request_path, "/hello")
89 | |> Map.put(:query_string, "q=23&user=1")
90 | |> ConnHandler.build_current_path()
91 |
92 | assert current_path == "/hello?q=23&user=1"
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/test/phoenix_test/live_view_watcher_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.LiveViewWatcherTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.LiveViewWatcher
5 |
6 | defmodule DummyLiveView do
7 | use GenServer, restart: :temporary
8 |
9 | def start_link(opts \\ []) do
10 | GenServer.start_link(__MODULE__, opts)
11 | end
12 |
13 | def redirect(pid) do
14 | GenServer.call(pid, :redirect)
15 | end
16 |
17 | def init(opts) do
18 | {:ok, opts}
19 | end
20 |
21 | def handle_call(:redirect, _from, state) do
22 | reason = {:shutdown, {:redirect, %{}}}
23 | {:stop, reason, state}
24 | end
25 | end
26 |
27 | describe "start_link/1" do
28 | test "watches original view as soon as Watcher is started" do
29 | {:ok, view_pid} = start_supervised(DummyLiveView)
30 | view = %{pid: view_pid}
31 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view}})
32 |
33 | %{views: views} = :sys.get_state(watcher)
34 | watched_views = Map.keys(views)
35 |
36 | assert view_pid in watched_views
37 | end
38 | end
39 |
40 | describe "watch_view/2" do
41 | test "sends :live_view_died message when LiveView dies" do
42 | {:ok, view_pid} = start_supervised(DummyLiveView)
43 | view = %{pid: view_pid}
44 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view}})
45 |
46 | :ok = LiveViewWatcher.watch_view(watcher, view)
47 |
48 | Process.exit(view_pid, :kill)
49 |
50 | assert_receive {:watcher, ^view_pid, :live_view_died}
51 | end
52 |
53 | test "sends :live_view_redirected message when LiveView redirects" do
54 | {:ok, view_pid} = start_supervised(DummyLiveView)
55 | view = %{pid: view_pid}
56 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view}})
57 |
58 | :ok = LiveViewWatcher.watch_view(watcher, view)
59 |
60 | spawn(fn ->
61 | DummyLiveView.redirect(view_pid)
62 | end)
63 |
64 | assert_receive {:watcher, ^view_pid, {:live_view_redirected, _redirect_data}}
65 | end
66 |
67 | test "does not overrides an (internal) live_view_ref info" do
68 | {:ok, view_pid} = start_supervised(DummyLiveView)
69 | view = %{pid: view_pid}
70 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view}})
71 |
72 | %{views: views} = :sys.get_state(watcher)
73 | %{live_view_ref: live_view_ref} = views[view_pid]
74 |
75 | :ok = LiveViewWatcher.watch_view(watcher, view)
76 |
77 | %{views: views} = :sys.get_state(watcher)
78 | assert %{live_view_ref: ^live_view_ref} = views[view_pid]
79 | end
80 |
81 | test "can watch multiple LiveViews" do
82 | {:ok, view_pid1} = start_supervised(DummyLiveView, id: 1)
83 | {:ok, view_pid2} = start_supervised(DummyLiveView, id: 2)
84 | view1 = %{pid: view_pid1}
85 | view2 = %{pid: view_pid2}
86 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view1}})
87 |
88 | :ok = LiveViewWatcher.watch_view(watcher, view1)
89 | :ok = LiveViewWatcher.watch_view(watcher, view2)
90 |
91 | %{views: views} = :sys.get_state(watcher)
92 | watched_views = Map.keys(views)
93 |
94 | assert view_pid1 in watched_views
95 | assert view_pid2 in watched_views
96 | end
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PhoenixTest
2 |
3 | [](https://hex.pm/packages/phoenix_test/)
4 | [](https://hexdocs.pm/phoenix_test/)
5 | [](https://github.com/germsvel/phoenix_test/blob/main/LICENSE)
6 |
7 | PhoenixTest provides a unified way of writing feature tests -- regardless of
8 | whether you're testing LiveView pages or static (non-LiveView) pages.
9 |
10 | It also handles navigation between LiveView and static pages seamlessly. So, you
11 | don't have to worry about what type of page you're visiting. Just write the
12 | tests from the user's perspective.
13 |
14 | Thus, you can test a flow going from static to LiveView pages and back without
15 | having to worry about the underlying implementation.
16 |
17 | This is a sample flow:
18 |
19 | ```elixir
20 | test "admin can create a user", %{conn: conn} do
21 | conn
22 | |> visit("/")
23 | |> click_link("Users")
24 | |> fill_in("Name", with: "Aragorn")
25 | |> click_button("Create")
26 | |> assert_has(".user", "Aragorn")
27 | end
28 | ```
29 |
30 | Note that PhoenixTest does not handle JavaScript. If you're looking for
31 | something that supports JavaScript, take a look at
32 | [Wallaby](https://hexdocs.pm/wallaby/readme.html).
33 |
34 | For full documentation, take a look at [PhoenixTest docs](https://hexdocs.pm/phoenix_test/PhoenixTest.html).
35 |
36 | ## Why PhoenixTest?
37 |
38 | ### A unified way of writing feature tests
39 |
40 | With the advent of LiveView, I find myself writing less and less JavaScript.
41 |
42 | Sure, there are sprinkles of it here and there, and there’s always the
43 | occasional need for something more.
44 |
45 | But for the most part:
46 |
47 | - If I’m going to build a page that needs interactivity, I use LiveView.
48 | - If I’m going to write a static page, I use regular controllers + views/HTML
49 | modules.
50 |
51 | The problem is that LiveView pages and static pages have _vastly different_
52 | testing strategies.
53 |
54 | If we use LiveView, we have a good set of helpers.
55 |
56 | ```elixir
57 | {:ok, view, _html} = live(conn, ~p"/")
58 |
59 | html =
60 | view
61 | |> element("#greet-guest")
62 | |> render_click()
63 |
64 | assert html =~ "Hello, guest!"
65 | ```
66 |
67 | But if we're testing a static page, we have to resort to controller testing:
68 |
69 | ```elixir
70 | conn = get(conn, ~p"/greet_page")
71 |
72 | assert html_response(conn, 200) =~ "Hello, guest!"
73 | ```
74 |
75 | That means we don’t have ways of interacting with static pages at all!
76 |
77 | What if we want to submit a form or click a link? And what if a click takes us
78 | from a LiveView to a static view or vice versa?
79 |
80 | Instead, I'd like to have a unified way of testing Phoenix apps -- regardless of
81 | whether we're testing LiveView pages or static pages.
82 |
83 | ### Improved assertions
84 |
85 | And then there's the problem of assertions.
86 |
87 | Because LiveView and controller tests use `=~` for assertions, the error
88 | messages aren't very helpful when assertions fail.
89 |
90 | After all, we’re just comparing two blobs of text – and trust me, HTML pages can
91 | get very large and hard to read as a blob of text in your terminal.
92 |
93 | LiveView tries to help with its `has_element?/3` helper, which allows us to
94 | target elements by CSS selectors and text.
95 |
96 | Unfortunately, it still doesn't provide the best errors.
97 |
98 | `has_element?/3` only tells us what was passed into the function. It doesn't
99 | give us a clue as to what else might've been on the page – maybe we just made a
100 | small typo and we have no idea!
101 |
102 | And that's where `PhoenixTest` comes in! A unified way of writing feature tests
103 | and improved assertions where they're needed!
104 |
105 | ## What do you mean by "static" pages?
106 |
107 | We use the term _static_ as compared to LiveView pages. Thus, in PhoenixTest's
108 | terminology static pages are what is typically known as dynamic, server-rendered
109 | pages -- pages that were normal prior to the advent of LiveView and which are
110 | sometimes called "dead" views. Thus, we do not mean _static_ in the sense that
111 | static-site generators (such as Jekyll, Gatsby, etc.) mean it.
112 |
113 |
114 | **Made with ❤️ by [German Velasco]**
115 |
116 | [German Velasco]: https://germanvelasco.com
117 |
--------------------------------------------------------------------------------
/test/phoenix_test/live_view_timeout_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.LiveViewTimeoutTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.Live
5 | alias PhoenixTest.LiveViewTimeout
6 |
7 | defmodule DummyLiveView do
8 | use GenServer, restart: :temporary
9 |
10 | def start_link(opts \\ []) do
11 | GenServer.start_link(__MODULE__, opts)
12 | end
13 |
14 | def render_html(pid) do
15 | GenServer.call(pid, :render_html)
16 | end
17 |
18 | def redirect(pid) do
19 | GenServer.call(pid, :redirect)
20 | end
21 |
22 | def init(opts) do
23 | {:ok, opts}
24 | end
25 |
26 | def handle_call({:phoenix, :ping}, _from, state) do
27 | {:reply, :ok, state}
28 | end
29 |
30 | def handle_call(:render_html, _from, state) do
31 | {:reply, "rendered HTML", state}
32 | end
33 |
34 | def handle_call(:redirect, _from, state) do
35 | reason = {:shutdown, {:redirect, %{to: "/live/index"}}}
36 | {:stop, reason, state}
37 | end
38 | end
39 |
40 | describe "with_timeout/3" do
41 | alias PhoenixTest.LiveViewWatcher
42 |
43 | setup do
44 | {:ok, view_pid} = start_supervised(DummyLiveView)
45 | view = %{pid: view_pid}
46 | conn = Phoenix.ConnTest.build_conn()
47 | {:ok, watcher} = start_supervised({LiveViewWatcher, %{caller: self(), view: view}})
48 | session = %Live{conn: conn, view: view, watcher: watcher}
49 |
50 | {:ok, %{session: session}}
51 | end
52 |
53 | test "performs action if timeout is 0", %{session: session} do
54 | action = fn _session -> {:ok, :action_performed} end
55 |
56 | assert {:ok, :action_performed} = LiveViewTimeout.with_timeout(session, 0, action)
57 | end
58 |
59 | test "performs action at the end of timeout", %{session: session} do
60 | action = fn session -> DummyLiveView.render_html(session.view.pid) end
61 |
62 | assert "rendered HTML" = LiveViewTimeout.with_timeout(session, 100, action)
63 | end
64 |
65 | test "retries action at an interval when it fails", %{session: session} do
66 | action = fn session ->
67 | # Not deterministic, but close enough
68 | case Enum.random([:fail, :fail, :pass]) do
69 | :fail ->
70 | raise ExUnit.AssertionError, message: "Example failure"
71 |
72 | :pass ->
73 | DummyLiveView.render_html(session.view.pid)
74 | end
75 | end
76 |
77 | assert "rendered HTML" = LiveViewTimeout.with_timeout(session, 1000, action)
78 | end
79 |
80 | test "redirects when LiveView notifies of redirection", %{session: session} do
81 | %{view: %{pid: view_pid}} = session
82 |
83 | action = fn
84 | %{view: %{pid: ^view_pid}} ->
85 | DummyLiveView.redirect(view_pid)
86 |
87 | _redirected_view ->
88 | :redirected
89 | end
90 |
91 | assert :redirected = LiveViewTimeout.with_timeout(session, 1000, action)
92 | end
93 |
94 | test "tries to redirect if the LiveView dies before timeout", %{session: session} do
95 | %{view: %{pid: view_pid}} = session
96 | test_pid = self()
97 |
98 | action = fn
99 | %{view: %{pid: ^view_pid}} ->
100 | # Kill DummyLiveView and then attempt to send message
101 | # to emulate LiveView behavior
102 | Process.exit(view_pid, :kill)
103 | DummyLiveView.render_html(view_pid)
104 |
105 | _redirected_view ->
106 | :ok
107 | end
108 |
109 | fetch_redirect_info = fn session ->
110 | send(test_pid, {:redirect_attempted, from_view: session.view.pid})
111 | {"/live/index", %{}}
112 | end
113 |
114 | :ok = LiveViewTimeout.with_timeout(session, 1000, action, fetch_redirect_info)
115 |
116 | assert_receive {:redirect_attempted, from_view: ^view_pid}
117 | end
118 |
119 | test "attempts redirects when LiveView exits due to timeout", %{session: session} do
120 | %{view: %{pid: view_pid}} = session
121 | test_pid = self()
122 | too_short_timeout = LiveViewTimeout.interval_wait_time() - 10
123 |
124 | action = fn
125 | %{view: %{pid: ^view_pid}} ->
126 | # Kill DummyLiveView and then attempt to send message
127 | # to emulate LiveView behavior
128 | Process.exit(view_pid, :kill)
129 | DummyLiveView.render_html(view_pid)
130 |
131 | _redirected_view ->
132 | :ok
133 | end
134 |
135 | fetch_redirect_info = fn session ->
136 | send(test_pid, {:redirect_attempted, from_view: session.view.pid})
137 | {"/live/index", %{}}
138 | end
139 |
140 | :ok = LiveViewTimeout.with_timeout(session, too_short_timeout, action, fetch_redirect_info)
141 |
142 | assert_receive {:redirect_attempted, from_view: ^view_pid}
143 | end
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/test/phoenix_test/element/select_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.Element.SelectTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.Element.Select
5 |
6 | describe "find_select_option!" do
7 | test "returns the selected option value" do
8 | html = """
9 |
10 |
14 | """
15 |
16 | field = Select.find_select_option!(html, "select", "Name", "Select 2", exact: true)
17 |
18 | assert ~s|[id="name"]| = field.selector
19 | assert ["select_2"] = field.value
20 | end
21 |
22 | test "finds select nested in label" do
23 | html = """
24 |
31 | """
32 |
33 | field = Select.find_select_option!(html, "select", "Name", "Select 2", exact: true)
34 |
35 | assert ~s|[id="name"]| = field.selector
36 | assert ["select_2"] = field.value
37 | end
38 |
39 | test "returns multiple selected option value" do
40 | html = """
41 |
42 |
47 | """
48 |
49 | field = Select.find_select_option!(html, "select", "Name", ["Select 2", "Select 3"], exact: true)
50 |
51 | assert ~s|[id="name"]| = field.selector
52 | assert ["select_2", "select_3"] = field.value
53 | end
54 |
55 | test "can target option with substring match" do
56 | html = """
57 |
58 |
62 | """
63 |
64 | field = Select.find_select_option!(html, "select", "Name", "On", exact_option: false)
65 |
66 | assert ~s|[id="name"]| = field.selector
67 | assert ["one"] = field.value
68 | end
69 |
70 | test "returns multiple selected option value without multiple attribute to select raises error" do
71 | html = """
72 |
73 |
78 | """
79 |
80 | assert_raise ArgumentError, ~r/Could not find a select with a "multiple" attribute set/, fn ->
81 | Select.find_select_option!(html, "select", "Name", ["Select 2", "Select 3"], exact: true)
82 | end
83 | end
84 | end
85 |
86 | describe "belongs_to_form?" do
87 | test "returns true if field is inside a form" do
88 | html = """
89 |
95 | """
96 |
97 | field = Select.find_select_option!(html, "select", "Name", "Select 1", exact: true)
98 |
99 | assert Select.belongs_to_form?(field, html)
100 | end
101 |
102 | test "returns false if field is outside of a form" do
103 | html = """
104 |
105 |
108 | """
109 |
110 | field = Select.find_select_option!(html, "select", "Name", "Select 1", exact: true)
111 |
112 | refute Select.belongs_to_form?(field, html)
113 | end
114 | end
115 |
116 | describe "phx_click_option?" do
117 | test "returns true if all option have a phx-click attached" do
118 | html = """
119 |
120 |
124 | """
125 |
126 | field = Select.find_select_option!(html, "select", "Name", "Select 2", exact: true)
127 |
128 | assert Select.phx_click_options?(field)
129 | end
130 |
131 | test "returns false if any option doesn't have e phx-click attached" do
132 | html = """
133 |
134 |
138 | """
139 |
140 | field = Select.find_select_option!(html, "select", "Name", "Select 2", exact: true)
141 |
142 | refute Select.phx_click_options?(field)
143 | end
144 | end
145 | end
146 |
--------------------------------------------------------------------------------
/lib/phoenix_test/element/form.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.Element.Form do
2 | @moduledoc false
3 |
4 | alias PhoenixTest.Element
5 | alias PhoenixTest.Element.Button
6 | alias PhoenixTest.FormData
7 | alias PhoenixTest.Html
8 | alias PhoenixTest.Query
9 | alias PhoenixTest.Utils
10 |
11 | defstruct ~w[selector parsed id action method form_data submit_button]a
12 |
13 | def find!(html, selector) do
14 | html
15 | |> Query.find!(selector)
16 | |> build()
17 | end
18 |
19 | def find(html, selector) do
20 | html
21 | |> Query.find(selector)
22 | |> case do
23 | {:found, element} -> {:found, build(element)}
24 | {:found_many, elements} -> {:found_many, Enum.map(elements, &build/1)}
25 | :not_found -> :not_found
26 | end
27 | end
28 |
29 | def find_by_descendant!(html, descendant) do
30 | html
31 | |> Query.find_ancestor!("form", descendant_selector(descendant))
32 | |> build()
33 | end
34 |
35 | defp build(%LazyHTML{} = form) do
36 | id = Html.attribute(form, "id")
37 | action = Html.attribute(form, "action")
38 | selector = Element.build_selector(form)
39 |
40 | %__MODULE__{
41 | action: action,
42 | form_data: form_data(form),
43 | id: id,
44 | method: operative_method(form),
45 | parsed: form,
46 | selector: selector,
47 | submit_button: Button.find_first(form)
48 | }
49 | end
50 |
51 | def form_element_names(%__MODULE__{} = form) do
52 | form.parsed
53 | |> Html.all("[name]")
54 | |> Enum.map(&Html.attribute(&1, "name"))
55 | |> Enum.uniq()
56 | end
57 |
58 | def phx_change?(form) do
59 | form.parsed
60 | |> Html.attribute("phx-change")
61 | |> Utils.present?()
62 | end
63 |
64 | def phx_submit?(form) do
65 | form.parsed
66 | |> Html.attribute("phx-submit")
67 | |> Utils.present?()
68 | end
69 |
70 | def has_action?(form), do: Utils.present?(form.action)
71 |
72 | defp descendant_selector(%{id: id}) when is_binary(id), do: "[id=#{inspect(id)}]"
73 | defp descendant_selector(%{selector: selector, text: text}), do: {selector, text}
74 | defp descendant_selector(%{selector: selector}), do: selector
75 |
76 | @simple_value_types ~w(
77 | date
78 | datetime-local
79 | email
80 | month
81 | number
82 | password
83 | range
84 | search
85 | tel
86 | text
87 | time
88 | url
89 | week
90 | )
91 |
92 | @hidden_inputs "input[type='hidden']"
93 | @checked_radio_buttons "input:not([disabled])[type='radio'][value]:checked"
94 | @checked_checkboxes "input:not([disabled])[type='checkbox'][value]:checked"
95 | @pre_filled_default_text_inputs "input:not([disabled]):not([type])[value]"
96 |
97 | @pre_filled_simple_value_inputs Enum.map_join(
98 | @simple_value_types,
99 | ",",
100 | &"input:not([disabled])[type='#{&1}'][value]"
101 | )
102 |
103 | defp form_data(form) do
104 | FormData.new()
105 | |> FormData.add_data(form_data(@hidden_inputs, form))
106 | |> FormData.add_data(form_data(@checked_radio_buttons, form))
107 | |> FormData.add_data(form_data(@checked_checkboxes, form))
108 | |> FormData.add_data(form_data(@pre_filled_simple_value_inputs, form))
109 | |> FormData.add_data(form_data(@pre_filled_default_text_inputs, form))
110 | |> FormData.add_data(form_data_textarea(form))
111 | |> FormData.add_data(form_data_select(form))
112 | end
113 |
114 | defp form_data(selector, form) do
115 | form
116 | |> Html.all(selector)
117 | |> Enum.map(&to_form_field/1)
118 | end
119 |
120 | defp form_data_textarea(form) do
121 | form
122 | |> Html.all("textarea:not([disabled])")
123 | |> Enum.map(&to_form_field/1)
124 | end
125 |
126 | defp form_data_select(form) do
127 | form
128 | |> Html.all("select:not([disabled])")
129 | |> Enum.flat_map(fn select ->
130 | selected_options = Html.all(select, "option[selected]")
131 | multiple? = Html.attribute(select, "multiple") != nil
132 |
133 | case {multiple?, Enum.count(selected_options)} do
134 | {true, 0} ->
135 | []
136 |
137 | {false, 0} ->
138 | if option = select |> Html.all("option") |> Enum.at(0) do
139 | [to_form_field(select, option)]
140 | else
141 | []
142 | end
143 |
144 | {false, _} ->
145 | [to_form_field(select, selected_options)]
146 |
147 | _ ->
148 | Enum.map(selected_options, &to_form_field(select, &1))
149 | end
150 | end)
151 | end
152 |
153 | def put_button_data(form, nil), do: form
154 |
155 | def put_button_data(form, %Button{} = button) do
156 | Map.update!(form, :form_data, &FormData.add_data(&1, button))
157 | end
158 |
159 | defp to_form_field(element) do
160 | to_form_field(element, element)
161 | end
162 |
163 | defp to_form_field(name_element, value_element) do
164 | name = Html.attribute(name_element, "name")
165 | {name, element_value(value_element)}
166 | end
167 |
168 | defp element_value(element) do
169 | Html.attribute(element, "value") || Html.element_text(element)
170 | end
171 |
172 | defp operative_method(%LazyHTML{} = form) do
173 | hidden_input_method_value(form) || Html.attribute(form, "method") || "get"
174 | end
175 |
176 | defp hidden_input_method_value(form) do
177 | form
178 | |> Html.all("input[type='hidden'][name='_method']")
179 | |> Enum.find_value(&Html.attribute(&1, "value"))
180 | end
181 | end
182 |
--------------------------------------------------------------------------------
/test/phoenix_test/element/field_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.Element.FieldTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias PhoenixTest.Element.Field
5 |
6 | describe "find_input!" do
7 | test "finds text field" do
8 | html = """
9 |
10 |
11 | """
12 |
13 | field = Field.find_input!(html, "input", "Name", exact: true)
14 |
15 | assert %{id: "name", label: "Name", name: "name", value: "Hello world"} = field
16 | end
17 |
18 | test "finds radio button specified by label" do
19 | html = """
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | """
29 |
30 | field = Field.find_input!(html, "input", "Elf", exact: true)
31 |
32 | assert %{id: "elf", label: "Elf", name: "race", value: "elf"} = field
33 | end
34 |
35 | test "finds input if nested inside label (and no id)" do
36 | html = """
37 |
41 | """
42 |
43 | field = Field.find_input!(html, "input", "Name", exact: true)
44 |
45 | assert %{label: "Name", name: "name", value: "Hello world"} = field
46 | end
47 |
48 | test "builds a selector based on id if id is present" do
49 | html = """
50 |
51 |
52 | """
53 |
54 | field = Field.find_input!(html, "input", "Name", exact: true)
55 |
56 | assert %{selector: ~s|[id="name"]|} = field
57 | end
58 |
59 | test "builds a composite selector if id isn't present" do
60 | html = """
61 |
65 | """
66 |
67 | field = Field.find_input!(html, "input", "Name", exact: true)
68 |
69 | assert ~s(input[type="text"][name="name"]) = field.selector
70 | end
71 | end
72 |
73 | describe "find_checkbox!" do
74 | test "finds a checkbox and defaults value to 'on'" do
75 | html = """
76 |
77 |
78 | """
79 |
80 | field = Field.find_checkbox!(html, "input", "Yes", exact: true)
81 |
82 | assert %{id: "yes", label: "Yes", name: "yes", value: "on"} = field
83 | end
84 |
85 | test "finds a checkbox and uses value if present" do
86 | html = """
87 |
88 |
89 | """
90 |
91 | field = Field.find_checkbox!(html, "input", "Yes", exact: true)
92 |
93 | assert %{value: "yes"} = field
94 | end
95 | end
96 |
97 | describe "find_hidden_uncheckbox!" do
98 | test "finds and uses hidden input's value that is associated to the checkbox" do
99 | html = """
100 |
101 |
102 |
103 | """
104 |
105 | field = Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true)
106 |
107 | assert %{id: "yes", label: "Yes", name: "yes", value: "no"} = field
108 | end
109 |
110 | test "raises an error if checkbox input doesn't have a `name` (needed to find hidden input)" do
111 | html = """
112 |
113 |
114 |
115 | """
116 |
117 | assert_raise ArgumentError, ~r/Could not find element/, fn ->
118 | Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true)
119 | end
120 | end
121 |
122 | test "raises an error if hidden input doesn't have a `name`" do
123 | html = """
124 |
125 |
126 |
127 | """
128 |
129 | assert_raise ArgumentError, ~r/Could not find element/, fn ->
130 | Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true)
131 | end
132 | end
133 | end
134 |
135 | describe "phx_click?" do
136 | test "returns true if field has a phx-click handler" do
137 | html = """
138 |
139 |
140 | """
141 |
142 | field = Field.find_input!(html, "input", "Name", exact: true)
143 |
144 | assert Field.phx_click?(field)
145 | end
146 |
147 | test "returns false if field doesn't have a phx-click handler" do
148 | html = """
149 |
150 |
151 | """
152 |
153 | field = Field.find_input!(html, "input", "Name", exact: true)
154 |
155 | refute Field.phx_click?(field)
156 | end
157 | end
158 |
159 | describe "belongs_to_form?" do
160 | test "returns true if field is inside a form" do
161 | html = """
162 |
166 | """
167 |
168 | field = Field.find_input!(html, "input", "Name", exact: true)
169 |
170 | assert Field.belongs_to_form?(field, html)
171 | end
172 |
173 | test "returns false if field is outside of a form" do
174 | html = """
175 |
176 |
177 | """
178 |
179 | field = Field.find_input!(html, "input", "Name", exact: true)
180 |
181 | refute Field.belongs_to_form?(field, html)
182 | end
183 | end
184 |
185 | describe "validate_name!" do
186 | test "raises error if name attribute is missing" do
187 | html = """
188 |
189 |
190 | """
191 |
192 | field = Field.find_input!(html, "input", "Name", exact: true)
193 |
194 | assert_raise ArgumentError, ~r/missing a `name`/, fn ->
195 | Field.validate_name!(field)
196 | end
197 | end
198 | end
199 | end
200 |
--------------------------------------------------------------------------------
/test/support/web_app/components.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixTest.WebApp.Components do
2 | @moduledoc false
3 | use Phoenix.Component
4 |
5 | @doc """
6 | Renders an input with label and error messages.
7 |
8 | A `Phoenix.HTML.FormField` may be passed as argument,
9 | which is used to retrieve the input name, id, and values.
10 | Otherwise all attributes may be passed explicitly.
11 |
12 | ## Types
13 |
14 | This function accepts all HTML input types, considering that:
15 |
16 | * You may also set `type="select"` to render a `