Current section
Files
Jump to
Current section
Files
lib/phoenix_test/playwright.ex
defmodule PhoenixTest.Playwright do
@moduledoc ~S"""
Execute PhoenixTest cases in an actual browser via [Playwright](https://playwright.dev/).
Please [get in touch](https://ftes.de) with feedback of any shape and size.
Enjoy! Freddy.
## Example
Refer to the accompanying example repo for a full example:
https://github.com/ftes/phoenix_test_playwright_example/commits/main
## Setup
1. Add to `mix.exs` deps: `{:phoenix_test_playwright, "~> 0.1", only: :test, runtime: false}`
2. Install Playwright: `npm --prefix assets i -D playwright`
3. Install browsers: `npm --prefix assets exec playwright install --with-deps`
4. Add to `config/test.exs`: `config :phoenix_test, otp_app: :your_app, playwright: [cli: "assets/node_modules/playwright/cli.js"]` (for more options, see [Configuration](#module-configuration) below)
5. Add to `config/test.exs`: `config :your_app, YourAppWeb.Endpoint, server: true`
6. Add to `test/test_helpers.exs`: `Application.put_env(:phoenix_test, :base_url, YourAppWeb.Endpoint.url())`
## Usage
```elixir
defmodule MyFeatureTest do
use PhoenixTest.Case, async: true
@moduletag :playwright
@moduletag browser: :firefox
@moduletag headless: false
@moduletag slow_mo: :timer.seconds(1)
@tag trace: :open
test "heading", %{conn: conn} do
conn
|> visit("/")
|> assert_has("h1", text: "Heading")
|> screenshot("heading.png")
end
end
```
## Configuration
In `config/test.exs`:
```elixir
config :phoenix_test,
otp_app: :your_app,
playwright: [
browser: :chromium,
headless: System.get_env("PW_HEADLESS", "true") in ~w(t true),
js_logger: false,
screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true),
trace: System.get_env("PW_TRACE", "false") in ~w(t true),
]
```
See `PhoenixTest.Playwright.Config` for more details.
You can override some options in your test via ExUnit tags (`@moduletag/@describetag/@tag`).
For example, you might set the global default to `headless: true` and override it for a single module:
```elixir
defmodule DebuggingFeatureTest do
use PhoenixTest.Case, async: true
# Run test in a browser with a 1 second delay between every interaction
@moduletag headless: false
@moduletag slow_mo: 1_000
```
## Playwright Traces
Playwright traces record a full browser history, including all 'user' interaction, browser console, network transfers etc.
Traces can be explored in an interactive viewer for debugging purposes.
Manually:
- Add `@tag trace: :open` to trace a test and open it in the Playwright viewer.
Automatically for failed tests in CI:
- Configuration: `config :phoenix_test, playwright: [trace: System.get_env("PW_TRACE", "false") in ~w(t true)]`
- CI script: `mix test || if [[ $? = 2 ]]; then PW_TRACE=true mix test --failed; else false; fi`
## Screenshots
Manually:
- Call `PhoenixTest.Playwright.screenshot/2` with the filename you want to write to.
- By default, the entire page will be captured, not just the current viewport.
Automatically for failed tests in CI:
- Configuration: `config :phoenix_test, playwright: [screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true)]`
- CI script: `mix test || if [[ $? = 2 ]]; then PW_SCREENSHOT=true mix test --failed; else false; fi`
## Common problems
- Test failures in CI (timeouts): Try less concurrency, e.g. `mix test --max-cases 1` for GitHub CI shared runners
- LiveView not connected: add `assert_has("body .phx-connected")` to test after `visit`ing (or otherwise navigating to) a LiveView
- LiveComponent not connected: add `data-connected={connected?(@socket)}` to template and `assert_has("#my-component[data-connected]")` to test
## Ecto SQL.Sandbox
`PhoenixTest.Case` automatically takes care of this.
It passes a user agent referencing your Ecto repos.
This allows for [concurrent browser tests](https://hexdocs.pm/phoenix_ecto/main.html#concurrent-browser-tests).
Make sure to follow the advanced set up instructions if necessary:
- [with LiveViews](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-liveviews)
- [with Channels](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-channels)
```elixir
defmodule MyTest do
use PhoenixTest.Case, async: true
```
## Advanced assertions
```elixir
def assert_has_value(session, label, value, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.have.value",
expectedText: [%{string: value}]
)
end
def assert_has_selected(session, label, value, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: label |> Selector.label(opts) |> Selector.concat("option[selected]"),
expression: "to.have.text",
expectedText: [%{string: value}]
)
end
def assert_is_chosen(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.have.attribute",
expressionArg: "checked"
)
end
def assert_is_editable(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.be.editable"
)
end
def refute_is_editable(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(
session,
[
selector: Selector.label(label, opts),
expression: "to.be.editable"
],
is_not: true
)
end
def assert_found(session, params, opts \\ []) do
is_not = Keyword.get(opts, :is_not, false)
params = Enum.into(params, %{is_not: is_not})
unwrap(session, fn frame_id ->
{:ok, found} = Frame.expect(frame_id, params)
if is_not, do: refute(found), else: assert(found)
end)
end
def assert_download(session, name, contains: content) do
assert_receive({:playwright, %{method: :download} = download_msg}, 2000)
artifact_guid = download_msg.params.artifact.guid
assert_receive({:playwright, %{method: :__create__, params: %{guid: ^artifact_guid}} = artifact_msg}, 2000)
download_path = artifact_msg.params.initializer.absolutePath
wait_for_file(download_path)
assert download_msg.params.suggestedFilename =~ name
assert File.read!(download_path) =~ content
session
end
defp wait_for_file(path, remaining_ms \\ 2000, wait_for_ms \\ 100)
defp wait_for_file(path, remaining_ms, _) when remaining_ms <= 0, do: flunk("File #{path} does not exist")
defp wait_for_file(path, remaining_ms, wait_for_ms) do
if File.exists?(path) do
:ok
else
Process.sleep(wait_for_ms)
wait_for_file(path, remaining_ms - wait_for_ms, wait_for_ms)
end
end
```
"""
import ExUnit.Assertions
alias PhoenixTest.OpenBrowser
alias PhoenixTest.Playwright.Config
alias PhoenixTest.Playwright.Connection
alias PhoenixTest.Playwright.Frame
alias PhoenixTest.Playwright.Page
alias PhoenixTest.Playwright.Selector
require Logger
defstruct [:context_id, :page_id, :frame_id, :last_input_selector, within: :none]
@endpoint Application.compile_env(:phoenix_test, :endpoint)
def build(context_id, page_id, frame_id) do
%__MODULE__{context_id: context_id, page_id: page_id, frame_id: frame_id}
end
def retry(fun, backoff_ms \\ [100, 250, 500, timeout()])
def retry(fun, []), do: fun.()
def retry(fun, [sleep_ms | backoff_ms]) do
fun.()
rescue
ExUnit.AssertionError ->
Process.sleep(sleep_ms)
retry(fun, backoff_ms)
end
def visit(session, path) do
url =
case path do
"http://" <> _ -> path
"https://" <> _ -> path
_ -> Application.fetch_env!(:phoenix_test, :base_url) <> path
end
Frame.goto(session.frame_id, url)
session
end
@doc """
Takes a screenshot of the current page and saves it to the given file path.
The screenshot type will be inferred from the file extension on the path you provide.
If the path is relative (e.g., "my_screenshot.png" or "my_test/my_screenshot.jpg"), it will
be saved in the directory specified by the `:screenshot_dir` config option, which defaults
to `"screenshots"`.
## Options
- `:full_page` (boolean): Whether to take a full page screenshot. If false,
only the current viewport will be captured. Defaults to true.
- `:omit_background` (boolean): Whether to omit the background, allowing screenshots
to be captured with transparency. Only applicable to PNG images. Defaults to false.
## Examples
# By default, writes to screenshots/my-screenshot.png within your project root
> PhoenixTest.Playwright.screenshot(session, "my-screenshot.png")
# Writes to screenshots/my-test/my-screenshot.jpg by default
> PhoenixTest.Playwright.screenshot(session, "my-test/my-screenshot.jpg")
"""
def screenshot(session, file_path, opts \\ []) do
opts = Keyword.validate!(opts, full_page: true, omit_background: false)
dir = Config.global(:screenshot_dir)
File.mkdir_p!(dir)
path = Path.join(dir, file_path)
{:ok, binary_img} = Page.screenshot(session.page_id, opts)
File.write!(path, Base.decode64!(binary_img))
session
end
def assert_has(session, "title") do
retry(fn -> assert render_page_title(session) != nil end)
end
def assert_has(session, selector), do: assert_has(session, selector, [])
def assert_has(session, "title", opts) do
text = Keyword.fetch!(opts, :text)
exact = Keyword.get(opts, :exact, false)
if exact do
retry(fn -> assert render_page_title(session) == text end)
else
retry(fn -> assert render_page_title(session) =~ text end)
end
session
end
def assert_has(session, selector, opts) do
if !found?(session, selector, opts) do
flunk("Could not find element #{selector} #{inspect(opts)}")
end
session
end
def refute_has(session, "title") do
retry(fn -> assert render_page_title(session) == nil end)
end
def refute_has(session, selector), do: refute_has(session, selector, [])
def refute_has(session, "title", opts) do
text = Keyword.fetch!(opts, :text)
exact = Keyword.get(opts, :exact, false)
if exact do
retry(fn -> refute render_page_title(session) == text end)
else
retry(fn -> refute render_page_title(session) =~ text end)
end
session
end
def refute_has(session, selector, opts) do
if found?(session, selector, opts) do
flunk("Found element #{selector} #{inspect(opts)}")
end
session
end
defp found?(session, selector, opts) do
selector =
session
|> maybe_within()
|> Selector.concat(Selector.css(selector))
|> Selector.concat("visible=true")
|> Selector.concat(Selector.text(opts[:text], opts))
if opts[:count] do
if opts[:at],
do: raise(ArgumentError, message: "Options `count` and `at` can not be used together.")
params =
%{
expression: "to.have.count",
expected_number: opts[:count],
selector: Selector.build(selector),
timeout: timeout(opts)
}
{:ok, found?} = Frame.expect(session.frame_id, params)
found?
else
params =
%{
selector: selector |> Selector.concat(Selector.at(opts[:at])) |> Selector.build(),
timeout: timeout(opts)
}
case Frame.wait_for_selector(session.frame_id, params) do
{:ok, _} -> true
_ -> false
end
end
end
def render_page_title(session) do
case Frame.title(session.frame_id) do
{:ok, ""} -> nil
{:ok, title} -> title
end
end
def render_html(session) do
selector = session |> maybe_within() |> Selector.build()
{:ok, html} = Frame.inner_html(session.frame_id, selector)
html
end
def click(session, selector) do
session.frame_id
|> Frame.click(selector)
|> handle_response(selector)
session
end
def click(session, selector, text, opts \\ []) do
opts = Keyword.validate!(opts, exact: false)
selector =
session
|> maybe_within()
|> Selector.concat(selector)
|> Selector.concat(Selector.text(text, opts))
session.frame_id
|> Frame.click(selector)
|> handle_response(selector)
session
end
def click_link(session, selector \\ nil, text, opts \\ []) do
opts = Keyword.validate!(opts, exact: false)
selector =
session
|> maybe_within()
|> Selector.concat(
case selector do
nil -> Selector.link(text, opts)
css -> css |> Selector.css() |> Selector.concat(Selector.text(text, opts))
end
)
|> Selector.build()
session.frame_id
|> Frame.click(selector)
|> handle_response(selector)
session
end
def click_button(session, selector \\ nil, text, opts \\ []) do
opts = Keyword.validate!(opts, exact: false)
selector =
session
|> maybe_within()
|> Selector.concat(
case selector do
nil -> Selector.button(text, opts)
css -> css |> Selector.css() |> Selector.concat(Selector.text(text, opts))
end
)
|> Selector.build()
session.frame_id
|> Frame.click(selector)
|> handle_response(selector)
session
end
def within(session, selector, fun) do
session
|> Map.put(:within, selector)
|> fun.()
|> Map.put(:within, :none)
end
def fill_in(session, css_selector \\ nil, label, opts) do
{value, opts} = Keyword.pop!(opts, :with)
fun = &Frame.fill(session.frame_id, &1, to_string(value), &2)
input(session, css_selector, label, opts, fun)
end
def select(session, css_selector \\ nil, option_labels, opts) do
if opts[:exact_option] != true, do: raise("exact_option not implemented")
{label, opts} = Keyword.pop!(opts, :from)
options = option_labels |> List.wrap() |> Enum.map(&%{label: &1})
fun = &Frame.select_option(session.frame_id, &1, options, &2)
input(session, css_selector, label, opts, fun)
end
def check(session, css_selector \\ nil, label, opts) do
fun = &Frame.check(session.frame_id, &1, &2)
input(session, css_selector, label, opts, fun)
end
def uncheck(session, css_selector \\ nil, label, opts) do
fun = &Frame.uncheck(session.frame_id, &1, &2)
input(session, css_selector, label, opts, fun)
end
def choose(session, css_selector \\ nil, label, opts) do
fun = &Frame.check(session.frame_id, &1, &2)
input(session, css_selector, label, opts, fun)
end
def upload(session, css_selector \\ nil, label, paths, opts) do
paths = paths |> List.wrap() |> Enum.map(&Path.expand/1)
fun = &Frame.set_input_files(session.frame_id, &1, paths, &2)
input(session, css_selector, label, opts, fun)
end
defp input(session, css_selector, label, opts, fun) do
selector =
session
|> maybe_within()
|> Selector.concat(
case css_selector do
nil -> Selector.label(label, opts)
css -> css |> Selector.css() |> Selector.and(Selector.label(label, opts))
end
)
|> Selector.build()
selector
|> fun.(%{timeout: timeout(opts)})
|> handle_response(selector)
%{session | last_input_selector: selector}
end
defp maybe_within(session) do
case session.within do
:none -> Selector.none()
selector -> selector
end
end
defp handle_response(result, debug_selector) do
case result do
{:error, %{error: %{error: %{name: "TimeoutError"} = pw_error}} = error} ->
base_error_header = "Could not find element with selector #{debug_selector}"
playwright_message = pw_error[:message]
error_header =
case is_binary(playwright_message) &&
Regex.scan(~r/Timeout (\d+)ms exceeded./, playwright_message) do
[[_, timeout]] -> base_error_header <> " within #{String.to_integer(timeout)}ms"
_ -> base_error_header
end
more_info =
case error[:log] do
log when is_list(log) -> "Playwright log:\n" <> Enum.join(log, "\n")
_ -> inspect(error, pretty: true)
end
flunk("#{error_header}\n#{more_info}")
{:error, %{error: %{error: %{message: "Error: strict mode violation: " <> _ = message}}}} ->
short_message = String.replace(message, "Error: strict mode violation: ", "")
flunk("Found more than one element matching selector #{debug_selector}:\n#{short_message}")
{:error,
%{
error: %{
error: %{name: "Error", message: "Clicking the checkbox did not change its state"}
}
}} ->
:ok
{:ok, result} ->
result
end
end
def submit(session) do
Frame.press(session.frame_id, session.last_input_selector, "Enter")
session
end
def open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do
# Await any pending navigation
Process.sleep(100)
{:ok, html} = Frame.content(session.frame_id)
fixed_html =
html
|> Floki.parse_document!()
|> Floki.traverse_and_update(&OpenBrowser.prefix_static_paths(&1, @endpoint))
|> Floki.raw_html()
path = Path.join([System.tmp_dir!(), "phx-test#{System.unique_integer([:monotonic])}.html"])
File.write!(path, fixed_html)
open_fun.(path)
session
end
def unwrap(session, fun) do
fun.(Map.take(session, ~w(context_id page_id frame_id)a))
session
end
def current_path(session) do
resp =
session.frame_id
|> Connection.received()
|> Enum.find(&match?(%{method: :navigated, params: %{url: _}}, &1))
if resp == nil, do: raise(ArgumentError, "Could not find current path.")
uri = URI.parse(resp.params.url)
[uri.path, uri.query] |> Enum.reject(&is_nil/1) |> Enum.join("?")
end
defp timeout(opts \\ []) do
Keyword.get_lazy(opts, :timeout, fn -> Config.global(:timeout) end)
end
end
defimpl PhoenixTest.Driver, for: PhoenixTest.Playwright do
alias PhoenixTest.Assertions
alias PhoenixTest.Playwright
defdelegate visit(session, path), to: Playwright
defdelegate render_page_title(session), to: Playwright
defdelegate render_html(session), to: Playwright
defdelegate within(session, selector, fun), to: Playwright
defdelegate click_link(session, text), to: Playwright
defdelegate click_link(session, selector, text), to: Playwright
defdelegate click_button(session, text), to: Playwright
defdelegate click_button(session, selector, text), to: Playwright
defdelegate fill_in(session, label, opts), to: Playwright
defdelegate fill_in(session, selector, label, opts), to: Playwright
defdelegate select(session, selector, option, opts), to: Playwright
defdelegate select(session, option, opts), to: Playwright
defdelegate check(session, selector, label, opts), to: Playwright
defdelegate check(session, label, opts), to: Playwright
defdelegate uncheck(session, selector, label, opts), to: Playwright
defdelegate uncheck(session, label, opts), to: Playwright
defdelegate choose(session, selector, label, opts), to: Playwright
defdelegate choose(session, label, opts), to: Playwright
defdelegate upload(session, selector, label, path, opts), to: Playwright
defdelegate upload(session, label, path, opts), to: Playwright
defdelegate submit(session), to: Playwright
defdelegate open_browser(session), to: Playwright
defdelegate open_browser(session, open_fun), to: Playwright
defdelegate unwrap(session, fun), to: Playwright
defdelegate current_path(session), to: Playwright
defdelegate assert_has(session, selector), to: Playwright
defdelegate assert_has(session, selector, opts), to: Playwright
defdelegate refute_has(session, selector), to: Playwright
defdelegate refute_has(session, selector, opts), to: Playwright
def assert_path(session, path), do: Playwright.retry(fn -> Assertions.assert_path(session, path) end)
def assert_path(session, path, opts), do: Playwright.retry(fn -> Assertions.assert_path(session, path, opts) end)
def refute_path(session, path), do: Playwright.retry(fn -> Assertions.refute_path(session, path) end)
def refute_path(session, path, opts), do: Playwright.retry(fn -> Assertions.refute_path(session, path, opts) end)
end