Skip to content

Commit

Permalink
Add playwright driver
Browse files Browse the repository at this point in the history
  • Loading branch information
ftes committed Oct 24, 2024
1 parent 6d6d40b commit 2b617c1
Show file tree
Hide file tree
Showing 18 changed files with 680 additions and 199 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ phoenix_test-*.tar

# Ignore assets that are produced by build tools.
/priv/static/assets/

.envrc
6 changes: 6 additions & 0 deletions lib/phoenix_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ defmodule PhoenixTest do
LiveView or a static view. You don't need to worry about which type of page
you're visiting.
"""
if Code.ensure_loaded?(Playwright.Page) do
def visit(%Playwright.Page{} = page, path) do
PhoenixTest.Playwright.build(page, path)
end
end

def visit(conn, path) do
case get(conn, path) do
%{assigns: %{live_module: _}} = conn ->
Expand Down
2 changes: 1 addition & 1 deletion lib/phoenix_test/assertions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ defmodule PhoenixTest.Assertions do
|> append_found(found)
end

defp assert_not_found_error_msg(selector, opts, other_matches \\ []) do
def assert_not_found_error_msg(selector, opts, other_matches \\ []) do
count = Keyword.get(opts, :count, :any)
position = Keyword.get(opts, :at, :any)
text = Keyword.get(opts, :text, :no_text)
Expand Down
66 changes: 66 additions & 0 deletions lib/phoenix_test/phoenix_test_case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
defmodule PhoenixTest.Case do
@moduledoc false

defmacro __using__(options \\ []) do
quote do
import PhoenixTest

setup_all do
[conn: Phoenix.ConnTest.build_conn()]
end

if Code.ensure_loaded?(Playwright.Page) and unquote(options[:playwright]) do
setup_all(context) do
opts = Map.new(unquote(options))
PhoenixTest.Case.setup_all_playwright(opts)
end

setup(context) do
PhoenixTest.Case.setup_playwright(context)
end
end
end
end

if Code.ensure_loaded?(Playwright.Page) do
def setup_all_playwright(options) do
client = options.playwright
launch_options = Map.merge(Playwright.SDK.Config.launch_options(), options)
runner_options = Map.merge(Playwright.SDK.Config.playwright_test(), options)
Application.put_env(:playwright, LaunchOptions, launch_options)
{:ok, _} = Application.ensure_all_started(:playwright)
browser = setup_browser(client, runner_options, launch_options)

[browser: browser]
end

def setup_playwright(%{playwright: _} = context) do
page = Playwright.Browser.new_page(context.browser)
ExUnit.Callbacks.on_exit(fn -> Playwright.Page.close(page) end)

[conn: page]
end

def setup_playwright(_context) do
[conn: Phoenix.ConnTest.build_conn()]
end

defp setup_browser(true = _client, runner_options, launch_options) do
setup_browser(:chromium, runner_options, launch_options)
end

defp setup_browser(client, runner_options, launch_options) do
case runner_options.transport do
:driver ->
{_pid, browser} = Playwright.BrowserType.launch(client, launch_options)
ExUnit.Callbacks.on_exit(fn -> Playwright.Browser.close(browser) end)
browser

:websocket ->
options = Playwright.SDK.Config.connect_options()
{_pid, browser} = Playwright.BrowserType.connect(options.ws_endpoint)
browser
end
end
end
end
246 changes: 246 additions & 0 deletions lib/phoenix_test/playwright.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
if Code.ensure_loaded?(Playwright.Page) do
defmodule PhoenixTest.Playwright do
@moduledoc false

alias ExUnit.AssertionError
alias PhoenixTest.Assertions
alias PhoenixTest.OpenBrowser
alias PhoenixTest.Playwright.Locator, as: L
alias Playwright.Locator
alias Playwright.Page
alias Playwright.SDK.Channel

defstruct [:page, within: :none]

@endpoint Application.compile_env(:phoenix_test, :endpoint)
@default_timeout :timer.seconds(1)

def build(page, path) do
base_url = Application.fetch_env!(:phoenix_test, :base_url)
Page.goto(page, base_url <> path)
%__MODULE__{page: page}
end

def assert_has(session, selector, opts \\ []) do
unless found?(session, selector, opts) do
raise(AssertionError, Assertions.assert_not_found_error_msg(selector, opts, []))
end

session
end

def refute_has(session, selector, opts \\ []) do
if found?(session, selector, opts, is_not: true) do
raise(AssertionError, Assertions.refute_found_error_msg(selector, opts, []))
end

session
end

defp found?(session, selector, opts, query_attrs \\ []) do
# TODO
if opts[:count], do: raise("count not implemented")

locator =
session
|> maybe_within()
|> L.concat(L.css(selector))
|> L.concat(L.text(opts[:text], opts))
|> L.concat(L.at(opts[:at]))

query =
Enum.into(query_attrs, %{
expression: "to.be.visible",
is_not: false,
selector: locator.selector,
timeout: timeout(opts)
})

Channel.post(session.page.session, {:guid, locator.frame.guid}, :expect, query)
end

def render_page_title(session) do
Page.title(session.page)
end

def render_html(session) do
session
|> maybe_within()
|> Locator.inner_html()
end

def click_link(session, selector, text) do
session
|> maybe_within()
|> L.concat(L.css(selector))
|> L.concat(L.text(text, exact: false))
|> Locator.click(%{timeout: timeout()})
|> handle_result(selector)

session
end

def click_button(session, selector, text) do
session
|> maybe_within()
|> L.concat(L.css(selector))
|> L.concat(L.text(text, exact: false))
|> Locator.click(%{timeout: timeout()})
|> handle_result(selector)

session
end

def within(session, selector, fun) do
session
|> Map.put(:within, selector)
|> fun.()
|> Map.put(:within, :none)
end

def fill_in(session, input_selector, label, opts) do
{value, opts} = Keyword.pop!(opts, :with)
fun = &Locator.fill(&1, to_string(value), &2)
input(session, input_selector, label, opts, fun)
end

def select(session, input_selector, option, opts) do
{label, opts} = Keyword.pop!(opts, :from)
fun = &Locator.select_option(&1, %{label: option}, &2)
input(session, input_selector, label, opts, fun)
end

def check(session, input_selector, label, opts) do
fun = &Locator.check/2
input(session, input_selector, label, opts, fun)
end

def uncheck(session, input_selector, label, opts) do
fun = &Locator.uncheck/2
input(session, input_selector, label, opts, fun)
end

def choose(session, input_selector, label, opts) do
fun = &Locator.check/2
input(session, input_selector, label, opts, fun)
end

def upload(session, input_selector, label, paths, opts) do
fun = &Locator.set_input_files(&1, List.wrap(paths), &2)
input(session, input_selector, label, opts, fun)
end

defp input(session, input_selector, label, opts, fun) do
session
|> maybe_within()
|> L.concat(L.css(input_selector))
|> L.and(L.label(label, opts))
|> fun.(%{timeout: timeout(opts)})
|> handle_result(input_selector, label)

session
end

defp maybe_within(session) do
case session.within do
:none -> Locator.new(session.page, "*")
selector -> Page.locator(session.page, "css=#{selector}")
end
end

defp handle_result(result, selector, label \\ nil) do
case result do
list when is_list(list) ->
{:ok, list}

:ok ->
result

{:ok, _} ->
result

{:error, %{type: "Error", message: "Error: strict mode violation" <> _}} ->
raise(ArgumentError, "Found more than one element with selector #{inspect(selector)}")

{:error, %{type: "TimeoutError"}} ->
msg =
case label do
nil -> "Could not find element with selector #{inspect(selector)}."
_ -> "Could not find element with label #{inspect(label)}."
end

raise(ArgumentError, msg)

{:error, %{type: "Error", message: "Clicking the checkbox did not change its state"}} ->
:ok
end
end

def submit(session) do
Page.Keyboard.down(session.page, "Enter")
session
end

def open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do
html =
session.page
|> Page.content()
|> 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, html)
open_fun.(path)

session
end

def unwrap(session, fun) do
fun.(session.page)
session
end

def current_path(session) do
url = Page.url(session.page)
uri = URI.parse(url)
[uri.path, uri.query] |> Enum.reject(&is_nil/1) |> Enum.join("?")
end

defp timeout(opts \\ []) do
default = Application.get_env(:phoenix_test, :timeout, @default_timeout)
Keyword.get(opts, :timeout, default)
end
end

defimpl PhoenixTest.Driver, for: PhoenixTest.Playwright do
alias PhoenixTest.Assertions
alias PhoenixTest.Playwright

defdelegate render_page_title(session), to: Playwright
defdelegate render_html(session), to: Playwright
defdelegate click_link(session, selector, text), to: Playwright
defdelegate click_button(session, selector, text), to: Playwright
defdelegate within(session, selector, fun), to: Playwright
defdelegate fill_in(session, input_selector, label, opts), to: Playwright
defdelegate select(session, input_selector, option, opts), to: Playwright
defdelegate check(session, input_selector, label, opts), to: Playwright
defdelegate uncheck(session, input_selector, label, opts), to: Playwright
defdelegate choose(session, input_selector, label, opts), to: Playwright
defdelegate upload(session, input_selector, 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
defdelegate assert_path(session, path), to: Assertions
defdelegate assert_path(session, path, opts), to: Assertions
defdelegate refute_path(session, path), to: Assertions
defdelegate refute_path(session, path, opts), to: Assertions
end
end
31 changes: 31 additions & 0 deletions lib/phoenix_test/playwright_locator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
defmodule PhoenixTest.Playwright.Locator do
@moduledoc false

def concat(locator, :none), do: locator
def concat(locator, string), do: Playwright.Locator.locator(locator, string)

def unquote(:and)(locator, :none), do: locator

def unquote(:and)(locator, string) do
and_string = "internal:and=#{Jason.encode!(string)}"
Playwright.Locator.locator(locator, and_string)
end

def text(nil, _opts), do: :none
def text(text, opts), do: "internal:text=\"#{text}\"#{exact_suffix(opts)}"

def label(nil, _opts), do: :none
def label(label, opts), do: "internal:label=\"#{label}\"#{exact_suffix(opts)}"

def at(nil), do: :none
def at(at), do: "nth=#{at + 1}"

def css(nil), do: :none
def css([]), do: :none
def css(selector) when is_binary(selector), do: css([selector])
def css(selectors) when is_list(selectors), do: "css=#{Enum.join(selectors, ",")}"

defp exact_suffix(opts) when is_list(opts), do: opts |> Keyword.get(:exact, false) |> exact_suffix()
defp exact_suffix(true), do: "s"
defp exact_suffix(false), do: "i"
end
6 changes: 4 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule PhoenixTest.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:esbuild, "~> 0.8", only: :test, runtime: false},
{:esbuild, "~> 0.8", runtime: false},
{:ex_doc, "~> 0.31", only: :dev, runtime: false},
{:floki, ">= 0.30.0"},
{:jason, "~> 1.4"},
Expand All @@ -51,7 +51,9 @@ defmodule PhoenixTest.MixProject do
{:phoenix, "~> 1.7.10"},
{:phoenix_live_view, "~> 0.20.1"},
{:plug_cowboy, "~> 2.7", only: :test, runtime: false},
{:styler, "~> 0.11", only: [:dev, :test], runtime: false}
{:styler, "~> 0.11", only: [:dev, :test], runtime: false},
{:cowlib, "~> 2.13.0", override: true},
{:playwright, github: "ftes/playwright-elixir", ref: "phoenix-test", optional: true}
]
end

Expand Down
Loading

0 comments on commit 2b617c1

Please sign in to comment.