├── .formatter.exs ├── .git-blame-ignore-revs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bench ├── README.md └── assertions.exs ├── bin └── publish ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── phoenix_test.ex └── phoenix_test │ ├── active_form.ex │ ├── assertions.ex │ ├── conn_handler.ex │ ├── credo │ └── no_open_browser.ex │ ├── data_attribute_form.ex │ ├── driver.ex │ ├── element.ex │ ├── element │ ├── button.ex │ ├── field.ex │ ├── form.ex │ ├── link.ex │ └── select.ex │ ├── file_upload.ex │ ├── form_data.ex │ ├── form_payload.ex │ ├── html.ex │ ├── live.ex │ ├── live_view_bindings.ex │ ├── live_view_timeout.ex │ ├── live_view_watcher.ex │ ├── locators.ex │ ├── open_browser.ex │ ├── query.ex │ ├── session_helpers.ex │ ├── static.ex │ └── utils.ex ├── mix.exs ├── mix.lock ├── test ├── assets │ └── js │ │ └── app.js ├── files │ ├── elixir.jpg │ └── phoenix.png ├── phoenix_test │ ├── active_form_test.exs │ ├── assertions_test.exs │ ├── conn_handler_test.exs │ ├── credo │ │ └── no_open_browser_test.exs │ ├── data_attribute_form_test.exs │ ├── element │ │ ├── button_test.exs │ │ ├── field_test.exs │ │ ├── form_test.exs │ │ └── select_test.exs │ ├── element_test.exs │ ├── form_data_test.exs │ ├── form_payload_test.exs │ ├── html_test.exs │ ├── live_test.exs │ ├── live_view_bindings_test.exs │ ├── live_view_timeout_test.exs │ ├── live_view_watcher_test.exs │ ├── locators_test.exs │ ├── query_test.exs │ ├── session_helpers_test.exs │ ├── static_test.exs │ └── utils_test.exs ├── phoenix_test_test.exs ├── support │ ├── test_helpers.ex │ └── web_app │ │ ├── async_page_2_live.ex │ │ ├── async_page_live.ex │ │ ├── components.ex │ │ ├── dynamic_form_live.ex │ │ ├── endpoint.ex │ │ ├── error_view.ex │ │ ├── index_live.ex │ │ ├── layout_view.ex │ │ ├── page_2_live.ex │ │ ├── page_controller.ex │ │ ├── page_view.ex │ │ ├── redirect_live.ex │ │ ├── router.ex │ │ └── simple_ordinal_inputs_live.ex └── test_helper.exs └── upgrade_guides.md /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:phoenix], 4 | plugins: [Phoenix.LiveView.HTMLFormatter, Styler], 5 | inputs: ["{mix,.formatter}.exs", "*.{heex,ex,exs}", "{bench,config,lib,test}/**/*.{heex,ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Run this command to always ignore formatting commits in `git blame` 2 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 3 | 4 | # Introduced Styler and ran mix format 5 | d3f22b4ef34d7cbe70e7a36cfa719cc54d4d667e 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 19 | strategy: 20 | matrix: 21 | otp: ['27.2'] 22 | elixir: ['1.17', '1.18'] 23 | steps: 24 | - name: Set up Elixir 25 | uses: erlef/setup-beam@v1 26 | with: 27 | otp-version: ${{matrix.otp}} 28 | elixir-version: ${{matrix.elixir}} 29 | 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Cache deps 34 | id: cache-deps 35 | uses: actions/cache@v3 36 | env: 37 | cache-name: cache-elixir-deps 38 | with: 39 | path: deps 40 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-mix-${{ env.cache-name }}- 43 | 44 | - name: Cache compiled build 45 | id: cache-build 46 | uses: actions/cache@v3 47 | env: 48 | cache-name: cache-compiled-build 49 | with: 50 | path: _build 51 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 52 | restore-keys: | 53 | ${{ runner.os }}-mix-${{ env.cache-name }}- 54 | ${{ runner.os }}-mix- 55 | 56 | - name: Bust cache on job rerun to rule out incremental build as a source of flakiness 57 | if: github.run_attempt != '1' 58 | run: | 59 | mix deps.clean --all 60 | mix clean 61 | shell: sh 62 | 63 | - name: Install dependencies 64 | run: mix deps.get 65 | 66 | - name: Compiles without warnings 67 | run: mix compile --warnings-as-errors 68 | 69 | - name: Check Formatting 70 | run: mix format --check-formatted 71 | 72 | - name: Run tests 73 | run: mix test 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phoenix_test-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0 2 | elixir 1.18.1-otp-27 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 The Software League 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoenixTest 2 | 3 | [![Module Version](https://img.shields.io/hexpm/v/phoenix_test.svg)](https://hex.pm/packages/phoenix_test/) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/phoenix_test/) 5 | [![License](https://img.shields.io/hexpm/l/phoenix_test.svg)](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", text: "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 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | # Performance Benchmarks 2 | 3 | Performance benchmarks are run using [benchee](https://github.com/bencheeorg/benchee). 4 | 5 | Run a benchmark with `MIX_ENV=test mix run `, for example 6 | `MIX_ENV=test mix run bench/assertions.exs`. We use the `test` environment so 7 | that benchmarks have access to the same testing LiveView application used by 8 | unit tests. 9 | -------------------------------------------------------------------------------- /bench/assertions.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule PhoenixTestBenchmark do 4 | @moduledoc """ 5 | We mimic an ExUnit test so that LiveView helpers work as expected. 6 | """ 7 | use ExUnit.Case, async: true 8 | 9 | import ExUnit.Assertions, only: [assert: 1] 10 | import Phoenix.ConnTest 11 | import Phoenix.LiveViewTest 12 | 13 | @endpoint PhoenixTest.WebApp.Endpoint 14 | 15 | test "run assertion benchmarks" do 16 | {:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: PhoenixTest.PubSub}], strategy: :one_for_one) 17 | {:ok, _} = PhoenixTest.WebApp.Endpoint.start_link() 18 | 19 | conn = Phoenix.ConnTest.build_conn() 20 | {:ok, view, html} = live(conn, "/live/index") 21 | session = PhoenixTest.visit(conn, "/live/index") 22 | 23 | Benchee.run(%{ 24 | "PhoenixTest.assert_has/2" => fn -> 25 | PhoenixTest.assert_has(session, "[data-role='title']") 26 | end, 27 | "PhoenixTest.assert_has/3, tag selector" => fn -> 28 | PhoenixTest.assert_has(session, "li", text: "Aragorn") 29 | end, 30 | "PhoenixTest.assert_has/3, id+tag selector" => fn -> 31 | PhoenixTest.assert_has(session, "#multiple-items li", text: "Aragorn") 32 | end, 33 | "PhoenixTest.assert_has/3, using within id, tag selector" => fn -> 34 | PhoenixTest.within(session, "#multiple-items", fn s -> 35 | PhoenixTest.assert_has(s, "li", text: "Aragorn") 36 | end) 37 | end, 38 | "LiveView string matching" => fn -> 39 | assert html =~ "main page" 40 | end, 41 | "LiveView tag selector" => fn -> 42 | assert has_element?(view, "li", "Aragorn") 43 | end, 44 | "LiveView id+tag selector" => fn -> 45 | assert has_element?(view, "#multiple-items li", "Aragorn") 46 | end 47 | }) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /bin/publish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | previous_version="${1}" 6 | release_version="${2}" 7 | 8 | sed -i '' "s/$previous_version/$release_version/" lib/phoenix_test.ex 9 | sed -i '' "s/$previous_version/$release_version/" mix.exs 10 | 11 | git add . 12 | git commit -m "Publish version $release_version" 13 | git tag "v$release_version" 14 | git push origin "v$release_version" 15 | mix hex.publish 16 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix_test, :endpoint, PhoenixTest.Endpoint 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix_test, :endpoint, PhoenixTest.WebApp.Endpoint 4 | 5 | config :phoenix_test, PhoenixTest.WebApp.Endpoint, 6 | server: true, 7 | http: [port: 4000], 8 | live_view: [signing_salt: "112345678212345678312345678412"], 9 | secret_key_base: String.duplicate("57689", 50), 10 | pubsub_server: PhoenixTest.PubSub, 11 | render_errors: [ 12 | formats: [html: PhoenixTest.WebApp.ErrorView], 13 | layout: false 14 | ] 15 | 16 | config :logger, level: :error 17 | 18 | config :esbuild, 19 | version: "0.17.11", 20 | default: [ 21 | args: ~w(js/app.js --bundle --target=es2017 --outdir=../../priv/static/assets), 22 | cd: Path.expand("../test/assets", __DIR__), 23 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 24 | ] 25 | -------------------------------------------------------------------------------- /lib/phoenix_test/active_form.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.ActiveForm do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.FormData 5 | 6 | defstruct [:id, :selector, form_data: FormData.new(), uploads: FormData.new()] 7 | 8 | @doc """ 9 | Data structure for tracking active form fields filled. 10 | 11 | Do not keep track of default form data on the page. That's what 12 | `PhoenixTest.Form.form_data` is for. 13 | """ 14 | def new(opts \\ []) when is_list(opts) do 15 | struct!(%__MODULE__{}, opts) 16 | end 17 | 18 | def add_form_data(%__MODULE__{} = active_form, new_form_data) do 19 | Map.update!(active_form, :form_data, &FormData.add_data(&1, new_form_data)) 20 | end 21 | 22 | def add_upload(%__MODULE__{} = active_form, new_upload) do 23 | Map.update!(active_form, :uploads, &FormData.add_data(&1, new_upload)) 24 | end 25 | 26 | def active?(%__MODULE__{} = active_form) do 27 | not FormData.empty?(active_form.form_data) or not FormData.empty?(active_form.uploads) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/phoenix_test/assertions.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Assertions do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions 5 | 6 | alias ExUnit.AssertionError 7 | alias PhoenixTest.Html 8 | alias PhoenixTest.Query 9 | alias PhoenixTest.Utils 10 | 11 | defmodule Opts do 12 | @moduledoc false 13 | defstruct [ 14 | :at, 15 | :count, 16 | :exact, 17 | :label, 18 | :text, 19 | :value 20 | ] 21 | 22 | def parse(opts) when is_list(opts) do 23 | at = Keyword.get(opts, :at, :any) 24 | count = Keyword.get(opts, :count, :any) 25 | exact = Keyword.get(opts, :exact, false) 26 | label = Keyword.get(opts, :label, :no_label) 27 | text = Keyword.get(opts, :text, :no_text) 28 | value = Keyword.get(opts, :value, :no_value) 29 | 30 | %__MODULE__{ 31 | at: at, 32 | count: count, 33 | exact: exact, 34 | label: label, 35 | text: text, 36 | value: value 37 | } 38 | end 39 | 40 | def to_list(%__MODULE__{} = opts) do 41 | [ 42 | at: opts.at, 43 | count: opts.count, 44 | exact: opts.exact, 45 | label: opts.label, 46 | text: opts.text, 47 | value: opts.value 48 | ] 49 | end 50 | end 51 | 52 | @doc """ 53 | Asserts that the rendered HTML content within the given session contains an 54 | element matching the specified selector and (optional) text. 55 | 56 | ## Parameters 57 | 58 | - `session`: The test session. 59 | - `selector`: The CSS selector to search for. 60 | - `text` (optional): The expected text content of the element. 61 | 62 | ## Raises 63 | 64 | Raises `AssertionError` if no element is found with the given selector and text. 65 | """ 66 | def assert_has(session, "title") do 67 | title = PhoenixTest.Driver.render_page_title(session) 68 | 69 | if is_nil(title) || title == "" do 70 | raise AssertionError, 71 | message: """ 72 | Expected title to be present but could not find it. 73 | """ 74 | else 75 | assert true 76 | end 77 | 78 | session 79 | end 80 | 81 | def assert_has(session, selector) when is_binary(selector) do 82 | assert_has(session, selector, count: :any) 83 | end 84 | 85 | def assert_has(session, "title", opts) do 86 | text = Keyword.fetch!(opts, :text) 87 | exact = Keyword.get(opts, :exact, false) 88 | title = PhoenixTest.Driver.render_page_title(session) 89 | matches? = if exact, do: title == text, else: title =~ text 90 | 91 | if matches? do 92 | assert true 93 | else 94 | raise AssertionError, 95 | message: """ 96 | Expected title to be #{inspect(text)} but got #{inspect(title)} 97 | """ 98 | end 99 | 100 | session 101 | end 102 | 103 | @label_related_failures [:no_label, :missing_for, :missing_input] 104 | def assert_has(session, selector, opts) when is_list(opts) do 105 | opts = Opts.parse(opts) 106 | finder = finder_fun(selector, opts) 107 | 108 | session 109 | |> PhoenixTest.Driver.render_html() 110 | |> finder.() 111 | |> case do 112 | :not_found -> 113 | raise AssertionError, assert_not_found_error_msg(selector, opts) 114 | 115 | {:not_found, potential_matches} -> 116 | raise AssertionError, 117 | message: assert_not_found_error_msg(selector, opts, potential_matches) 118 | 119 | {:not_found, failure, potential_matches} when failure in @label_related_failures -> 120 | raise AssertionError, 121 | message: assert_not_found_error_msg(selector, opts, potential_matches) 122 | 123 | {:not_found, :found_many_labels_with_inputs, _label_elements, found} -> 124 | found_count = Enum.count(found) 125 | 126 | if opts.count in [:any, found_count] do 127 | assert true 128 | else 129 | raise AssertionError, 130 | message: assert_incorrect_count_error_msg(selector, opts, found) 131 | end 132 | 133 | {:found, found} -> 134 | if opts.count in [:any, 1] do 135 | assert true 136 | else 137 | raise AssertionError, 138 | message: assert_incorrect_count_error_msg(selector, opts, [found]) 139 | end 140 | 141 | {:found_many, found} -> 142 | found_count = Enum.count(found) 143 | 144 | if opts.count in [:any, found_count] do 145 | assert true 146 | else 147 | raise AssertionError, 148 | message: assert_incorrect_count_error_msg(selector, opts, found) 149 | end 150 | end 151 | 152 | session 153 | end 154 | 155 | @doc """ 156 | Asserts that the rendered HTML within the given session does not contain an element matching the specified selector and text. 157 | 158 | ## Parameters 159 | 160 | - `session`: The test session. 161 | - `selector`: The CSS selector for the element. 162 | - `text`: The text that the element is expected not to contain. 163 | 164 | ## Raises 165 | 166 | Raises `AssertionError` if an element matching the selector and text is found. 167 | """ 168 | def refute_has(session, "title") do 169 | title = PhoenixTest.Driver.render_page_title(session) 170 | 171 | if is_nil(title) do 172 | refute false 173 | else 174 | raise AssertionError, 175 | message: """ 176 | Expected title not to be present but found: #{inspect(title)} 177 | """ 178 | end 179 | 180 | session 181 | end 182 | 183 | def refute_has(session, selector) when is_binary(selector) do 184 | refute_has(session, selector, count: :any) 185 | end 186 | 187 | def refute_has(session, "title", opts) do 188 | text = Keyword.fetch!(opts, :text) 189 | exact = Keyword.get(opts, :exact, false) 190 | title = PhoenixTest.Driver.render_page_title(session) 191 | matches? = if exact, do: title == text, else: title =~ text 192 | 193 | if matches? do 194 | raise AssertionError, 195 | message: """ 196 | Expected title not to be #{inspect(text)} 197 | """ 198 | else 199 | refute false 200 | end 201 | 202 | session 203 | end 204 | 205 | def refute_has(session, selector, opts) when is_list(opts) do 206 | opts = Opts.parse(opts) 207 | finder = finder_fun(selector, opts) 208 | 209 | session 210 | |> PhoenixTest.Driver.render_html() 211 | |> finder.() 212 | |> case do 213 | :not_found -> 214 | refute false 215 | 216 | {:not_found, _} -> 217 | refute false 218 | 219 | {:not_found, failure, _} when failure in @label_related_failures -> 220 | refute false 221 | 222 | {:not_found, :found_many_labels_with_inputs, _labels, elements} -> 223 | found_count = Enum.count(elements) 224 | 225 | if opts.count in [:any, found_count] do 226 | raise AssertionError, message: refute_found_error_msg(selector, opts, elements) 227 | else 228 | refute false 229 | end 230 | 231 | {:found, element} -> 232 | if opts.count in [:any, 1] do 233 | raise AssertionError, message: refute_found_error_msg(selector, opts, [element]) 234 | else 235 | refute false 236 | end 237 | 238 | {:found_many, elements} -> 239 | found_count = Enum.count(elements) 240 | 241 | if opts.count in [:any, found_count] do 242 | raise AssertionError, message: refute_found_error_msg(selector, opts, elements) 243 | else 244 | refute false 245 | end 246 | end 247 | 248 | session 249 | end 250 | 251 | def assert_path(session, path) do 252 | uri = URI.parse(PhoenixTest.Driver.current_path(session)) 253 | 254 | if path_matches?(path, uri.path) do 255 | assert true 256 | else 257 | msg = """ 258 | Expected path to be #{inspect(path)} but got #{inspect(uri.path)} 259 | """ 260 | 261 | raise AssertionError, msg 262 | end 263 | 264 | session 265 | end 266 | 267 | def assert_path(session, path, opts) do 268 | params = Keyword.get(opts, :query_params) 269 | 270 | session 271 | |> assert_path(path) 272 | |> assert_query_params(params) 273 | end 274 | 275 | defp path_matches?(path, path), do: true 276 | 277 | defp path_matches?(expected, is) do 278 | parts_expected = String.split(expected, "/") 279 | parts_is = String.split(is, "/") 280 | 281 | if Enum.count(parts_expected) != Enum.count(parts_is) do 282 | false 283 | else 284 | parts_not_matching = 285 | parts_expected 286 | |> Enum.zip(parts_is) 287 | |> Enum.filter(fn {expect, is} -> uri_parts_match?(expect, is) == false end) 288 | 289 | parts_not_matching == [] 290 | end 291 | end 292 | 293 | def uri_parts_match?("*", _), do: true 294 | def uri_parts_match?(part, part), do: true 295 | def uri_parts_match?(_a, _b), do: false 296 | 297 | defp assert_query_params(session, params) do 298 | params = Utils.stringify_keys_and_values(params) 299 | 300 | uri = URI.parse(PhoenixTest.Driver.current_path(session)) 301 | query_params = uri.query && Plug.Conn.Query.decode(uri.query) 302 | 303 | cond do 304 | query_params == params -> 305 | assert true 306 | 307 | is_nil(query_params) && params == %{} -> 308 | assert true 309 | 310 | true -> 311 | params_string = Plug.Conn.Query.encode(params) 312 | 313 | msg = """ 314 | Expected query params to be #{inspect(params_string)} but got #{inspect(uri.query)} 315 | """ 316 | 317 | raise AssertionError, msg 318 | end 319 | 320 | session 321 | end 322 | 323 | def refute_path(session, path) do 324 | uri = URI.parse(PhoenixTest.Driver.current_path(session)) 325 | 326 | if uri.path == path do 327 | msg = """ 328 | Expected path not to be #{inspect(path)} 329 | """ 330 | 331 | raise AssertionError, msg 332 | else 333 | refute false 334 | end 335 | 336 | session 337 | end 338 | 339 | def refute_path(session, path, opts) do 340 | params = Keyword.get(opts, :query_params) 341 | 342 | refute_query_params(session, params) || refute_path(session, path) 343 | end 344 | 345 | defp refute_query_params(session, params) do 346 | params = Utils.stringify_keys_and_values(params) 347 | 348 | uri = URI.parse(PhoenixTest.Driver.current_path(session)) 349 | query_params = uri.query && URI.decode_query(uri.query) 350 | 351 | if query_params == params do 352 | params_string = URI.encode_query(params) 353 | 354 | msg = """ 355 | Expected query params not to be #{inspect(params_string)} 356 | """ 357 | 358 | raise AssertionError, msg 359 | else 360 | refute false 361 | end 362 | 363 | session 364 | end 365 | 366 | defp assert_incorrect_count_error_msg(selector, opts, found) do 367 | "Expected #{count_elements(opts.count)} with #{inspect(selector)}" 368 | |> maybe_append_text(opts.text) 369 | |> maybe_append_value(opts.value) 370 | |> maybe_append_label(opts.label) 371 | |> append_found(found) 372 | end 373 | 374 | defp assert_not_found_error_msg(selector, opts, other_matches \\ []) do 375 | "Could not find #{count_elements(opts.count)} with selector #{inspect(selector)}" 376 | |> maybe_append_text(opts.text) 377 | |> maybe_append_value(opts.value) 378 | |> maybe_append_label(opts.label) 379 | |> maybe_append_position(opts.at) 380 | |> append_found_other_matches(selector, other_matches) 381 | end 382 | 383 | def refute_found_error_msg(selector, opts, found) do 384 | "Expected not to find #{count_elements(opts.count)} with selector #{inspect(selector)}" 385 | |> maybe_append_text(opts.text) 386 | |> maybe_append_value(opts.value) 387 | |> maybe_append_label(opts.label) 388 | |> maybe_append_position(opts.at) 389 | |> append_found(found) 390 | end 391 | 392 | defp count_elements(1), do: "1 element" 393 | defp count_elements(count), do: "#{count} elements" 394 | 395 | defp append_found(msg, found) do 396 | msg <> "\n\n" <> "But found #{Enum.count(found)}:" <> "\n\n" <> format_found_elements(found) 397 | end 398 | 399 | defp append_found_other_matches(msg, selector, matches) do 400 | if Enum.empty?(matches) do 401 | msg 402 | else 403 | msg <> 404 | "\n\n" <> 405 | "Found these elements matching the selector #{inspect(selector)}:" <> 406 | "\n\n" <> format_found_elements(matches) 407 | end 408 | end 409 | 410 | defp maybe_append_text(msg, :no_text), do: msg 411 | defp maybe_append_text(msg, text), do: msg <> " and text #{inspect(text)}" 412 | 413 | defp maybe_append_value(msg, :no_value), do: msg 414 | defp maybe_append_value(msg, value), do: msg <> " and value #{inspect(value)}" 415 | 416 | defp maybe_append_label(msg, :no_label), do: msg 417 | defp maybe_append_label(msg, label), do: msg <> " with label #{inspect(label)}" 418 | 419 | defp maybe_append_position(msg, :any), do: msg 420 | defp maybe_append_position(msg, position), do: msg <> " at position #{position}" 421 | 422 | defp finder_fun(selector, %Opts{} = opts) do 423 | case {opts.text, opts.value} do 424 | {:no_text, :no_value} -> 425 | &Query.find(&1, selector, Opts.to_list(opts)) 426 | 427 | {:no_text, value} -> 428 | value_finder_fun(value, selector, opts) 429 | 430 | {text, :no_value} -> 431 | &Query.find(&1, selector, text, Opts.to_list(opts)) 432 | 433 | {_text, _value} -> 434 | raise ArgumentError, "Cannot provide both :text and :value to assertions" 435 | end 436 | end 437 | 438 | defp value_finder_fun(value, selector, %Opts{} = opts) do 439 | selector = selector <> "[value=#{inspect(value)}]" 440 | 441 | case opts.label do 442 | :no_label -> 443 | &Query.find(&1, selector, Opts.to_list(opts)) 444 | 445 | label when is_binary(label) -> 446 | &Query.find_by_label(&1, selector, label, Opts.to_list(opts)) 447 | end 448 | end 449 | 450 | defp format_found_elements(elements) when is_list(elements) do 451 | Enum.map_join(elements, "\n", &Html.raw/1) 452 | end 453 | 454 | defp format_found_elements(element), do: format_found_elements([element]) 455 | end 456 | -------------------------------------------------------------------------------- /lib/phoenix_test/conn_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.ConnHandler do 2 | @moduledoc false 3 | import Phoenix.ConnTest 4 | 5 | @endpoint Application.compile_env(:phoenix_test, :endpoint) 6 | 7 | def visit(conn, path) do 8 | conn 9 | |> get(path) 10 | |> visit() 11 | end 12 | 13 | def visit(conn) do 14 | verify_when_local_path!(conn) 15 | 16 | case conn do 17 | %{assigns: %{live_module: _}} = conn -> 18 | PhoenixTest.Live.build(conn) 19 | 20 | %{status: 302} = conn -> 21 | path = redirected_to(conn) 22 | 23 | conn 24 | |> recycle_all_headers() 25 | |> visit(path) 26 | 27 | conn -> 28 | PhoenixTest.Static.build(conn) 29 | end 30 | end 31 | 32 | def build_current_path(conn), do: append_query_string(conn.request_path, conn.query_string) 33 | 34 | defp append_query_string(path, ""), do: path 35 | defp append_query_string(path, query), do: path <> "?" <> query 36 | 37 | def recycle_all_headers(conn) do 38 | recycle(conn, all_headers(conn)) 39 | end 40 | 41 | defp all_headers(conn) do 42 | Enum.map(conn.req_headers, &elem(&1, 0)) 43 | end 44 | 45 | defp verify_when_local_path!(conn) do 46 | if local_path?(conn) && !route_exists?(conn) do 47 | raise ArgumentError, message: "#{inspect(conn.request_path)} path doesn't exist" 48 | end 49 | end 50 | 51 | @plug_adapters_test_conn_default_host "www.example.com" 52 | defp local_path?(conn) do 53 | conn.host == @plug_adapters_test_conn_default_host or conn.host == endpoint_host() 54 | end 55 | 56 | defp endpoint_host do 57 | endpoint_at_runtime_to_avoid_warning = Application.get_env(:phoenix_test, :endpoint) 58 | endpoint_at_runtime_to_avoid_warning.host() 59 | end 60 | 61 | defp route_exists?(conn) do 62 | router = fetch_phoenix_router!(conn) 63 | method = conn.method 64 | host = conn.host 65 | path = conn.request_path 66 | 67 | case Phoenix.Router.route_info(router, method, path, host) do 68 | %{} -> true 69 | :error -> false 70 | end 71 | end 72 | 73 | defp fetch_phoenix_router!(conn) do 74 | case conn.private[:phoenix_router] do 75 | nil -> 76 | raise ArgumentError, message: "You must visit a page before calling `visit/1` or call `visit/2` with a path" 77 | 78 | router when is_atom(router) -> 79 | router 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/phoenix_test/credo/no_open_browser.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Credo) do 2 | defmodule PhoenixTest.Credo.NoOpenBrowser do 3 | @moduledoc false 4 | 5 | use Credo.Check, 6 | base_priority: :normal, 7 | category: :warning, 8 | explanations: [ 9 | check: """ 10 | The `open_browser/1` function is useful during development but should not be 11 | committed in tests as it would open browsers during CI runs, which can cause 12 | unexpected behavior and CI failures. 13 | 14 | A Credo check that disallows the use of `open_browser/1` in test code. 15 | 16 | ## Usage 17 | 18 | Add this check to your `.credo.exs` file: 19 | 20 | ```elixir 21 | %{ 22 | configs: [ 23 | %{ 24 | name: "default", 25 | requires: ["./deps/phoenix_test/lib/phoenix_test/credo/**/*.ex"], 26 | checks: [ 27 | {PhoenixTest.Credo.NoOpenBrowser, []} 28 | ] 29 | } 30 | ] 31 | } 32 | ``` 33 | """ 34 | ] 35 | 36 | def run(source_file, params \\ []) do 37 | issue_meta = IssueMeta.for(source_file, params) 38 | Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta)) 39 | end 40 | 41 | defp traverse({:open_browser, meta, _} = ast, issues, issue_meta) do 42 | {ast, issues ++ [issue_for(meta[:line], issue_meta)]} 43 | end 44 | 45 | defp traverse({:., meta, [{:__aliases__, _, [:PhoenixTest]}, :open_browser]} = ast, issues, issue_meta) do 46 | {ast, issues ++ [issue_for(meta[:line], issue_meta)]} 47 | end 48 | 49 | defp traverse(ast, issues, _issue_meta) do 50 | {ast, issues} 51 | end 52 | 53 | defp issue_for(line_no, issue_meta) do 54 | format_issue( 55 | issue_meta, 56 | message: "There should be no `open_browser` calls.", 57 | line_no: line_no 58 | ) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/phoenix_test/data_attribute_form.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.DataAttributeForm do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Html 5 | 6 | def build(%LazyHTML{} = element) do 7 | method = Html.attribute(element, "data-method") 8 | action = Html.attribute(element, "data-to") 9 | csrf_token = Html.attribute(element, "data-csrf") 10 | 11 | %{} 12 | |> Map.put(:method, method) 13 | |> Map.put(:action, action) 14 | |> Map.put(:csrf_token, csrf_token) 15 | |> Map.put(:element, element) 16 | |> Map.put(:data, %{"_csrf_token" => csrf_token, "_method" => method}) 17 | end 18 | 19 | def validate!(form, selector, text) do 20 | method = form.method 21 | action = form.action 22 | csrf_token = form.csrf_token 23 | 24 | missing = 25 | Enum.filter(["data-method": method, "data-to": action, "data-csrf": csrf_token], fn {_, value} -> empty?(value) end) 26 | 27 | unless method && action && csrf_token do 28 | raise ArgumentError, """ 29 | Tried submitting form via `data-method` but some data attributes are 30 | missing. 31 | 32 | I expected #{inspect(selector)} with text #{inspect(text)} to include 33 | data-method, data-to, and data-csrf. 34 | 35 | I found: 36 | 37 | #{Html.raw(form.element)} 38 | 39 | It seems these are missing: #{Enum.map_join(missing, ", ", fn {key, _} -> key end)}. 40 | 41 | NOTE: `data-method` form submissions happen through JavaScript. Tests 42 | emulate that, but be sure to verify you're including Phoenix.HTML.js! 43 | 44 | See: https://hexdocs.pm/phoenix_html/Phoenix.HTML.html#module-javascript-library 45 | """ 46 | end 47 | 48 | form 49 | end 50 | 51 | defp empty?(value) do 52 | value == "" || value == nil 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/phoenix_test/driver.ex: -------------------------------------------------------------------------------- 1 | defprotocol PhoenixTest.Driver do 2 | @moduledoc false 3 | def visit(initial_struct, path) 4 | def render_page_title(session) 5 | def render_html(session) 6 | def click_link(session, text) 7 | def click_link(session, selector, text) 8 | def click_button(session, text) 9 | def click_button(session, selector, text) 10 | def within(session, selector, fun) 11 | def fill_in(session, label, opts) 12 | def fill_in(session, input_selector, label, opts) 13 | def select(session, option, opts) 14 | def select(session, input_selector, option, opts) 15 | def check(session, label, opts) 16 | def check(session, input_selector, label, opts) 17 | def uncheck(session, label, opts) 18 | def uncheck(session, input_selector, label, opts) 19 | def choose(session, label, opts) 20 | def choose(session, input_selector, label, opts) 21 | def upload(session, label, path, opts) 22 | def upload(session, input_selector, label, path, opts) 23 | def submit(session) 24 | def unwrap(session, fun) 25 | def open_browser(session) 26 | def open_browser(session, open_fun) 27 | def current_path(session) 28 | 29 | def assert_has(session, selector) 30 | def assert_has(session, selector, opts) 31 | def refute_has(session, selector) 32 | def refute_has(session, selector, opts) 33 | def assert_path(session, path) 34 | def assert_path(session, path, opts) 35 | def refute_path(session, path) 36 | def refute_path(session, path, opts) 37 | end 38 | -------------------------------------------------------------------------------- /lib/phoenix_test/element.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Html 5 | 6 | def build_selector(%LazyHTML{} = html) do 7 | {tag, attributes, _} = Html.element(html) 8 | 9 | Enum.reduce_while(attributes, tag, fn 10 | {"id", id}, _ when is_binary(id) -> 11 | {:halt, "[id=#{inspect(id)}]"} 12 | 13 | {"phx-" <> _rest = phx_attr, value}, acc -> 14 | if encoded_live_view_js?(value) do 15 | {:cont, acc} 16 | else 17 | {:cont, acc <> "[#{phx_attr}=#{inspect(value)}]"} 18 | end 19 | 20 | {"class", _}, acc -> 21 | {:cont, acc} 22 | 23 | {k, v}, acc -> 24 | {:cont, acc <> "[#{k}=#{inspect(v)}]"} 25 | end) 26 | end 27 | 28 | defp encoded_live_view_js?(value) do 29 | value =~ "[[" 30 | end 31 | 32 | def selector_has_id?(selector, id) when is_binary(selector) and is_binary(id) do 33 | Enum.any?(["[id='#{id}'", ~s|[id="#{id}"|, "##{id}"], &String.contains?(selector, &1)) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/phoenix_test/element/button.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.Button do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Element 5 | alias PhoenixTest.Element.Form 6 | alias PhoenixTest.Html 7 | alias PhoenixTest.LiveViewBindings 8 | alias PhoenixTest.Query 9 | alias PhoenixTest.Utils 10 | 11 | defstruct ~w[source_raw raw parsed id selector text name value form_id]a 12 | 13 | def find!(html, selector, text) do 14 | html 15 | |> Query.find!(selector, text) 16 | |> build(html) 17 | |> keep_best_selector(selector) 18 | end 19 | 20 | defp keep_best_selector(button, provided_selector) do 21 | case provided_selector do 22 | "button" -> 23 | button 24 | 25 | anything_better_than_button -> 26 | %{button | selector: anything_better_than_button} 27 | end 28 | end 29 | 30 | def find_first(html) do 31 | html 32 | |> Query.find("button") 33 | |> case do 34 | {:found, element} -> build(element, html) 35 | {:found_many, elements} -> elements |> Enum.at(0) |> build(html) 36 | :not_found -> nil 37 | end 38 | end 39 | 40 | def build(parsed, source_raw) do 41 | button_html = Html.raw(parsed) 42 | id = Html.attribute(parsed, "id") 43 | name = Html.attribute(parsed, "name") 44 | value = Html.attribute(parsed, "value") || if name, do: "" 45 | selector = Element.build_selector(parsed) 46 | text = Html.inner_text(parsed) 47 | form_id = Html.attribute(parsed, "form") 48 | 49 | %__MODULE__{ 50 | source_raw: source_raw, 51 | raw: button_html, 52 | parsed: parsed, 53 | id: id, 54 | selector: selector, 55 | text: text, 56 | name: name, 57 | value: value, 58 | form_id: form_id 59 | } 60 | end 61 | 62 | def belongs_to_form?(button) do 63 | !!button.form_id || belongs_to_ancestor_form?(button) 64 | end 65 | 66 | defp belongs_to_ancestor_form?(button) do 67 | case Query.find_ancestor(button.source_raw, "form", {button.selector, button.text}) do 68 | {:found, _} -> true 69 | _ -> false 70 | end 71 | end 72 | 73 | def phx_click?(button), do: LiveViewBindings.phx_click?(button.parsed) 74 | 75 | def has_data_method?(button) do 76 | button.parsed 77 | |> Html.attribute("data-method") 78 | |> Utils.present?() 79 | end 80 | 81 | def parent_form!(button) do 82 | if button.form_id do 83 | Form.find!(button.source_raw, "[id=#{inspect(button.form_id)}]") 84 | else 85 | Form.find_by_descendant!(button.source_raw, button) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/phoenix_test/element/field.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.Field do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Element 5 | alias PhoenixTest.Element.Form 6 | alias PhoenixTest.Html 7 | alias PhoenixTest.LiveViewBindings 8 | alias PhoenixTest.Query 9 | 10 | @enforce_keys ~w[source_raw parsed label id name value selector]a 11 | defstruct ~w[source_raw parsed label id name value selector]a 12 | 13 | def find_input!(html, input_selectors, label, opts) do 14 | field = Query.find_by_label!(html, input_selectors, label, opts) 15 | id = Html.attribute(field, "id") 16 | name = Html.attribute(field, "name") 17 | value = Html.attribute(field, "value") 18 | 19 | %__MODULE__{ 20 | source_raw: html, 21 | parsed: field, 22 | label: label, 23 | id: id, 24 | name: name, 25 | value: value, 26 | selector: Element.build_selector(field) 27 | } 28 | end 29 | 30 | def find_checkbox!(html, input_selector, label, opts) do 31 | field = Query.find_by_label!(html, input_selector, label, opts) 32 | 33 | id = Html.attribute(field, "id") 34 | name = Html.attribute(field, "name") 35 | value = Html.attribute(field, "value") || "on" 36 | 37 | %__MODULE__{ 38 | source_raw: html, 39 | parsed: field, 40 | label: label, 41 | id: id, 42 | name: name, 43 | value: value, 44 | selector: Element.build_selector(field) 45 | } 46 | end 47 | 48 | def find_hidden_uncheckbox!(html, input_selector, label, opts) do 49 | field = Query.find_by_label!(html, input_selector, label, opts) 50 | id = Html.attribute(field, "id") 51 | name = Html.attribute(field, "name") 52 | 53 | hidden_input = Query.find!(html, "input[type='hidden'][name='#{name}']") 54 | value = Html.attribute(hidden_input, "value") 55 | 56 | %__MODULE__{ 57 | source_raw: html, 58 | parsed: field, 59 | label: label, 60 | id: id, 61 | name: name, 62 | value: value, 63 | selector: Element.build_selector(field) 64 | } 65 | end 66 | 67 | def parent_form!(field) do 68 | Form.find_by_descendant!(field.source_raw, field) 69 | end 70 | 71 | def phx_click?(field), do: LiveViewBindings.phx_click?(field.parsed) 72 | 73 | def phx_value?(field), do: LiveViewBindings.phx_value?(field.parsed) 74 | 75 | def belongs_to_form?(field) do 76 | case Query.find_ancestor(field.source_raw, "form", field.selector) do 77 | {:found, _} -> true 78 | _ -> false 79 | end 80 | end 81 | 82 | def validate_name!(field) do 83 | if field.name == nil do 84 | raise ArgumentError, """ 85 | Field is missing a `name` attribute: 86 | 87 | #{Html.raw(field.parsed)} 88 | """ 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /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 raw 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 | raw = Html.raw(form) 37 | id = Html.attribute(form, "id") 38 | action = Html.attribute(form, "action") 39 | selector = Element.build_selector(form) 40 | 41 | %__MODULE__{ 42 | action: action, 43 | form_data: form_data(form), 44 | id: id, 45 | method: operative_method(form), 46 | parsed: form, 47 | raw: raw, 48 | selector: selector, 49 | submit_button: Button.find_first(form) 50 | } 51 | end 52 | 53 | def form_element_names(%__MODULE__{} = form) do 54 | form.parsed 55 | |> Html.all("[name]") 56 | |> Enum.map(&Html.attribute(&1, "name")) 57 | |> Enum.uniq() 58 | end 59 | 60 | def phx_change?(form) do 61 | form.parsed 62 | |> Html.attribute("phx-change") 63 | |> Utils.present?() 64 | end 65 | 66 | def phx_submit?(form) do 67 | form.parsed 68 | |> Html.attribute("phx-submit") 69 | |> Utils.present?() 70 | end 71 | 72 | def has_action?(form), do: Utils.present?(form.action) 73 | 74 | defp descendant_selector(%{id: id}) when is_binary(id), do: "[id=#{inspect(id)}]" 75 | defp descendant_selector(%{selector: selector, text: text}), do: {selector, text} 76 | defp descendant_selector(%{selector: selector}), do: selector 77 | 78 | @simple_value_types ~w( 79 | date 80 | datetime-local 81 | email 82 | month 83 | number 84 | password 85 | range 86 | search 87 | tel 88 | text 89 | time 90 | url 91 | week 92 | ) 93 | 94 | @hidden_inputs "input[type='hidden']" 95 | @checked_radio_buttons "input:not([disabled])[type='radio'][value]:checked" 96 | @checked_checkboxes "input:not([disabled])[type='checkbox'][value]:checked" 97 | @pre_filled_default_text_inputs "input:not([disabled]):not([type])[value]" 98 | 99 | @pre_filled_simple_value_inputs Enum.map_join( 100 | @simple_value_types, 101 | ",", 102 | &"input:not([disabled])[type='#{&1}'][value]" 103 | ) 104 | 105 | defp form_data(form) do 106 | FormData.new() 107 | |> FormData.add_data(form_data(@hidden_inputs, form)) 108 | |> FormData.add_data(form_data(@checked_radio_buttons, form)) 109 | |> FormData.add_data(form_data(@checked_checkboxes, form)) 110 | |> FormData.add_data(form_data(@pre_filled_simple_value_inputs, form)) 111 | |> FormData.add_data(form_data(@pre_filled_default_text_inputs, form)) 112 | |> FormData.add_data(form_data_textarea(form)) 113 | |> FormData.add_data(form_data_select(form)) 114 | end 115 | 116 | defp form_data(selector, form) do 117 | form 118 | |> Html.all(selector) 119 | |> Enum.map(&to_form_field/1) 120 | end 121 | 122 | defp form_data_textarea(form) do 123 | form 124 | |> Html.all("textarea:not([disabled])") 125 | |> Enum.map(&to_form_field/1) 126 | end 127 | 128 | defp form_data_select(form) do 129 | form 130 | |> Html.all("select:not([disabled])") 131 | |> Enum.flat_map(fn select -> 132 | selected_options = Html.all(select, "option[selected]") 133 | multiple? = Html.attribute(select, "multiple") != nil 134 | 135 | case {multiple?, Enum.count(selected_options)} do 136 | {true, 0} -> 137 | [] 138 | 139 | {false, 0} -> 140 | if option = select |> Html.all("option") |> Enum.at(0) do 141 | [to_form_field(select, option)] 142 | else 143 | [] 144 | end 145 | 146 | {false, _} -> 147 | [to_form_field(select, selected_options)] 148 | 149 | _ -> 150 | Enum.map(selected_options, &to_form_field(select, &1)) 151 | end 152 | end) 153 | end 154 | 155 | def put_button_data(form, nil), do: form 156 | 157 | def put_button_data(form, %Button{} = button) do 158 | Map.update!(form, :form_data, &FormData.add_data(&1, button)) 159 | end 160 | 161 | defp to_form_field(element) do 162 | to_form_field(element, element) 163 | end 164 | 165 | defp to_form_field(name_element, value_element) do 166 | name = Html.attribute(name_element, "name") 167 | {name, element_value(value_element)} 168 | end 169 | 170 | defp element_value(element) do 171 | Html.attribute(element, "value") || Html.inner_text(element) 172 | end 173 | 174 | defp operative_method(%LazyHTML{} = form) do 175 | hidden_input_method_value(form) || Html.attribute(form, "method") || "get" 176 | end 177 | 178 | defp hidden_input_method_value(form) do 179 | form 180 | |> Html.all("input[type='hidden'][name='_method']") 181 | |> Enum.find_value(&Html.attribute(&1, "value")) 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/phoenix_test/element/link.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.Link do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Html 5 | alias PhoenixTest.Query 6 | alias PhoenixTest.Utils 7 | 8 | defstruct ~w[raw parsed id selector text href]a 9 | 10 | def find!(html, selector, text) do 11 | link = Query.find!(html, selector, text) 12 | link_html = Html.raw(link) 13 | id = Html.attribute(link, "id") 14 | href = Html.attribute(link, "href") 15 | 16 | %__MODULE__{ 17 | raw: link_html, 18 | parsed: link, 19 | id: id, 20 | selector: selector, 21 | text: text, 22 | href: href 23 | } 24 | end 25 | 26 | def has_data_method?(link) do 27 | link.parsed 28 | |> Html.attribute("data-method") 29 | |> Utils.present?() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/phoenix_test/element/select.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.Select do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Element 5 | alias PhoenixTest.Html 6 | alias PhoenixTest.LiveViewBindings 7 | alias PhoenixTest.Query 8 | 9 | @enforce_keys ~w[source_raw selected_options parsed label id name value selector]a 10 | defstruct ~w[source_raw selected_options parsed label id name value selector]a 11 | 12 | def find_select_option!(html, input_selector, label, option, opts) do 13 | field = Query.find_by_label!(html, input_selector, label, opts) 14 | id = Html.attribute(field, "id") 15 | name = Html.attribute(field, "name") 16 | 17 | multiple = not is_nil(Html.attribute(field, "multiple")) 18 | 19 | exact_option = Keyword.get(opts, :exact_option, true) 20 | 21 | selected_options = 22 | case {multiple, option} do 23 | {true, [_ | _]} -> 24 | Enum.map(option, fn opt -> 25 | Query.find!(field, "option", opt, exact: exact_option) 26 | end) 27 | 28 | {true, _} -> 29 | [Query.find!(field, "option", option, exact: exact_option)] 30 | 31 | {false, [_ | _]} -> 32 | msg = """ 33 | Could not find a select with a "multiple" attribute set. 34 | 35 | Found the following select: 36 | 37 | #{Html.raw(field)} 38 | """ 39 | 40 | raise ArgumentError, msg 41 | 42 | {false, _} -> 43 | [Query.find!(field, "option", option, exact: exact_option)] 44 | end 45 | 46 | values = Enum.map(selected_options, fn option -> Html.attribute(option, "value") end) 47 | 48 | %__MODULE__{ 49 | source_raw: html, 50 | parsed: field, 51 | label: label, 52 | id: id, 53 | name: name, 54 | value: values, 55 | selected_options: selected_options, 56 | selector: Element.build_selector(field) 57 | } 58 | end 59 | 60 | def phx_click_options?(field) do 61 | Enum.all?(field.selected_options, &LiveViewBindings.phx_click?/1) 62 | end 63 | 64 | def select_option_selector(field, value) do 65 | field.selector <> " option[value=#{inspect(value)}]" 66 | end 67 | 68 | def belongs_to_form?(field) do 69 | case Query.find_ancestor(field.source_raw, "form", field.selector) do 70 | {:found, _} -> true 71 | _ -> false 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/phoenix_test/file_upload.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.FileUpload do 2 | @moduledoc false 3 | def mime_type(path) do 4 | if Code.ensure_loaded?(MIME) do 5 | "." <> ext = Path.extname(path) 6 | MIME.type(ext) 7 | else 8 | "application/octet-stream" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/phoenix_test/form_payload.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.FormPayload do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.FormData 5 | 6 | def new(%FormData{} = form_data) do 7 | form_data 8 | |> FormData.to_list() 9 | |> Enum.map_join("&", fn {key, value} -> 10 | "#{URI.encode_www_form(key)}=#{if(value, do: URI.encode_www_form(value))}" 11 | end) 12 | |> Plug.Conn.Query.decode() 13 | end 14 | 15 | def add_form_data(payload, %FormData{} = form_data) when is_map(payload) do 16 | add_form_data(payload, FormData.to_list(form_data)) 17 | end 18 | 19 | def add_form_data(payload, form_data) when is_map(payload) and is_list(form_data) do 20 | Enum.reduce(form_data, payload, fn {name, value}, acc -> 21 | with_placeholder = Plug.Conn.Query.decode("#{URI.encode_www_form(name)}=placeholder") 22 | put_at_placeholder(acc, with_placeholder, value) 23 | end) 24 | end 25 | 26 | defp put_at_placeholder(_, "placeholder", value), do: value 27 | defp put_at_placeholder(list, ["placeholder"], value), do: (list || []) ++ [value] 28 | 29 | defp put_at_placeholder(map, with_placeholder, value) do 30 | map = map || %{} 31 | [{key, placeholder_value}] = Map.to_list(with_placeholder) 32 | Map.put(map, key, put_at_placeholder(map[key], placeholder_value, value)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/phoenix_test/html.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Html do 2 | @moduledoc false 3 | 4 | def parse_document(%LazyHTML{} = html), do: html 5 | 6 | def parse_document(html) when is_binary(html) do 7 | LazyHTML.from_document(html) 8 | end 9 | 10 | def parse_fragment(%LazyHTML{} = html), do: html 11 | 12 | def parse_fragment(html) when is_binary(html) do 13 | LazyHTML.from_fragment(html) 14 | end 15 | 16 | @doc """ 17 | Returns the rendered text content of an element and its descendants. 18 | Similar to Javascript [innerText](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText) property, 19 | but with the following differences: 20 | - exclude select `option` labels 21 | """ 22 | def inner_text(%LazyHTML{} = element) do 23 | element 24 | |> LazyHTML.to_tree(skip_whitespace_nodes: false) 25 | |> text_without_select_options() 26 | |> String.trim() 27 | |> normalize_whitespace() 28 | end 29 | 30 | defp text_without_select_options(tree, acc \\ "") 31 | 32 | defp text_without_select_options([], acc), do: acc 33 | 34 | defp text_without_select_options([node | rest], acc) do 35 | acc = 36 | case node do 37 | {"select", _, _} -> acc 38 | {:comment, _} -> acc 39 | {_, _, children} -> acc <> text_without_select_options(children) 40 | text when is_binary(text) -> acc <> text 41 | end 42 | 43 | text_without_select_options(rest, acc) 44 | end 45 | 46 | def attribute(%LazyHTML{} = element, attr) when is_binary(attr) do 47 | element 48 | |> LazyHTML.attribute(attr) 49 | |> List.first() 50 | end 51 | 52 | def attributes(%LazyHTML{} = element) do 53 | element 54 | |> LazyHTML.attributes() 55 | |> List.first() 56 | end 57 | 58 | def all(%LazyHTML{} = html, selector) when is_binary(selector) do 59 | LazyHTML.query(html, selector) 60 | end 61 | 62 | def raw(%LazyHTML{} = html), do: LazyHTML.to_html(html) 63 | 64 | def postwalk(%LazyHTML{} = html, postwalk_fun) when is_function(postwalk_fun, 1) do 65 | html 66 | |> LazyHTML.to_tree() 67 | |> LazyHTML.Tree.postwalk(postwalk_fun) 68 | |> LazyHTML.from_tree() 69 | end 70 | 71 | def element(%LazyHTML{} = html) do 72 | case Enum.at(html, 0) do 73 | nil -> nil 74 | %LazyHTML{} = element -> element |> LazyHTML.to_tree() |> hd() 75 | end 76 | end 77 | 78 | defp normalize_whitespace(string) do 79 | String.replace(string, ~r/[\s]+/, " ") 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/phoenix_test/live_view_bindings.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.LiveViewBindings do 2 | @moduledoc false 3 | 4 | alias PhoenixTest.Html 5 | alias PhoenixTest.Utils 6 | 7 | def phx_click?(parsed_element) do 8 | parsed_element 9 | |> Html.attribute("phx-click") 10 | |> valid_event_or_js_command?() 11 | end 12 | 13 | def phx_value?(parsed_element) do 14 | cond do 15 | any_phx_value_attributes?(parsed_element) -> true 16 | phx_click_command_has_value?(parsed_element) -> true 17 | true -> false 18 | end 19 | end 20 | 21 | defp valid_event_or_js_command?("[" <> _ = js_command) do 22 | js_command 23 | |> Jason.decode!() 24 | |> any_valid_js_command?() 25 | end 26 | 27 | defp valid_event_or_js_command?(value), do: Utils.present?(value) 28 | 29 | defp any_valid_js_command?(js_commands) do 30 | Enum.any?(js_commands, &valid_js_command?/1) 31 | end 32 | 33 | defp valid_js_command?(["navigate", _opts]), do: true 34 | defp valid_js_command?(["patch", _opts]), do: true 35 | defp valid_js_command?(["push", _opts]), do: true 36 | defp valid_js_command?([_command, _opts]), do: false 37 | 38 | defp any_phx_value_attributes?(%LazyHTML{} = element) do 39 | element 40 | |> Html.attributes() 41 | |> Enum.any?(fn {key, _value} -> String.starts_with?(key, "phx-value-") end) 42 | end 43 | 44 | defp phx_click_command_has_value?(%LazyHTML{} = element) do 45 | element 46 | |> Html.attribute("phx-click") 47 | |> phx_click_command_has_value?() 48 | end 49 | 50 | defp phx_click_command_has_value?("[" <> _ = js_command) do 51 | js_command 52 | |> Jason.decode!() 53 | |> Enum.find(&match?(["push", _opts], &1)) 54 | |> case do 55 | ["push", opts] -> Map.has_key?(opts, "value") 56 | _ -> false 57 | end 58 | end 59 | 60 | defp phx_click_command_has_value?(_), do: false 61 | end 62 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/phoenix_test/locators.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Locators do 2 | @moduledoc false 3 | 4 | defmodule Button do 5 | @moduledoc false 6 | defstruct ~w[text selectors]a 7 | end 8 | 9 | def button(opts) do 10 | text = Keyword.get(opts, :text) 11 | 12 | selectors = 13 | ~w|button [role="button"] input[type="button"] input[type="image"] input[type="reset"] input[type="submit"]| 14 | 15 | %Button{text: text, selectors: selectors} 16 | end 17 | 18 | def role_selectors(%Button{} = button) do 19 | %Button{text: text, selectors: selectors} = button 20 | 21 | Enum.map(selectors, fn 22 | "button" -> {"button", text} 23 | ~s|[role="button"]| -> {~s|[role="button"]|, text} 24 | role -> role <> "[value=#{inspect(text)}]" 25 | end) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/phoenix_test/open_browser.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.OpenBrowser do 2 | @moduledoc false 3 | 4 | # This module contains private functionality ported over from 5 | # `Phoenix.LiveViewTest` to make `open_browser` work with `Static` tests. 6 | 7 | @doc """ 8 | Fully qualifies static assets paths so the browser can find them. 9 | """ 10 | def prefix_static_paths(node, endpoint) do 11 | static_path = static_path(endpoint) 12 | 13 | case node do 14 | # Remove script tags 15 | {"script", _, _} -> [] 16 | # Skip prefixing src attributes on anchor tags 17 | {"a", _, _} = link -> link 18 | {el, attrs, children} -> {el, maybe_prefix_static_path(attrs, static_path), children} 19 | el -> el 20 | end 21 | end 22 | 23 | defp static_path(endpoint) do 24 | static_url = endpoint.config(:static_url) || [] 25 | priv_dir = :otp_app |> endpoint.config() |> Application.app_dir("priv") 26 | 27 | if Keyword.get(static_url, :path) do 28 | priv_dir 29 | else 30 | Path.join(priv_dir, "static") 31 | end 32 | end 33 | 34 | defp maybe_prefix_static_path(attrs, nil), do: attrs 35 | 36 | defp maybe_prefix_static_path(attrs, static_path) do 37 | Enum.map(attrs, fn 38 | {"src", path} -> {"src", prefix_static_path(path, static_path)} 39 | {"href", path} -> {"href", prefix_static_path(path, static_path)} 40 | attr -> attr 41 | end) 42 | end 43 | 44 | defp prefix_static_path(<<"//" <> _::binary>> = url, _prefix), do: url 45 | 46 | defp prefix_static_path(<<"/" <> _::binary>> = path, prefix) do 47 | "file://#{Path.join([prefix, path])}" 48 | end 49 | 50 | defp prefix_static_path(url, _), do: url 51 | 52 | @doc """ 53 | System agnostic function to open the default browser with the given `path`. 54 | 55 | This is ripped verbatim from `Phoenix.LiveViewTest`. 56 | """ 57 | def open_with_system_cmd(path) do 58 | {cmd, args} = 59 | case :os.type() do 60 | {:win32, _} -> 61 | {"cmd", ["/c", "start", path]} 62 | 63 | {:unix, :darwin} -> 64 | {"open", [path]} 65 | 66 | {:unix, _} -> 67 | if wsl?(path) do 68 | {"cmd.exe", ["/c", "start", path]} 69 | else 70 | {"xdg-open", [path]} 71 | end 72 | end 73 | 74 | System.cmd(cmd, args) 75 | end 76 | 77 | defp wsl?(path) do 78 | path =~ "\\" and System.find_executable("cmd.exe") != nil 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/phoenix_test/session_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.SessionHelpers do 2 | @moduledoc false 3 | def within(session, selector, fun) when is_binary(selector) and is_function(fun, 1) do 4 | session 5 | |> Map.update!(:within, fn 6 | :none -> selector 7 | parent when is_binary(parent) -> parent <> " " <> selector 8 | end) 9 | |> fun.() 10 | |> Map.put(:within, :none) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/phoenix_test/static.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Static do 2 | @moduledoc false 3 | 4 | import Phoenix.ConnTest 5 | 6 | alias PhoenixTest.ActiveForm 7 | alias PhoenixTest.ConnHandler 8 | alias PhoenixTest.DataAttributeForm 9 | alias PhoenixTest.Element.Button 10 | alias PhoenixTest.Element.Field 11 | alias PhoenixTest.Element.Form 12 | alias PhoenixTest.Element.Link 13 | alias PhoenixTest.Element.Select 14 | alias PhoenixTest.FileUpload 15 | alias PhoenixTest.FormData 16 | alias PhoenixTest.FormPayload 17 | alias PhoenixTest.Html 18 | alias PhoenixTest.Locators 19 | alias PhoenixTest.OpenBrowser 20 | alias PhoenixTest.Query 21 | 22 | @endpoint Application.compile_env(:phoenix_test, :endpoint) 23 | 24 | defstruct conn: nil, active_form: ActiveForm.new(), within: :none, current_path: "" 25 | 26 | def build(conn) do 27 | %__MODULE__{conn: conn, current_path: ConnHandler.build_current_path(conn)} 28 | end 29 | 30 | def current_path(session), do: session.current_path 31 | 32 | def render_page_title(session) do 33 | session 34 | |> render_html() 35 | |> Query.find("title") 36 | |> case do 37 | {:found, element} -> Html.inner_text(element) 38 | _ -> nil 39 | end 40 | end 41 | 42 | def render_html(%{conn: conn, within: within}) do 43 | html = 44 | conn 45 | |> html_response(conn.status) 46 | |> Html.parse_document() 47 | 48 | case within do 49 | :none -> html 50 | selector when is_binary(selector) -> Html.all(html, selector) 51 | end 52 | end 53 | 54 | def click_link(session, selector \\ "a", text) do 55 | link = 56 | session 57 | |> render_html() 58 | |> Link.find!(selector, text) 59 | 60 | if Link.has_data_method?(link) do 61 | form = 62 | link.parsed 63 | |> DataAttributeForm.build() 64 | |> DataAttributeForm.validate!(selector, text) 65 | 66 | perform_submit(session, form, form.data) 67 | else 68 | conn = session.conn 69 | 70 | conn 71 | |> ConnHandler.recycle_all_headers() 72 | |> PhoenixTest.visit(link.href) 73 | end 74 | end 75 | 76 | def click_button(session, text) do 77 | locator = Locators.button(text: text) 78 | html = render_html(session) 79 | 80 | button = 81 | html 82 | |> Query.find_by_role!(locator) 83 | |> Button.build(html) 84 | 85 | click_button(session, button.selector, button.text) 86 | end 87 | 88 | def click_button(session, selector, text) do 89 | active_form = session.active_form 90 | 91 | html = render_html(session) 92 | button = Button.find!(html, selector, text) 93 | 94 | if Button.has_data_method?(button) do 95 | form = 96 | button.parsed 97 | |> DataAttributeForm.build() 98 | |> DataAttributeForm.validate!(selector, text) 99 | 100 | perform_submit(session, form, form.data) 101 | else 102 | form = 103 | button 104 | |> Button.parent_form!() 105 | |> Form.put_button_data(button) 106 | 107 | if active_form.selector == form.selector do 108 | submit_active_form(session, form) 109 | else 110 | perform_submit(session, form, build_payload(form)) 111 | end 112 | end 113 | end 114 | 115 | def fill_in(session, label, opts) do 116 | selectors = ["input:not([type='hidden'])", "textarea"] 117 | fill_in(session, selectors, label, opts) 118 | end 119 | 120 | def fill_in(session, input_selector, label, opts) do 121 | {value, opts} = Keyword.pop!(opts, :with) 122 | 123 | session 124 | |> render_html() 125 | |> Field.find_input!(input_selector, label, opts) 126 | |> Map.put(:value, to_string(value)) 127 | |> then(&fill_in_field_data(session, &1)) 128 | end 129 | 130 | def select(session, option, opts) do 131 | select(session, "select", option, opts) 132 | end 133 | 134 | def select(session, input_selector, option, opts) do 135 | {label, opts} = Keyword.pop!(opts, :from) 136 | 137 | session 138 | |> render_html() 139 | |> Select.find_select_option!(input_selector, label, option, opts) 140 | |> then(&fill_in_field_data(session, &1)) 141 | end 142 | 143 | def check(session, label, opts) do 144 | check(session, "input[type='checkbox']", label, opts) 145 | end 146 | 147 | def check(session, input_selector, label, opts) do 148 | session 149 | |> render_html() 150 | |> Field.find_checkbox!(input_selector, label, opts) 151 | |> then(&fill_in_field_data(session, &1)) 152 | end 153 | 154 | def uncheck(session, label, opts) do 155 | uncheck(session, "input[type='checkbox']", label, opts) 156 | end 157 | 158 | def uncheck(session, input_selector, label, opts) do 159 | session 160 | |> render_html() 161 | |> Field.find_hidden_uncheckbox!(input_selector, label, opts) 162 | |> then(&fill_in_field_data(session, &1)) 163 | end 164 | 165 | def choose(session, label, opts) do 166 | choose(session, "input[type='radio']", label, opts) 167 | end 168 | 169 | def choose(session, input_selector, label, opts) do 170 | session 171 | |> render_html() 172 | |> Field.find_input!(input_selector, label, opts) 173 | |> then(&fill_in_field_data(session, &1)) 174 | end 175 | 176 | def upload(session, label, path, opts) do 177 | upload(session, "input[type='file']", label, path, opts) 178 | end 179 | 180 | def upload(session, input_selector, label, path, opts) do 181 | mime_type = FileUpload.mime_type(path) 182 | upload = %Plug.Upload{content_type: mime_type, filename: Path.basename(path), path: path} 183 | field = session |> render_html() |> Field.find_input!(input_selector, label, opts) 184 | form = Field.parent_form!(field) 185 | upload_data = {field.name, upload} 186 | 187 | Map.update!(session, :active_form, fn active_form -> 188 | if active_form.selector == form.selector do 189 | ActiveForm.add_upload(active_form, upload_data) 190 | else 191 | [id: form.id, selector: form.selector] 192 | |> ActiveForm.new() 193 | |> ActiveForm.add_upload(upload_data) 194 | end 195 | end) 196 | end 197 | 198 | def submit(session) do 199 | active_form = session.active_form 200 | 201 | unless ActiveForm.active?(active_form), do: raise(no_active_form_error()) 202 | 203 | selector = active_form.selector 204 | 205 | form = 206 | session 207 | |> render_html() 208 | |> Form.find!(selector) 209 | |> then(fn form -> 210 | Form.put_button_data(form, form.submit_button) 211 | end) 212 | 213 | submit_active_form(session, form) 214 | end 215 | 216 | def submit_form(session, selector, form_data) do 217 | form = 218 | session 219 | |> render_html() 220 | |> Form.find!(selector) 221 | |> then(fn form -> 222 | Form.put_button_data(form, form.submit_button) 223 | end) 224 | 225 | to_submit = FormPayload.new(FormData.merge(form.form_data, form_data)) 226 | 227 | session 228 | |> Map.put(:active_form, ActiveForm.new()) 229 | |> perform_submit(form, to_submit) 230 | end 231 | 232 | def open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do 233 | path = Path.join([System.tmp_dir!(), "phx-test#{System.unique_integer([:monotonic])}.html"]) 234 | 235 | html = 236 | session.conn.resp_body 237 | |> Html.parse_document() 238 | |> Html.postwalk(&OpenBrowser.prefix_static_paths(&1, @endpoint)) 239 | |> Html.raw() 240 | 241 | File.write!(path, html) 242 | 243 | open_fun.(path) 244 | 245 | session 246 | end 247 | 248 | def unwrap(%{conn: conn} = session, fun) when is_function(fun, 1) do 249 | conn 250 | |> fun.() 251 | |> maybe_redirect(session) 252 | end 253 | 254 | defp fill_in_field_data(session, field) do 255 | Field.validate_name!(field) 256 | form = Field.parent_form!(field) 257 | 258 | Map.update!(session, :active_form, fn active_form -> 259 | if active_form.selector == form.selector do 260 | ActiveForm.add_form_data(active_form, field) 261 | else 262 | [id: form.id, selector: form.selector] 263 | |> ActiveForm.new() 264 | |> ActiveForm.add_form_data(field) 265 | end 266 | end) 267 | end 268 | 269 | defp submit_active_form(session, form) do 270 | active_form = session.active_form 271 | 272 | session 273 | |> Map.put(:active_form, ActiveForm.new()) 274 | |> perform_submit(form, build_payload(form, active_form)) 275 | end 276 | 277 | defp perform_submit(session, form, payload) do 278 | conn = session.conn 279 | 280 | conn 281 | |> ConnHandler.recycle_all_headers() 282 | |> dispatch(@endpoint, form.method, form.action, payload) 283 | |> maybe_redirect(session) 284 | end 285 | 286 | defp no_active_form_error do 287 | %ArgumentError{ 288 | message: "There's no active form. Fill in a form with `fill_in`, `select`, etc." 289 | } 290 | end 291 | 292 | defp build_payload(form, active_form \\ ActiveForm.new()) do 293 | form.form_data 294 | |> FormData.merge(active_form.form_data) 295 | |> FormPayload.new() 296 | |> FormPayload.add_form_data(active_form.uploads) 297 | end 298 | 299 | defp maybe_redirect(conn, session) do 300 | case conn do 301 | %{status: 302} -> 302 | path = redirected_to(conn) 303 | 304 | conn 305 | |> ConnHandler.recycle_all_headers() 306 | |> PhoenixTest.visit(path) 307 | 308 | %{status: _} -> 309 | %{session | conn: conn, current_path: ConnHandler.build_current_path(conn)} 310 | end 311 | end 312 | end 313 | 314 | defimpl PhoenixTest.Driver, for: PhoenixTest.Static do 315 | alias PhoenixTest.Assertions 316 | alias PhoenixTest.ConnHandler 317 | alias PhoenixTest.SessionHelpers 318 | alias PhoenixTest.Static 319 | 320 | def visit(session, path) do 321 | ConnHandler.visit(session.conn, path) 322 | end 323 | 324 | defdelegate render_page_title(session), to: Static 325 | defdelegate render_html(session), to: Static 326 | defdelegate click_link(session, text), to: Static 327 | defdelegate click_link(session, selector, text), to: Static 328 | defdelegate click_button(session, text), to: Static 329 | defdelegate click_button(session, selector, text), to: Static 330 | defdelegate within(session, selector, fun), to: SessionHelpers 331 | defdelegate fill_in(session, label, opts), to: Static 332 | defdelegate fill_in(session, input_selector, label, opts), to: Static 333 | defdelegate select(session, option, opts), to: Static 334 | defdelegate select(session, input_selector, option, opts), to: Static 335 | defdelegate check(session, label, opts), to: Static 336 | defdelegate check(session, input_selector, label, opts), to: Static 337 | defdelegate uncheck(session, label, opts), to: Static 338 | defdelegate uncheck(session, input_selector, label, opts), to: Static 339 | defdelegate choose(session, label, opts), to: Static 340 | defdelegate choose(session, input_selector, label, opts), to: Static 341 | defdelegate upload(session, label, path, opts), to: Static 342 | defdelegate upload(session, input_selector, label, path, opts), to: Static 343 | defdelegate submit(session), to: Static 344 | defdelegate open_browser(session), to: Static 345 | defdelegate open_browser(session, open_fun), to: Static 346 | defdelegate unwrap(session, fun), to: Static 347 | defdelegate current_path(session), to: Static 348 | 349 | defdelegate assert_has(session, selector), to: Assertions 350 | defdelegate assert_has(session, selector, opts), to: Assertions 351 | defdelegate refute_has(session, selector), to: Assertions 352 | defdelegate refute_has(session, selector, opts), to: Assertions 353 | defdelegate assert_path(session, path), to: Assertions 354 | defdelegate assert_path(session, path, opts), to: Assertions 355 | defdelegate refute_path(session, path), to: Assertions 356 | defdelegate refute_path(session, path, opts), to: Assertions 357 | end 358 | -------------------------------------------------------------------------------- /lib/phoenix_test/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Utils do 2 | @moduledoc false 3 | 4 | def present?(term), do: !blank?(term) 5 | def blank?(term), do: term == nil || term == "" 6 | 7 | def stringify_keys_and_values(map) when is_map(map) do 8 | Map.new(map, fn 9 | {k, v} when is_map(v) -> 10 | {to_string(k), stringify_keys_and_values(v)} 11 | 12 | {k, v} when is_list(v) -> 13 | {to_string(k), Enum.map(v, &to_string/1)} 14 | 15 | {k, v} -> 16 | {to_string(k), to_string(v)} 17 | end) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.8.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 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, 5 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, 6 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 9 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 10 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 11 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 12 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 13 | "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, 14 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 15 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 16 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 17 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 18 | "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, 19 | "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, 20 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 21 | "lazy_html": {:hex, :lazy_html, "0.1.7", "53aa9ebdbde8aec7c8ee03a8bdaec38dd56302995b0baeebf8dbe7cbdd550400", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "e115944e6ddb887c45cadfd660348934c318abec0341f7b7156e912b98d3eb95"}, 22 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 23 | "makeup_eex": {:hex, :makeup_eex, "0.1.2", "93a5ef3d28ed753215dba2d59cb40408b37cccb4a8205e53ef9b5319a992b700", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "6140eafb28215ad7182282fd21d9aa6dcffbfbe0eb876283bc6b768a6c57b0c3"}, 24 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 25 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 26 | "makeup_html": {:hex, :makeup_html, "0.1.1", "c3d4abd39d5f7e925faca72ada6e9cc5c6f5fa7cd5bc0158315832656cf14d7f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "44f2a61bc5243645dd7fafeaa6cc28793cd22f3c76b861e066168f9a5b2c26a4"}, 27 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 28 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 29 | "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, 30 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, 31 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 32 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.8", "d283d5e047e6c013182a3833e99ff33942e3a8076f9f984c337ea04cc53e8206", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6184cf1e82fe6627d40cfa62236133099438513710d30358f4c085c16ecb84b4"}, 33 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 34 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 35 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 36 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, 37 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 38 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 39 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 40 | "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, 41 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 42 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 43 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 44 | } 45 | -------------------------------------------------------------------------------- /test/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 2 | import "phoenix_html" 3 | import {Socket} from "phoenix" 4 | import {LiveSocket} from "phoenix_live_view" 5 | 6 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 7 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: {SomeHook: {}, SomeOtherHook: {}}}) 8 | liveSocket.connect() 9 | -------------------------------------------------------------------------------- /test/files/elixir.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/germsvel/phoenix_test/97f664d93d01e3cde8b2ba0ade1792a9b47c26ef/test/files/elixir.jpg -------------------------------------------------------------------------------- /test/files/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/germsvel/phoenix_test/97f664d93d01e3cde8b2ba0ade1792a9b47c26ef/test/files/phoenix.png -------------------------------------------------------------------------------- /test/phoenix_test/active_form_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.ActiveFormTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.ActiveForm 5 | alias PhoenixTest.FormData 6 | 7 | describe "add_form_data" do 8 | test "adds form data passed" do 9 | active_form = 10 | [id: "user-form", selector: "#user-form"] 11 | |> ActiveForm.new() 12 | |> ActiveForm.add_form_data({"user[name]", "Frodo"}) 13 | 14 | assert FormData.has_data?(active_form.form_data, "user[name]", "Frodo") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /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/credo/no_open_browser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Credo.NoOpenBrowserTest do 2 | use Credo.Test.Case 3 | 4 | alias PhoenixTest.Credo.NoOpenBrowser 5 | 6 | test "does NOT report if no open_browser() call present" do 7 | """ 8 | defmodule SampleTest do 9 | use ExUnit.Case, async: true 10 | import PhoenixTest 11 | 12 | test "open browser" do 13 | Phoenix.ConnTest.build_conn() 14 | |> visit("/live/index") 15 | end 16 | end 17 | """ 18 | |> to_source_file() 19 | |> run_check(NoOpenBrowser) 20 | |> refute_issues() 21 | end 22 | 23 | test "reports imported PhoenixTest.open_browser/1 call" do 24 | """ 25 | defmodule SampleTest do 26 | use ExUnit.Case, async: true 27 | import PhoenixTest 28 | 29 | test "open browser" do 30 | Phoenix.ConnTest.build_conn() 31 | |> open_browser() 32 | end 33 | end 34 | """ 35 | |> to_source_file() 36 | |> run_check(NoOpenBrowser) 37 | |> assert_issue() 38 | end 39 | 40 | test "reports fully qualified PhoenixTest.open_browser/1 call" do 41 | """ 42 | defmodule SampleTest do 43 | use ExUnit.Case, async: true 44 | 45 | test "open browser" do 46 | Phoenix.ConnTest.build_conn() 47 | |> PhoenixTest.open_browser() 48 | end 49 | end 50 | """ 51 | |> to_source_file() 52 | |> run_check(NoOpenBrowser) 53 | |> assert_issue() 54 | end 55 | 56 | test "does NOT report fully qualified Phoenix.LiveViewTest.open_browser/1 call" do 57 | """ 58 | defmodule SampleTest do 59 | use ExUnit.Case, async: true 60 | 61 | test "open browser" do 62 | {:ok, view, _html} = Phoenix.LiveViewTest.live(Phoenix.ConnTest.build_conn(), "/live/index") 63 | Phoenix.LiveViewTest.open_browser(view) 64 | end 65 | end 66 | """ 67 | |> to_source_file() 68 | |> run_check(NoOpenBrowser) 69 | |> refute_issues() 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /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/phoenix_test/element/button_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.ButtonTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Element.Button 5 | 6 | describe "find!" do 7 | test "finds button by selector and text" do 8 | html = """ 9 | 12 | 13 | 16 | """ 17 | 18 | button = Button.find!(html, "button", "Save") 19 | 20 | assert button.id == "save" 21 | end 22 | 23 | test "raises an error if no button is found" do 24 | html = """ 25 | 28 | """ 29 | 30 | assert_raise ArgumentError, fn -> 31 | Button.find!(html, "button", "Delete") 32 | end 33 | end 34 | end 35 | 36 | describe "button.selector" do 37 | test "returns a dom id if an id is found" do 38 | html = """ 39 | 42 | """ 43 | 44 | button = Button.find!(html, "button", "Save") 45 | 46 | assert button.selector == ~s|[id="save"]| 47 | end 48 | 49 | test "returns a composite selector if button has no id" do 50 | html = """ 51 | 54 | """ 55 | 56 | button = Button.find!(html, "button", "Save") 57 | 58 | assert button.selector == ~s(button[name="super"][value="button"]) 59 | end 60 | 61 | test "keeps provided selector if more complex than 'button'" do 62 | html = """ 63 |
64 | 65 |
66 | """ 67 | 68 | button = Button.find!(html, "#button-id button", "Save") 69 | 70 | assert button.selector == ~s(#button-id button) 71 | end 72 | end 73 | 74 | describe "button.form_id" do 75 | test "returns the button's form attribute if present" do 76 | html = """ 77 | 80 | """ 81 | 82 | button = Button.find!(html, "button", "Save") 83 | 84 | assert button.form_id == "form-id" 85 | end 86 | 87 | test "returns nil if button doesn't have a form attribute" do 88 | html = """ 89 | 92 | """ 93 | 94 | button = Button.find!(html, "button", "Save") 95 | 96 | assert button.form_id == nil 97 | end 98 | end 99 | 100 | describe "button.raw" do 101 | test "returns the button's HTML" do 102 | html = """ 103 |
104 | 107 |
108 | """ 109 | 110 | button = Button.find!(html, "button", "Save") 111 | 112 | assert button.raw =~ ~r/^ 123 | 124 | """ 125 | 126 | button = Button.find!(html, "button", "Save") 127 | 128 | assert button.source_raw == html 129 | end 130 | end 131 | 132 | describe "button.name and button.value" do 133 | test "returns button's name and value if present" do 134 | html = """ 135 | 138 | """ 139 | 140 | button = Button.find!(html, "button", "Save") 141 | 142 | assert button.name == "super" 143 | assert button.value == "save" 144 | end 145 | 146 | test "returns nil if name and value aren't found" do 147 | html = """ 148 | 151 | """ 152 | 153 | button = Button.find!(html, "button", "Save") 154 | 155 | assert is_nil(button.name) 156 | assert is_nil(button.value) 157 | end 158 | 159 | test "returns empty value if name is present and no value is found" do 160 | html = """ 161 | 164 | """ 165 | 166 | button = Button.find!(html, "button", "Save") 167 | 168 | assert button.name == "generate" 169 | assert button.value == "" 170 | end 171 | end 172 | 173 | describe "belongs_to_form?" do 174 | test "returns true if button has a form ancestor" do 175 | html = """ 176 |
177 | 180 |
181 | """ 182 | 183 | button = Button.find!(html, "button", "Save") 184 | 185 | assert Button.belongs_to_form?(button) 186 | end 187 | 188 | test "returns true if button has a form attribute" do 189 | html = """ 190 | 193 | """ 194 | 195 | button = Button.find!(html, "button", "Save") 196 | 197 | assert Button.belongs_to_form?(button) 198 | end 199 | 200 | test "returns false if button stands alone" do 201 | html = """ 202 | 205 | """ 206 | 207 | button = Button.find!(html, "button", "Save") 208 | 209 | refute Button.belongs_to_form?(button) 210 | end 211 | end 212 | 213 | describe "phx_click?" do 214 | test "returns true if button has a phx-click attribute" do 215 | html = """ 216 | 219 | """ 220 | 221 | button = Button.find!(html, "button", "Save") 222 | 223 | assert Button.phx_click?(button) 224 | end 225 | 226 | test "returns false if button doesn't have a phx-click attribute" do 227 | html = """ 228 | 231 | """ 232 | 233 | button = Button.find!(html, "button", "Save") 234 | 235 | refute Button.phx_click?(button) 236 | end 237 | end 238 | 239 | describe "has_data_method?" do 240 | test "returns true if button has a data-method attribute" do 241 | html = """ 242 | 245 | """ 246 | 247 | button = Button.find!(html, "button", "Save") 248 | 249 | assert Button.has_data_method?(button) 250 | end 251 | 252 | test "returns false if button doesn't have a data-method attribute" do 253 | html = """ 254 | 257 | """ 258 | 259 | button = Button.find!(html, "button", "Save") 260 | 261 | refute Button.has_data_method?(button) 262 | end 263 | end 264 | 265 | describe "parent_form!" do 266 | test "returns the ancestor form when button was found" do 267 | html = """ 268 |
269 | 272 |
273 | """ 274 | 275 | form = 276 | html 277 | |> Button.find!("button", "Save") 278 | |> Button.parent_form!() 279 | 280 | assert form.id == "form" 281 | end 282 | 283 | test "returns associated form if button has form attribute" do 284 | html = """ 285 |
286 |
287 | 290 | """ 291 | 292 | form = 293 | html 294 | |> Button.find!("button", "Save") 295 | |> Button.parent_form!() 296 | 297 | assert form.id == "form" 298 | end 299 | 300 | test "raises an error if no parent form is found" do 301 | html = """ 302 | 305 | """ 306 | 307 | button = 308 | Button.find!(html, "button", "Save") 309 | 310 | assert_raise ArgumentError, fn -> 311 | Button.parent_form!(button) 312 | end 313 | end 314 | end 315 | end 316 | -------------------------------------------------------------------------------- /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 %{source_raw: ^html, id: "name", label: "Name", name: "name", value: "Hello world"} = 16 | field 17 | end 18 | 19 | test "finds radio button specified by label" do 20 | html = """ 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | """ 30 | 31 | field = Field.find_input!(html, "input", "Elf", exact: true) 32 | 33 | assert %{source_raw: ^html, id: "elf", label: "Elf", name: "race", value: "elf"} = field 34 | end 35 | 36 | test "finds input if nested inside label (and no id)" do 37 | html = """ 38 | 42 | """ 43 | 44 | field = Field.find_input!(html, "input", "Name", exact: true) 45 | 46 | assert %{source_raw: ^html, label: "Name", name: "name", value: "Hello world"} = field 47 | end 48 | 49 | test "builds a selector based on id if id is present" do 50 | html = """ 51 | 52 | 53 | """ 54 | 55 | field = Field.find_input!(html, "input", "Name", exact: true) 56 | 57 | assert %{selector: ~s|[id="name"]|} = field 58 | end 59 | 60 | test "builds a composite selector if id isn't present" do 61 | html = """ 62 | 66 | """ 67 | 68 | field = Field.find_input!(html, "input", "Name", exact: true) 69 | 70 | assert ~s(input[type="text"][name="name"]) = field.selector 71 | end 72 | end 73 | 74 | describe "find_checkbox!" do 75 | test "finds a checkbox and defaults value to 'on'" do 76 | html = """ 77 | 78 | 79 | """ 80 | 81 | field = Field.find_checkbox!(html, "input", "Yes", exact: true) 82 | 83 | assert %{source_raw: ^html, id: "yes", label: "Yes", name: "yes", value: "on"} = 84 | field 85 | end 86 | 87 | test "finds a checkbox and uses value if present" do 88 | html = """ 89 | 90 | 91 | """ 92 | 93 | field = Field.find_checkbox!(html, "input", "Yes", exact: true) 94 | 95 | assert %{value: "yes"} = field 96 | end 97 | end 98 | 99 | describe "find_hidden_uncheckbox!" do 100 | test "finds and uses hidden input's value that is associated to the checkbox" do 101 | html = """ 102 | 103 | 104 | 105 | """ 106 | 107 | field = Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true) 108 | 109 | assert %{source_raw: ^html, id: "yes", label: "Yes", name: "yes", value: "no"} = 110 | field 111 | end 112 | 113 | test "raises an error if checkbox input doesn't have a `name` (needed to find hidden input)" do 114 | html = """ 115 | 116 | 117 | 118 | """ 119 | 120 | assert_raise ArgumentError, ~r/Could not find element/, fn -> 121 | Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true) 122 | end 123 | end 124 | 125 | test "raises an error if hidden input doesn't have a `name`" do 126 | html = """ 127 | 128 | 129 | 130 | """ 131 | 132 | assert_raise ArgumentError, ~r/Could not find element/, fn -> 133 | Field.find_hidden_uncheckbox!(html, "input", "Yes", exact: true) 134 | end 135 | end 136 | end 137 | 138 | describe "phx_click?" do 139 | test "returns true if field has a phx-click handler" do 140 | html = """ 141 | 142 | 143 | """ 144 | 145 | field = Field.find_input!(html, "input", "Name", exact: true) 146 | 147 | assert Field.phx_click?(field) 148 | end 149 | 150 | test "returns false if field doesn't have a phx-click handler" do 151 | html = """ 152 | 153 | 154 | """ 155 | 156 | field = Field.find_input!(html, "input", "Name", exact: true) 157 | 158 | refute Field.phx_click?(field) 159 | end 160 | end 161 | 162 | describe "belongs_to_form?" do 163 | test "returns true if field is inside a form" do 164 | html = """ 165 |
166 | 167 | 168 |
169 | """ 170 | 171 | field = Field.find_input!(html, "input", "Name", exact: true) 172 | 173 | assert Field.belongs_to_form?(field) 174 | end 175 | 176 | test "returns false if field is outside of a form" do 177 | html = """ 178 | 179 | 180 | """ 181 | 182 | field = Field.find_input!(html, "input", "Name", exact: true) 183 | 184 | refute Field.belongs_to_form?(field) 185 | end 186 | end 187 | 188 | describe "validate_name!" do 189 | test "raises error if name attribute is missing" do 190 | html = """ 191 | 192 | 193 | """ 194 | 195 | field = Field.find_input!(html, "input", "Name", exact: true) 196 | 197 | assert_raise ArgumentError, ~r/missing a `name`/, fn -> 198 | Field.validate_name!(field) 199 | end 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /test/phoenix_test/element/form_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.Element.FormTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Element.Button 5 | alias PhoenixTest.Element.Field 6 | alias PhoenixTest.Element.Form 7 | alias PhoenixTest.Html 8 | 9 | describe "find!" do 10 | test "finds a form by selector" do 11 | html = """ 12 |
13 |
14 | 15 |
16 |
17 | """ 18 | 19 | form = Form.find!(html, "#user-form") 20 | 21 | assert form.id == "user-form" 22 | end 23 | end 24 | 25 | describe "find_by_descendant!" do 26 | test "finds parent form for button (if form id is present)" do 27 | html = """ 28 |
29 |
255 | 256 | """ 257 | 258 | form = Form.find!(html, "form") 259 | 260 | assert %Button{text: "Save"} = form.submit_button 261 | end 262 | 263 | test "returns the first button in the form (if many)" do 264 | html = """ 265 |
266 | 267 | 268 |
269 | """ 270 | 271 | form = Form.find!(html, "form") 272 | 273 | assert %Button{text: "Save"} = form.submit_button 274 | end 275 | 276 | test "returns nil if no buttons in the form" do 277 | html = """ 278 |
279 |
280 | """ 281 | 282 | form = Form.find!(html, "form") 283 | 284 | assert is_nil(form.submit_button) 285 | end 286 | end 287 | 288 | describe "form.action" do 289 | test "returns action if found in form" do 290 | html = """ 291 |
292 |
293 | """ 294 | 295 | form = Form.find!(html, "form") 296 | 297 | assert form.action == "/" 298 | end 299 | 300 | test "returns nil if no action is found" do 301 | html = """ 302 |
303 |
304 | """ 305 | 306 | form = Form.find!(html, "form") 307 | 308 | assert is_nil(form.action) 309 | end 310 | end 311 | 312 | describe "form.method" do 313 | test "sets 'get' as the form's method if none is specified" do 314 | html = """ 315 |
316 |
317 | """ 318 | 319 | form = Form.find!(html, "form") 320 | 321 | assert form.method == "get" 322 | end 323 | 324 | test "sets form method as operative_method if present" do 325 | html = """ 326 |
327 |
328 | """ 329 | 330 | form = Form.find!(html, "form") 331 | 332 | assert form.method == "post" 333 | end 334 | 335 | test "sets method based on hidden input if available" do 336 | html = 337 | """ 338 |
339 | 340 |
341 | """ 342 | 343 | form = Form.find!(html, "form") 344 | 345 | assert form.method == "put" 346 | end 347 | end 348 | 349 | describe "form_element_names/1" do 350 | test "returns list of names for all inputs, selects, texareas, etc." do 351 | html = """ 352 |
353 | 354 | 355 | 359 | 360 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 375 |
376 | """ 377 | 378 | form = Form.find!(html, "form") 379 | names = Form.form_element_names(form) 380 | 381 | assert "method" in names 382 | assert "some_input" in names 383 | assert "some_select" in names 384 | assert "select_multiple[]" in names 385 | assert "some_checkbox" in names 386 | assert "some_radio" in names 387 | assert "some_textarea" in names 388 | end 389 | end 390 | end 391 | -------------------------------------------------------------------------------- /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 |
90 | 91 | 94 |
95 | """ 96 | 97 | field = Select.find_select_option!(html, "select", "Name", "Select 1", exact: true) 98 | 99 | assert Select.belongs_to_form?(field) 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) 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 | -------------------------------------------------------------------------------- /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/form_data_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.FormDataTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Element.Button 5 | alias PhoenixTest.Element.Field 6 | alias PhoenixTest.Element.Select 7 | alias PhoenixTest.FormData 8 | 9 | describe "add_data" do 10 | test "adds new data to existing data" do 11 | form_data = 12 | FormData.new() 13 | |> FormData.add_data("name", "frodo") 14 | |> FormData.add_data("email", "frodo@example.com") 15 | 16 | assert FormData.has_data?(form_data, "name", "frodo") 17 | assert FormData.has_data?(form_data, "email", "frodo@example.com") 18 | end 19 | 20 | test "adds new data whose value is a list" do 21 | form_data = FormData.add_data(FormData.new(), "name", ["value_1", "value_2"]) 22 | 23 | assert FormData.has_data?(form_data, "name", "value_1") 24 | assert FormData.has_data?(form_data, "name", "value_2") 25 | end 26 | 27 | test "does not add data when name is nil" do 28 | form_data = FormData.add_data(FormData.new(), nil, "value") 29 | 30 | assert FormData.empty?(form_data) 31 | end 32 | 33 | test "adds Field (name/value)" do 34 | html = """ 35 | 36 | 37 | """ 38 | 39 | field = Field.find_input!(html, "input", "Name", exact: true) 40 | 41 | form_data = FormData.add_data(FormData.new(), field) 42 | 43 | assert FormData.has_data?(form_data, "name", "Hello world") 44 | end 45 | 46 | test "adds Button (name/value)" do 47 | button = %Button{name: "name", value: "Frodo"} 48 | 49 | form_data = FormData.add_data(FormData.new(), button) 50 | 51 | assert FormData.has_data?(form_data, "name", "Frodo") 52 | end 53 | 54 | test "doesn't add button data if Button doesn't have name and value" do 55 | button = %Button{} 56 | 57 | form_data = FormData.add_data(FormData.new(), button) 58 | 59 | assert FormData.empty?(form_data) 60 | end 61 | 62 | test "adds single Selected value as list of name/value pairs" do 63 | html = """ 64 | 65 | 70 | """ 71 | 72 | select = Select.find_select_option!(html, "select", "Name", "Select 1", exact: true) 73 | 74 | form_data = FormData.add_data(FormData.new(), select) 75 | 76 | assert FormData.has_data?(form_data, "name", "select_1") 77 | end 78 | 79 | test "adds list of Selected values into list of name/value pairs" do 80 | html = """ 81 | 82 | 87 | """ 88 | 89 | select = Select.find_select_option!(html, "select", "Name", ["Select 2", "Select 3"], exact: true) 90 | form_data = FormData.add_data(FormData.new(), select) 91 | 92 | assert FormData.has_data?(form_data, "name", "select_2") 93 | assert FormData.has_data?(form_data, "name", "select_3") 94 | end 95 | 96 | test "adds Field data as a name/value pair" do 97 | html = """ 98 | 99 | 100 | """ 101 | 102 | field = Field.find_input!(html, "input", "Name", exact: true) 103 | form_data = FormData.add_data(FormData.new(), field) 104 | 105 | assert FormData.has_data?(form_data, "name", "Hello world") 106 | end 107 | 108 | test "does not duplicate list data (with same name with [] and same value)" do 109 | form_data = 110 | FormData.new() 111 | |> FormData.add_data("email[]", "value") 112 | |> FormData.add_data("email[]", "value") 113 | 114 | data = FormData.to_list(form_data) 115 | 116 | assert data == [{"email[]", "value"}] 117 | end 118 | 119 | test "preserves multiple entries with different values if name has []" do 120 | form_data = 121 | FormData.new() 122 | |> FormData.add_data("email[]", "value") 123 | |> FormData.add_data("email[]", "value2") 124 | 125 | assert FormData.has_data?(form_data, "email[]", "value") 126 | assert FormData.has_data?(form_data, "email[]", "value2") 127 | end 128 | 129 | test "adds a list of data" do 130 | form_data = 131 | FormData.add_data(FormData.new(), [{"name", "frodo"}, {"email", "frodo@example.com"}]) 132 | 133 | assert FormData.has_data?(form_data, "name", "frodo") 134 | assert FormData.has_data?(form_data, "email", "frodo@example.com") 135 | end 136 | end 137 | 138 | describe "merge" do 139 | test "combines two FormData" do 140 | fd1 = 141 | FormData.add_data(FormData.new(), "name", "frodo") 142 | 143 | fd2 = 144 | FormData.add_data(FormData.new(), "email", "frodo@fellowship.com") 145 | 146 | form_data = FormData.merge(fd1, fd2) 147 | 148 | assert FormData.has_data?(form_data, "name", "frodo") 149 | assert FormData.has_data?(form_data, "email", "frodo@fellowship.com") 150 | end 151 | 152 | test "when a key ends in [], values are combined" do 153 | fd1 = 154 | FormData.add_data(FormData.new(), "contact[]", "email") 155 | 156 | fd2 = 157 | FormData.add_data(FormData.new(), "contact[]", "sms") 158 | 159 | form_data = FormData.merge(fd1, fd2) 160 | 161 | assert FormData.has_data?(form_data, "contact[]", "email") 162 | assert FormData.has_data?(form_data, "contact[]", "sms") 163 | end 164 | 165 | test "when a key doesn't end in [], new value overrides original value" do 166 | fd1 = 167 | FormData.add_data(FormData.new(), "contact", "email") 168 | 169 | fd2 = 170 | FormData.add_data(FormData.new(), "contact", "sms") 171 | 172 | form_data = FormData.merge(fd1, fd2) 173 | 174 | refute FormData.has_data?(form_data, "contact", "email") 175 | assert FormData.has_data?(form_data, "contact", "sms") 176 | end 177 | end 178 | 179 | describe "filter" do 180 | test "filters form data based on function provivded" do 181 | form_data = 182 | FormData.new() 183 | |> FormData.add_data("name", "frodo") 184 | |> FormData.add_data("email", "frodo@fellowship.com") 185 | 186 | filtered_data = 187 | FormData.filter(form_data, fn %{name: name, value: value} -> 188 | name == "name" and value == "frodo" 189 | end) 190 | 191 | assert FormData.has_data?(filtered_data, "name", "frodo") 192 | refute FormData.has_data?(filtered_data, "email", "frodo@fellowship.com") 193 | end 194 | end 195 | 196 | describe "empty?" do 197 | test "returns true if there's no data" do 198 | assert FormData.empty?(FormData.new()) 199 | end 200 | 201 | test "returns false if it has some data" do 202 | form_data = 203 | FormData.add_data(FormData.new(), "name", "frodo") 204 | 205 | refute FormData.empty?(form_data) 206 | end 207 | end 208 | 209 | describe "to_list" do 210 | test "transforms FormData into a list" do 211 | form_data = 212 | FormData.new() 213 | |> FormData.add_data("name", "frodo") 214 | |> FormData.add_data("email", "frodo@fellowship.com") 215 | 216 | list = FormData.to_list(form_data) 217 | 218 | assert list == [{"email", "frodo@fellowship.com"}, {"name", "frodo"}] 219 | end 220 | 221 | test "preserves select options ordering" do 222 | html = """ 223 | 224 | 229 | """ 230 | 231 | select = Select.find_select_option!(html, "select", "Name", ["Select 2", "Select 3"], exact: true) 232 | form_data = FormData.add_data(FormData.new(), select) 233 | 234 | assert FormData.to_list(form_data) == [ 235 | {"name[]", "select_2"}, 236 | {"name[]", "select_3"} 237 | ] 238 | end 239 | 240 | test "only returns one name (preserving of operations when deduplicating data)" do 241 | form_data = 242 | FormData.new() 243 | |> FormData.add_data("email", "value") 244 | |> FormData.add_data("email", "other_value") 245 | |> FormData.add_data("email", "third_value") 246 | 247 | data = FormData.to_list(form_data) 248 | 249 | assert data == [{"email", "third_value"}] 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/phoenix_test/form_payload_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.FormPayloadTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Element.Form 5 | alias PhoenixTest.FormPayload 6 | 7 | describe "new" do 8 | test "transforms FormData into a map ready to be a form payload" do 9 | html = """ 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 |
42 | """ 43 | 44 | form = Form.find!(html, "form") 45 | 46 | assert %{ 47 | "method" => "delete", 48 | "input" => "value", 49 | "text-input" => "text value", 50 | "number-input" => "123", 51 | "select" => "selected", 52 | "select_multiple" => ["select_1", "select_2"], 53 | "checkbox" => "checked", 54 | "radio" => "checked", 55 | "textarea" => "Default text" 56 | } = FormPayload.new(form.form_data) 57 | end 58 | 59 | test "multiple checkbox values named with [] resolve to a list in order of their appearance" do 60 | html = """ 61 |
62 | 63 | 64 |
65 | """ 66 | 67 | form = Form.find!(html, "form") 68 | 69 | assert %{"checkbox" => ["some_value", "another_value"]} = FormPayload.new(form.form_data) 70 | end 71 | 72 | test "single checkboxe value named with [] resolves to a list" do 73 | html = """ 74 |
75 | 76 | 77 |
78 | """ 79 | 80 | form = Form.find!(html, "form") 81 | 82 | assert %{"checkbox" => ["some_value"]} = FormPayload.new(form.form_data) 83 | end 84 | 85 | test "multiple hidden inputs named with [] resolve to a list in order of their appearance" do 86 | html = """ 87 |
88 | 89 | 90 |
91 | """ 92 | 93 | form = Form.find!(html, "form") 94 | 95 | assert %{"hidden" => ["some_value", "another_value"]} = FormPayload.new(form.form_data) 96 | end 97 | 98 | test "single hidden input value named with [] resolves to a list" do 99 | html = """ 100 |
101 | 102 |
103 | """ 104 | 105 | form = Form.find!(html, "form") 106 | 107 | assert %{"hidden" => ["some_value"]} = FormPayload.new(form.form_data) 108 | end 109 | 110 | test "ignores hidden value for checkbox when checked" do 111 | html = """ 112 |
113 | 114 | 115 |
116 | """ 117 | 118 | form = Form.find!(html, "form") 119 | 120 | assert %{"checkbox" => "checked"} = FormPayload.new(form.form_data) 121 | end 122 | 123 | test "uses hidden value for checkbox when unchecked" do 124 | html = """ 125 |
126 | 127 | 128 |
129 | """ 130 | 131 | form = Form.find!(html, "form") 132 | 133 | assert %{"checkbox" => "unchecked"} = FormPayload.new(form.form_data) 134 | end 135 | end 136 | 137 | describe "add_form_data" do 138 | test "adds new top-level data" do 139 | payload = %{"name" => "Frodo"} 140 | uploads = [{"avatar", upload()}] 141 | 142 | new_payload = FormPayload.add_form_data(payload, uploads) 143 | 144 | assert new_payload == %{"name" => "Frodo", "avatar" => upload()} 145 | end 146 | 147 | test "overwrites existing field value" do 148 | payload = %{"avatar" => "how did this string get here?"} 149 | uploads = [{"avatar", upload()}] 150 | 151 | updated_payload = FormPayload.add_form_data(payload, uploads) 152 | 153 | assert updated_payload == %{"avatar" => upload()} 154 | end 155 | 156 | test "injects nested data" do 157 | payload = %{"user" => %{"name" => "Frodo"}} 158 | uploads = [{"user[avatar]", upload()}] 159 | 160 | updated_payload = FormPayload.add_form_data(payload, uploads) 161 | 162 | assert updated_payload == %{"user" => %{"name" => "Frodo", "avatar" => upload()}} 163 | end 164 | 165 | test "handles form data in list" do 166 | payload = %{} 167 | uploads = [{"avatar[]", upload(0)}, {"avatar[]", upload(1)}] 168 | 169 | updated_payload = FormPayload.add_form_data(payload, uploads) 170 | 171 | assert updated_payload == %{"avatar" => [upload(0), upload(1)]} 172 | end 173 | 174 | test "handles all data in inputs_for pseudo list" do 175 | payload = %{} 176 | uploads = [{"avatar[0][file]", upload(0)}, {"avatar[1][file]", upload(1)}] 177 | 178 | updated_payload = FormPayload.add_form_data(payload, uploads) 179 | 180 | assert updated_payload == %{"avatar" => %{"0" => %{"file" => upload(0)}, "1" => %{"file" => upload(1)}}} 181 | end 182 | 183 | defp upload(filename \\ 0), do: %Plug.Upload{filename: filename} 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /test/phoenix_test/html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.HtmlTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Html 5 | 6 | describe "inner_text" do 7 | test "extracts text from parsed html, removing extra whitespace" do 8 | html = """ 9 | 13 | """ 14 | 15 | result = 16 | html 17 | |> Html.parse_fragment() 18 | |> Html.inner_text() 19 | 20 | assert result == "hello world!" 21 | end 22 | 23 | test "extracts text but excludes select elements and their options" do 24 | html = """ 25 |
26 |

Choose an option:

27 | 31 |

More text here

32 |
33 | """ 34 | 35 | result = 36 | html 37 | |> Html.parse_fragment() 38 | |> Html.inner_text() 39 | 40 | assert result == "Choose an option: More text here" 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test/phoenix_test/locators_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.LocatorsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Locators 5 | alias PhoenixTest.Locators.Button 6 | 7 | describe "button" do 8 | test "includes provided text" do 9 | %Button{text: text} = Locators.button(text: "Hello") 10 | 11 | assert text == "Hello" 12 | end 13 | 14 | test "has list of valid selectors" do 15 | valid_selectors = 16 | ~w|button [role="button"] input[type="button"] input[type="image"] input[type="reset"] input[type="submit"]| 17 | 18 | %Button{selectors: selectors} = Locators.button(text: "doesn't matter") 19 | 20 | assert selectors == valid_selectors 21 | end 22 | end 23 | 24 | describe "role_selectors/1 for button" do 25 | test "returns {'button', text} in list" do 26 | locator = Locators.button(text: "Hello") 27 | 28 | selectors = Locators.role_selectors(locator) 29 | 30 | assert {"button", "Hello"} in selectors 31 | end 32 | 33 | test "returns {[role=button], text} in list" do 34 | locator = Locators.button(text: "Hello") 35 | 36 | selectors = Locators.role_selectors(locator) 37 | 38 | assert {~s|[role="button"]|, "Hello"} in selectors 39 | end 40 | 41 | test "returns text as value for other selectors" do 42 | locator = Locators.button(text: "Hello") 43 | 44 | selectors = Locators.role_selectors(locator) 45 | 46 | assert ~s|input[type="button"][value="Hello"]| in selectors 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/phoenix_test/session_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.SessionHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | import PhoenixTest.SessionHelpers, only: [within: 3] 5 | 6 | describe "within" do 7 | test "runs action provided inside within" do 8 | initial = %{within: :none} 9 | 10 | assert_raise RuntimeError, "hello world", fn -> 11 | within(initial, "selector", fn _session -> 12 | raise "hello world" 13 | end) 14 | end 15 | end 16 | 17 | test "updates selector scope inside within" do 18 | initial = %{within: :none} 19 | 20 | within(initial, "#email-form", fn session -> 21 | assert session.within == "#email-form" 22 | session 23 | end) 24 | end 25 | 26 | test "scope is reset to :none outside of within call" do 27 | initial = %{within: :none} 28 | 29 | session = 30 | within(initial, "#email-form", fn session -> 31 | session 32 | end) 33 | 34 | assert session.within == :none 35 | end 36 | 37 | test "nests selector scopes when multiple withins" do 38 | initial = %{within: :none} 39 | 40 | within(initial, "main", fn session -> 41 | within(session, "#email-form", fn session -> 42 | assert session.within == "main #email-form" 43 | session 44 | end) 45 | end) 46 | end 47 | 48 | test "selector scopes do not interfere with adjacent withins" do 49 | initial = %{within: :none} 50 | 51 | initial 52 | |> within("#email-form", fn session -> 53 | session 54 | end) 55 | |> within("body", fn session -> 56 | within(session, "#user-form", fn session -> 57 | assert session.within == "body #user-form" 58 | session 59 | end) 60 | end) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/phoenix_test/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixTest.Utils 5 | 6 | describe "stringify_keys_and_values" do 7 | test "turns atom keys into string keys" do 8 | original = %{hello: "world"} 9 | 10 | result = Utils.stringify_keys_and_values(original) 11 | 12 | assert %{"hello" => "world"} = result 13 | end 14 | 15 | test "turns values into string keys" do 16 | original = %{value: :ok} 17 | 18 | result = Utils.stringify_keys_and_values(original) 19 | 20 | assert %{"value" => "ok"} = result 21 | end 22 | 23 | test "preserves lists and stringifies values" do 24 | original = %{greet: [:hello, "hola"]} 25 | 26 | result = Utils.stringify_keys_and_values(original) 27 | 28 | assert %{"greet" => ["hello", "hola"]} = result 29 | end 30 | 31 | test "preserves nested map values" do 32 | original = %{foo: %{bar: "baz"}} 33 | 34 | result = Utils.stringify_keys_and_values(original) 35 | 36 | assert %{"foo" => %{"bar" => "baz"}} = result 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/phoenix_test_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTestTest do 2 | use ExUnit.Case, async: true 3 | 4 | import PhoenixTest 5 | 6 | setup do 7 | %{conn: Phoenix.ConnTest.build_conn()} 8 | end 9 | 10 | describe "select/3" do 11 | test "shows deprecation warning when using :from", %{conn: conn} do 12 | message = 13 | ExUnit.CaptureIO.capture_io(:stderr, fn -> 14 | conn 15 | |> visit("/live/index") 16 | |> select("Elf", from: "Race") 17 | end) 18 | 19 | assert message =~ "select/3 with :from is deprecated" 20 | end 21 | end 22 | 23 | describe "select/4" do 24 | test "shows deprecation warning if passing `:from`", %{conn: conn} do 25 | message = 26 | ExUnit.CaptureIO.capture_io(:stderr, fn -> 27 | conn 28 | |> visit("/live/index") 29 | |> select("#select-favorite-character", "Frodo", from: "Character") 30 | end) 31 | 32 | assert message =~ "select/4 with :from is deprecated" 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.TestHelpers do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Converts a multi-line string into a whitespace-forgiving regex 6 | """ 7 | def ignore_whitespace(string) do 8 | string 9 | |> String.split("\n") 10 | |> Enum.map(&String.trim/1) 11 | |> Enum.reject(fn s -> s == "" end) 12 | |> Enum.map_join("\n", fn s -> "\\s*" <> s <> "\\s*" end) 13 | |> Regex.compile!([:dotall]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/web_app/async_page_2_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.AsyncPage2Live do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(_, _, socket) do 6 | {:ok, 7 | assign_async(socket, :title, fn -> 8 | Process.sleep(100) 9 | {:ok, %{title: "Another title loaded async"}} 10 | end)} 11 | end 12 | 13 | def render(assigns) do 14 | ~H""" 15 | <.async_result :let={title} assign={@title}> 16 | <:loading>Loading title... 17 | <:failed :let={_failure}>there was an error loading the title 18 |

{title}

19 | 20 | """ 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /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/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 ` 72 | 81 | {@label} 82 | 83 | <.error :for={msg <- @errors}>{msg} 84 | 85 | """ 86 | end 87 | 88 | def input(%{type: "select"} = assigns) do 89 | ~H""" 90 |
91 | <.label for={@id}>{@label} 92 | 102 | <.error :for={msg <- @errors}>{msg} 103 |
104 | """ 105 | end 106 | 107 | def input(%{type: "textarea"} = assigns) do 108 | ~H""" 109 |
110 | <.label for={@id}>{@label} 111 | 122 | <.error :for={msg <- @errors}>{msg} 123 |
124 | """ 125 | end 126 | 127 | # All other inputs text, datetime-local, url, password, etc. are handled here... 128 | def input(assigns) do 129 | ~H""" 130 |
131 | <.label for={@id}>{@label} 132 | 145 | <.error :for={msg <- @errors}>{msg} 146 |
147 | """ 148 | end 149 | 150 | @doc """ 151 | Renders a label. 152 | """ 153 | attr :for, :string, default: nil 154 | slot :inner_block, required: true 155 | 156 | def label(assigns) do 157 | ~H""" 158 | 161 | """ 162 | end 163 | 164 | @doc """ 165 | Generates a generic error message. 166 | """ 167 | slot :inner_block, required: true 168 | 169 | def error(assigns) do 170 | ~H""" 171 |

172 | {render_slot(@inner_block)} 173 |

174 | """ 175 | end 176 | 177 | @doc """ 178 | Translates an error message using gettext. 179 | """ 180 | def translate_error({msg, _opts}), do: msg 181 | 182 | @doc """ 183 | Translates the errors for a field from a keyword list of errors. 184 | """ 185 | def translate_errors(errors, field) when is_list(errors) do 186 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /test/support/web_app/dynamic_form_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.DynamicFormLive do 2 | @moduledoc false 3 | 4 | use Phoenix.LiveView 5 | 6 | def render(assigns) do 7 | ~H""" 8 |

Dynamic Form Test

9 | 10 | 11 |
19 | 20 | 21 | 22 |
23 | """ 24 | end 25 | 26 | def mount(_params, _session, socket) do 27 | {:ok, assign(socket, show_form: false, trigger_submit: false)} 28 | end 29 | 30 | def handle_event("show-form", _, socket) do 31 | {:noreply, assign(socket, :show_form, true)} 32 | end 33 | 34 | def handle_event("submit-form", _params, socket) do 35 | {:noreply, assign(socket, :trigger_submit, true)} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/web_app/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.Endpoint do 2 | @moduledoc false 3 | use Phoenix.Endpoint, otp_app: :phoenix_test 4 | 5 | @session_options [ 6 | store: :cookie, 7 | key: "_phoenix_key", 8 | signing_salt: "KUJB95ho", 9 | same_site: "Lax" 10 | ] 11 | 12 | socket "/live", Phoenix.LiveView.Socket, 13 | websocket: [connect_info: [session: @session_options]], 14 | longpoll: [connect_info: [session: @session_options]] 15 | 16 | plug Plug.Static, 17 | at: "/", 18 | from: :phoenix_test, 19 | gzip: false, 20 | only: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | plug Plug.RequestId 23 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 24 | 25 | plug Plug.Parsers, 26 | parsers: [:urlencoded, :multipart, :json], 27 | pass: ["*/*"], 28 | json_decoder: Phoenix.json_library() 29 | 30 | plug Plug.MethodOverride 31 | plug Plug.Head 32 | plug Plug.Session, @session_options 33 | plug PhoenixTest.WebApp.Router 34 | end 35 | -------------------------------------------------------------------------------- /test/support/web_app/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.ErrorView do 2 | use Phoenix.Component 3 | 4 | def render(_template, assigns) do 5 | ~H""" 6 |

{@status}

7 |

{@reason.message}

8 | """ 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/support/web_app/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.LayoutView do 2 | use Phoenix.Component 3 | 4 | use Phoenix.VerifiedRoutes, 5 | endpoint: PhoenixTest.WebApp.Endpoint, 6 | router: PhoenixTest.WebApp.Router, 7 | statics: ~w(assets fonts images favicon.ico robots.txt) 8 | 9 | def render("root.html", assigns) do 10 | ~H""" 11 | 12 | 13 | 14 | 15 | 16 | 17 | <.live_title>{assigns[:page_title] || "PhoenixTest is the best!"} 18 | 19 | 22 | 24 | 25 | 26 | {@inner_content} 27 | 28 | 29 | """ 30 | end 31 | 32 | def render("app.html", assigns) do 33 | ~H""" 34 |
35 |
36 |
37 | <.flash kind={:info} title="Success!" flash={@flash} /> 38 | <.flash kind={:error} title="Error!" flash={@flash} /> 39 |
40 | {@inner_content} 41 |
42 |
43 | """ 44 | end 45 | 46 | def flash(assigns) do 47 | assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) 48 | 49 | ~H""" 50 | 65 | """ 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/support/web_app/page_2_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.Page2Live do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def render(assigns) do 6 | ~H""" 7 |

LiveView page 2

8 | """ 9 | end 10 | 11 | def mount(%{"redirect_to" => path}, _, socket) do 12 | {:ok, 13 | socket 14 | |> put_flash(:info, "Navigated back!") 15 | |> push_navigate(to: path)} 16 | end 17 | 18 | def mount(_, _, socket) do 19 | {:ok, socket} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/web_app/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.PageController do 2 | use Phoenix.Controller, formats: [html: "View"] 3 | 4 | plug(:put_layout, {PhoenixTest.WebApp.LayoutView, :app}) 5 | 6 | def show(conn, %{"redirect_to" => path}) do 7 | conn 8 | |> put_flash(:info, "Redirected back!") 9 | |> redirect(to: path) 10 | end 11 | 12 | def show(conn, %{"page" => page}) do 13 | render(conn, page <> ".html") 14 | end 15 | 16 | def create(conn, params) do 17 | conn 18 | |> assign(:params, params) 19 | |> render("record_created.html") 20 | end 21 | 22 | def update(conn, params) do 23 | conn 24 | |> assign(:params, params) 25 | |> render("record_updated.html") 26 | end 27 | 28 | def delete(conn, _) do 29 | render(conn, "record_deleted.html") 30 | end 31 | 32 | def redirect_to_liveview(conn, _) do 33 | conn 34 | |> put_flash(:info, "Redirected to LiveView") 35 | |> redirect(to: "/live/index") 36 | end 37 | 38 | def redirect_to_static(conn, _) do 39 | conn 40 | |> put_flash(:info, "Redirected!") 41 | |> redirect(to: "/page/index") 42 | end 43 | 44 | def unauthorized(conn, _) do 45 | conn 46 | |> put_status(:unauthorized) 47 | |> render("unauthorized.html") 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/web_app/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.PageView do 2 | use Phoenix.Component 3 | 4 | def render("index.html", assigns) do 5 | ~H""" 6 |

Main page

7 | 8 | Page 2 9 | 10 | Navigate away and redirect back 11 | 12 | Multiple links 13 | Multiple links 14 | 15 | To LiveView! 16 | 17 |
    18 |
  • Aragorn
  • 19 |
  • Legolas
  • 20 |
  • Gimli
  • 21 |
22 | 23 |
    24 |
  • Aragorn
  • 25 |
26 | 27 |
28 |   Has extra space   29 |
30 | 31 | Incomplete data-method Delete 32 | 33 | 39 | Data-method Delete 40 | 41 | 42 | 43 | 44 | 47 | 48 |
49 | 50 |
51 | 52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 |
67 | 68 |
69 | 70 | 71 | 72 |
73 | 74 |
75 | 76 | 77 |
78 | 79 |
80 | 83 | 84 |
Test
85 | 86 | 90 | 91 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
101 | 102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 114 | 115 | 116 | 117 | 118 | 119 | 120 |
121 | 122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 151 | 152 | 153 | 159 | 160 |
161 | Please select your preferred contact method: 162 |
163 | 164 | 165 | 166 | 167 | 168 | 169 |
170 |
171 | 172 | 173 | 176 | 177 | 178 | 181 | 182 |
183 | 184 | 185 |
186 | 187 | 188 |
189 | 190 |
196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 | 211 |
212 | 213 | 214 | 215 |
216 | 217 |
218 | 219 |
220 | 221 |
222 | 223 | 224 |
225 | 226 |
227 | 228 | 229 |
230 | 231 |
232 | 233 | 234 | 235 | 236 |
237 | 238 | 239 | 240 |
241 | 242 | 243 |
244 | 245 | 246 |
247 | 250 | 251 | 252 | 255 | 256 | 257 | 258 |
259 | Book or movie? 260 | 261 | 262 | 263 | 264 | 265 | 266 |
267 | 268 | 269 | 273 | 274 | 275 | 276 | 277 | 278 |
279 | 280 |
281 |
282 | Do you like Elixir: 283 | 284 |
285 | 286 | 287 |
288 |
289 | 290 | 291 |
292 |
293 |
294 | Do you like Erlang: 295 | 296 |
297 | 298 | 299 |
300 |
301 | 302 | 303 |
304 |
305 | 306 |
307 | Favorite characters? 308 |
Book
309 | 310 | 311 | 312 |
Movies
313 | 314 | 315 |
316 | 317 |
318 | Do you like Elixir? 319 | 320 | 321 | 322 | 323 | Do you like Erlang 324 | 325 | 326 | 327 |
328 | 329 |
330 | Select your favorite character 331 | 332 | 333 | 339 |
340 | 341 |
342 | 343 | 349 |
350 | 351 |
352 | Upload your avatars 353 | 354 | 355 | 356 | 357 | 358 | 359 |
360 | 361 | 362 |
363 | """ 364 | end 365 | 366 | def render("page_2.html", assigns) do 367 | ~H""" 368 |

Page 2

369 | """ 370 | end 371 | 372 | def render("page_3.html", assigns) do 373 | ~H""" 374 |

Page 3

375 | """ 376 | end 377 | 378 | def render("by_value.html", assigns) do 379 | ~H""" 380 |

Find by value

381 |
382 | 385 | 386 | 389 | 390 | 391 | 394 | 397 |
398 | """ 399 | end 400 | 401 | def render("get_record.html", assigns) do 402 | ~H""" 403 |

Record received

404 | """ 405 | end 406 | 407 | def render("record_created.html", assigns) do 408 | ~H""" 409 |

Record created

410 | 411 |
412 | <%= for {key, value} <- @params do %> 413 | {render_input_data(key, value)} 414 | <% end %> 415 |
416 | """ 417 | end 418 | 419 | def render("record_updated.html", assigns) do 420 | ~H""" 421 |

Record updated

422 | 423 |
424 | <%= for {key, value} <- @params do %> 425 | {render_input_data(key, value)} 426 | <% end %> 427 |
428 | """ 429 | end 430 | 431 | def render("record_deleted.html", assigns) do 432 | ~H""" 433 |

Record deleted

434 | """ 435 | end 436 | 437 | def render("unauthorized.html", assigns) do 438 | ~H""" 439 |

Unauthorized

440 | """ 441 | end 442 | 443 | defp render_input_data(key, value) when value == "" or is_nil(value) do 444 | "#{key}'s value is empty" 445 | end 446 | 447 | defp render_input_data(key, [value | _] = values) when is_binary(value) do 448 | "#{key}: [#{Enum.join(values, ",")}]" 449 | end 450 | 451 | defp render_input_data(key, %Plug.Upload{} = upload) do 452 | "#{key}: #{upload.filename}" 453 | end 454 | 455 | defp render_input_data(key, value) when is_boolean(value) do 456 | "#{key}: #{to_string(value)}" 457 | end 458 | 459 | defp render_input_data(key, value) when is_binary(value) do 460 | "#{key}: #{value}" 461 | end 462 | 463 | defp render_input_data(key, values) do 464 | Enum.map_join(values, "\n", fn 465 | {nested_key, value} -> render_input_data("#{key}:#{nested_key}", value) 466 | value -> render_input_data("#{key}:[]", value) 467 | end) 468 | end 469 | end 470 | -------------------------------------------------------------------------------- /test/support/web_app/redirect_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.RedirectLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def render(assigns) do 6 | ~H""" 7 |

You shouldn't see this

8 | """ 9 | end 10 | 11 | def mount(%{"redirect_type" => redirect_type}, _, socket) do 12 | case redirect_type do 13 | "push_navigate" -> 14 | {:ok, 15 | socket 16 | |> put_flash(:info, "Navigated!") 17 | |> push_navigate(to: "/live/index")} 18 | 19 | "redirect" -> 20 | {:ok, 21 | socket 22 | |> put_flash(:info, "Redirected!") 23 | |> redirect(to: "/live/index")} 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/web_app/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.Router do 2 | use Phoenix.Router 3 | 4 | import Phoenix.LiveView.Router 5 | 6 | pipeline :setup_session do 7 | plug(Plug.Session, 8 | store: :cookie, 9 | key: "_phoenix_test_key", 10 | signing_salt: "/VADsdfSfdMnp5" 11 | ) 12 | 13 | plug(:fetch_session) 14 | end 15 | 16 | pipeline :browser do 17 | plug :accepts, ["html"] 18 | plug :fetch_session 19 | plug :fetch_live_flash 20 | plug :put_root_layout, html: {PhoenixTest.WebApp.LayoutView, :root} 21 | plug :protect_from_forgery 22 | plug :put_secure_browser_headers 23 | end 24 | 25 | scope "/", PhoenixTest.WebApp do 26 | pipe_through([:browser]) 27 | 28 | post "/page/create_record", PageController, :create 29 | put "/page/update_record", PageController, :update 30 | delete "/page/delete_record", PageController, :delete 31 | get "/page/unauthorized", PageController, :unauthorized 32 | get "/page/redirect_to_static", PageController, :redirect_to_static 33 | post "/page/redirect_to_liveview", PageController, :redirect_to_liveview 34 | post "/page/redirect_to_static", PageController, :redirect_to_static 35 | get "/page/:page", PageController, :show 36 | 37 | live_session :live_pages, layout: {PhoenixTest.WebApp.LayoutView, :app} do 38 | live "/live/index", IndexLive 39 | live "/live/index/alias", IndexLive 40 | live "/live/page_2", Page2Live 41 | live "/live/async_page", AsyncPageLive 42 | live "/live/async_page_2", AsyncPage2Live 43 | live "/live/dynamic_form", DynamicFormLive 44 | live "/live/simple_ordinal_inputs", SimpleOrdinalInputsLive 45 | end 46 | 47 | scope "/auth" do 48 | pipe_through([:proxy_header_auth]) 49 | 50 | live_session :auth, layout: {PhoenixTest.WebApp.LayoutView, :app} do 51 | live "/live/index", IndexLive 52 | live "/live/page_2", Page2Live 53 | end 54 | end 55 | 56 | live "/live/redirect_on_mount/:redirect_type", RedirectLive 57 | end 58 | 59 | def proxy_header_auth(conn, _opts) do 60 | case get_req_header(conn, "x-auth-header") do 61 | [value] -> put_session(conn, :auth_header, value) 62 | _ -> conn |> send_resp(401, "Unauthorized") |> halt() 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/web_app/simple_ordinal_inputs_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTest.WebApp.SimpleMailingList do 2 | @moduledoc false 3 | use Ecto.Schema 4 | 5 | import Ecto.Changeset 6 | 7 | embedded_schema do 8 | field(:title, :string) 9 | 10 | embeds_many :emails, Email, on_replace: :delete do 11 | field(:email, :string) 12 | end 13 | end 14 | 15 | def changeset(list, attrs) do 16 | list 17 | |> cast(attrs, [:title]) 18 | |> cast_embed(:emails, 19 | with: &email_changeset/2, 20 | sort_param: :emails_sort, 21 | drop_param: :emails_drop 22 | ) 23 | end 24 | 25 | def email_changeset(email_notification, attrs) do 26 | cast(email_notification, attrs, [:email]) 27 | end 28 | end 29 | 30 | defmodule PhoenixTest.WebApp.SimpleOrdinalInputsLive do 31 | @moduledoc false 32 | use Phoenix.LiveView 33 | use Phoenix.Component 34 | 35 | import PhoenixTest.WebApp.Components 36 | 37 | alias PhoenixTest.WebApp.SimpleMailingList 38 | 39 | def mount(_params, _session, socket) do 40 | email = %SimpleMailingList.Email{} 41 | changeset = SimpleMailingList.changeset(%SimpleMailingList{emails: [email]}, %{}) 42 | 43 | {:ok, 44 | assign(socket, 45 | changeset: changeset, 46 | form: to_form(changeset), 47 | submitted: false, 48 | emails: [] 49 | )} 50 | end 51 | 52 | def render(assigns) do 53 | ~H""" 54 | <.form for={@form} phx-submit="submit"> 55 | <.input field={@form[:title]} label="Title" /> 56 | <.inputs_for :let={ef} field={@form[:emails]}> 57 | <.input label="Email" type="text" field={ef[:email]} placeholder="email" /> 58 | 59 | 60 | 61 | 62 | 63 |
64 | <%= if @submitted do %> 65 |

Submitted Values:

66 |
Title: {@form.params["title"]}
67 | <%= for email <- @emails do %> 68 |
{email}
69 | <% end %> 70 | <% end %> 71 |
72 | """ 73 | end 74 | 75 | def handle_event("submit", %{"simple_mailing_list" => params}, socket) do 76 | changeset = SimpleMailingList.changeset(%SimpleMailingList{}, params) 77 | 78 | emails = 79 | changeset 80 | |> Ecto.Changeset.get_field(:emails) 81 | |> Enum.map(fn email -> email.email end) 82 | |> Enum.reject(&is_nil/1) 83 | 84 | {:noreply, 85 | assign(socket, 86 | changeset: changeset, 87 | form: to_form(changeset), 88 | submitted: true, 89 | emails: emails 90 | )} 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | {:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: PhoenixTest.PubSub}], strategy: :one_for_one) 4 | {:ok, _} = PhoenixTest.WebApp.Endpoint.start_link() 5 | {:ok, _} = Application.ensure_all_started(:credo) 6 | -------------------------------------------------------------------------------- /upgrade_guides.md: -------------------------------------------------------------------------------- 1 | Upgrade Guides 2 | ============== 3 | 4 | ## Upgrading to 0.7.0 5 | 6 | Version 0.7.0 has a potentially breaking changes for those using the `upload` 7 | helpers. 8 | 9 | PhoenixTest will now automatically trigger `phx-change` on `upload`. 10 | 11 | If you were previously triggering `phx-change` after your `upload`, you might 12 | now get duplicate `phx-change` events. Ideally, you simply no longer have to do 13 | that. 14 | 15 | For more information, see the discussion in PR [#162] or commit [8edd7b4]. 16 | 17 | [#162]: https://github.com/germsvel/phoenix_test/pull/162 18 | [8edd7b4]: https://github.com/germsvel/phoenix_test/commit/8edd7b4 19 | 20 | ### Why the change? 21 | 22 | We always want PhoenixTest to behave as closely as possible like real Phoenix 23 | does. When you have an upload, it triggers your `phx-change` event. So, we want 24 | to emulate that. All other form helpers already do that. But `upload` didn't do 25 | it until now. 26 | 27 | ## Upgrading to 0.6.0 28 | 29 | Version 0.6.0 has one deprecation warning and one (potentially) breaking change. 30 | 31 | ### Deprecates `select/3` and `select/4` using `:from` (use `:option` instead) 32 | 33 | Deprecates `select/3` and `select/4` using `:from` to denote the label. Instead, 34 | it expects the label text to be passed as a positional argument and an `:option` 35 | keyword argument to pass the option's text. 36 | 37 | Thus, you'll need to make this change: 38 | 39 | ```diff 40 | - |> select("Option 1", from: "Select Label") 41 | + |> select("Select Label", option: "Option 1") 42 | ``` 43 | 44 | And if you're using the version that provides a CSS selector: 45 | 46 | ```diff 47 | - |> select("#super-select", "Option 1", from: "Select Label") 48 | + |> select("#super-select", "Select Label", option: "Option 1") 49 | ``` 50 | 51 | #### Why the change? 52 | 53 | It may seem like a silly change (basically swapping positions of label and 54 | option arguments), and in some ways it is. There's no real change in 55 | functionality. So I've been very hesitant to make this change for a while. 56 | 57 | The problem is that `select` is a bit surprising and confusing to use! 58 | 59 | All other form helpers take in the label first as a positional argument, and 60 | then any additional arguments (when they have them) go into a keyword list. 61 | 62 | But `select` breaks that convention. It causes people to have to do mental 63 | gymnastics to switch the order of arguments. 64 | 65 | Rather than live with confusion for the rest of our lives, it seems better to 66 | incur the cost right now, and then we can move on with all of our form helpers 67 | being consistent. 68 | 69 | ### Raising when a route isn't found 70 | 71 | If you have any tests that navigate to a route that isn't defined in your 72 | router. Version 0.6.0 will raise an error. 73 | 74 | If you visit a path that isn't defined, it's possible you were already getting 75 | an error -- in which case this isn't a breaking change for you. 76 | 77 | But in some cases, people might've been landing on a page that wasn't defined 78 | (perhaps getting their 404 page), but their assertions still passed for other 79 | reasons (e.g. they were just asserting the path name) 80 | 81 | In those cases, the change we introduced would be a breaking change. 82 | 83 | #### Why the change? 84 | 85 | We want PhoenixTest to be as helpful as possible when you're test-driving the 86 | your implementation. And we don't want it to provide false positives (meaning 87 | your test passes, but it shouldn't pass). 88 | 89 | Let me give you two examples: 90 | 91 | 1. Your test fails because the `assert_has` doesn't find the text on the page. 92 | You know that text is on the page, so you're confused as to why that would 93 | be. Only after you add an `open_browser` do you realize you had landed on a 94 | 404 or 500 page because there was a typo on a route somewhere. PhoenixTest 95 | could just have raised an error that the route wasn't defined instead! 96 | 97 | 2. You have a test that has a `refute_has` with some text. Your test passes, so 98 | you think everything is good. Much later someone stumbles upon that test, but 99 | when they use `open_browser`, they realize the `refute_has` was passing only 100 | because you were on a 404 or 500 -- a completely different page from what you 101 | thought! It turned out that you navigated to a path that didn't exist. You 102 | thought your test was asserting that, for example, a user had been deleted, 103 | but instead you had gone to a non-existent path. 104 | 105 | In both of those cases, PhoenixTest could have saved you time and pain by simply 106 | ensuring that the path you're trying to visit is a real one. And the sooner we 107 | can give you feedback about that, the better. 108 | 109 | ## Upgrading to 0.2.13 110 | 111 | Version 0.2.13 deprecates `fill_form/3` and `submit_form/3`. 112 | 113 | 🥺 I know it's a pain. I'm sorry about that. 114 | 115 | I don't take changing APIs lightly (even pre 1.0)... but I think you'll like 116 | these changes. 117 | 118 | ### New form helpers 119 | 120 | Let me introduce you to our new form helpers: 121 | 122 | - `fill_in/3` 123 | - `select/3` 124 | - `choose/3` 125 | - `check/3` 126 | - `uncheck/3` 127 | 128 | These new form helpers target elements by labels! 🥳 129 | 130 | Instead of relying on the underlying data structures generated by Phoenix 131 | forms and changesets, you can now specify which label you're targeting. 132 | 133 | Change this: 👇 134 | 135 | ```elixir 136 | session 137 | |> fill_form("form", user: %{ 138 | name: "Aragorn", 139 | admin: true, 140 | country: "Arnor" 141 | }) 142 | ``` 143 | 144 | To this: 👇 145 | 146 | ```elixir 147 | session 148 | |> fill_in("Name", with: "Aragorn") 149 | |> check("Admin") 150 | |> select("Arnor", from: "Countries") 151 | ``` 152 | 153 | The new format: 154 | 155 | - encourages us (me included!) to use labels in forms, 156 | - decouples the testing of our forms from the underlying shape of a changeset or 157 | Phoenix form -- something that's a mere implementation detail, and 158 | - allows us to express our tests closer to the language a user would use when 159 | seeing a page. 160 | 161 | **But what if I don't want the label to show?** 162 | 163 | It's a good idea to have labels for accessibility -- even if they're not visible 164 | on the page. In those cases, you should hide them with CSS. 165 | 166 | For example, if you use Tailwind, you can add a `sr-only` class to your label. 167 | That will mark it as "screen-reader only" and hide it. 168 | 169 | ### Targeting a form to fill out 170 | 171 | Since `fill_form/3` used to allow targeting a form by CSS selector, you may want 172 | to target a form via CSS selector with the new format. To do that, you can scope 173 | all of the form helpers using `within/3`: 174 | 175 | ```elixir 176 | session 177 | |> within("#user-form", fn session -> 178 | session 179 | |> fill_in("Name", with: "Aragorn") 180 | |> check("Admin") 181 | |> select("Arnor", from: "Countries") 182 | end) 183 | ``` 184 | 185 | NOTE: you may no longer _need_ to target your form via CSS selector. The new 186 | helpers are a lot smarter since they're looking for the labels and their 187 | associated inputs or options. 188 | 189 | But if you have multiple forms with the same labels (even when those labels 190 | point to different inputs), then you might have to scope your form-filling. And 191 | that's where `within/3` can be handy. 192 | 193 | ### Submitting forms without clicking a button 194 | 195 | Once we've filled out a form, we typically click a button with `click_button/2` 196 | to submit the form. But sometimes you want to emulate what would happen by just 197 | pressing (or do what `submit_form/3` used to do). 198 | 199 | For that case, you can use `submit/1` to submit the form you just filled out. 200 | 201 | ```elixir 202 | session 203 | |> fill_in("Name", with: "Aragorn") 204 | |> check("Admin") 205 | |> select("Arnor", from: "Countries") 206 | |> submit() 207 | ``` 208 | --------------------------------------------------------------------------------