Skip to content

Commit

Permalink
Playwright driver, no deps
Browse files Browse the repository at this point in the history
  • Loading branch information
ftes committed Oct 28, 2024
1 parent 46d30bb commit fbfd376
Show file tree
Hide file tree
Showing 22 changed files with 1,099 additions and 201 deletions.
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,20 @@ jobs:
- name: Install dependencies
run: mix deps.get

- name: Install JS dependencies
run: npm ci --prefix priv/static/assets

- name: Compiles without warnings
run: mix compile --warnings-as-errors

- name: Check Formatting
run: mix format --check-formatted

- name: Build assets for browser tests
run: mix do assets.setup, assets.build

- name: Install playwright browsers
run: npm exec --prefix priv/static/assets playwright install --with-deps

- name: Run tests
run: mix test
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ phoenix_test-*.tar

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

!/priv/static/assets/app.css
!/priv/static/assets/package.json
!/priv/static/assets/package-lock.json

.envrc
4 changes: 4 additions & 0 deletions lib/phoenix_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ defmodule PhoenixTest do
LiveView or a static view. You don't need to worry about which type of page
you're visiting.
"""
def visit(%{__phoenix_test_driver__: module} = args, path) do
module.build(args, path)
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
62 changes: 62 additions & 0 deletions lib/phoenix_test/case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule PhoenixTest.Case do
@moduledoc false

use ExUnit.CaseTemplate

alias PhoenixTest.Playwright.Connection

@playwright_opts [
browsers: [:chromium],
headless: true,
slowMo: 0
]

using opts do
quote do
if unquote(opts)[:playwright] do
setup_all do
browsers = PhoenixTest.Case.launch_browsers(unquote(opts)[:playwright])
[playwright_browsers: browsers]
end
end
end
end

setup context do
case context do
%{playwright: _} -> [conn: new_playwright_context(context)]
_ -> [conn: Phoenix.ConnTest.build_conn()]
end
end

def launch_browsers(opts) do
opts = Keyword.merge(@playwright_opts, opts)
{types, opts} = Keyword.pop(opts, :browsers, [:chromium])

browsers =
types
|> Task.async_stream(&{&1, Connection.launch_browser(&1, opts)})
|> Map.new(fn {:ok, res} -> res end)

ExUnit.Callbacks.on_exit(fn ->
Enum.each(types, &Connection.sync_post(guid: Map.fetch!(browsers, &1), method: "close"))
end)

browsers
end

defp new_playwright_context(%{playwright: type, playwright_browsers: browsers}) do
browser_id =
case type do
true -> browsers |> Map.values() |> hd()
type -> Map.fetch!(browsers, type)
end

context_id = Connection.sync_post(guid: browser_id, method: "newContext").result.context.guid
page_id = Connection.sync_post(guid: context_id, method: "newPage").result.page.guid
[%{params: %{guid: "frame" <> _ = frame_id}}] = Connection.msg_history(page_id)
ExUnit.Callbacks.on_exit(fn -> Connection.sync_post(guid: context_id, method: "close") end)

%{__phoenix_test_driver__: PhoenixTest.Playwright, frame_id: frame_id}
end
end
261 changes: 261 additions & 0 deletions lib/phoenix_test/playwright.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
defmodule PhoenixTest.Playwright do
@moduledoc false

alias ExUnit.AssertionError
alias PhoenixTest.Assertions
alias PhoenixTest.OpenBrowser
alias PhoenixTest.Playwright.Connection
alias PhoenixTest.Playwright.Frame
alias PhoenixTest.Playwright.Selector

defstruct [:frame_id, :last_selector, within: :none]

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

def build(%{frame_id: frame_id}, path) do
base_url = Application.fetch_env!(:phoenix_test, :base_url)
Frame.goto(frame_id, base_url <> path)

%__MODULE__{frame_id: frame_id}
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, isNot: true) do
raise(AssertionError, Assertions.refute_found_error_msg(selector, opts, []))
end

session
end

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

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

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

{:ok, found?} = Frame.expect(session.frame_id, query)
found?
end

def render_page_title(session) do
{:ok, title} = Frame.title(session.frame_id)
title
end

def render_html(session) do
selector = maybe_within(session)
{:ok, html} = Frame.inner_html(session.frame_id, selector)
html
end

def click_link(session, css_selector, text) do
selector =
session
|> maybe_within()
|> Selector.concat(Selector.css(css_selector))
|> Selector.concat(Selector.text(text, exact: false))

session.frame_id
|> Frame.click(selector, %{timeout: timeout()})
|> handle_response(css_selector)

session
end

def click_button(session, css_selector, text) do
selector =
session
|> maybe_within()
|> Selector.concat(Selector.css(css_selector))
|> Selector.concat(Selector.text(text, exact: false))

session.frame_id
|> Frame.click(selector, %{timeout: timeout()})
|> handle_response(css_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 = &Frame.fill(session.frame_id, &1, to_string(value), &2)
input(session, input_selector, label, opts, fun)
end

def select(session, input_selector, option_labels, opts) do
# TODO Support exact_option
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, input_selector, label, opts, fun)
end

def check(session, input_selector, label, opts) do
fun = &Frame.check(session.frame_id, &1, &2)
input(session, input_selector, label, opts, fun)
end

def uncheck(session, input_selector, label, opts) do
fun = &Frame.uncheck(session.frame_id, &1, &2)
input(session, input_selector, label, opts, fun)
end

def choose(session, input_selector, label, opts) do
fun = &Frame.check(session.frame_id, &1, &2)
input(session, input_selector, label, opts, fun)
end

def upload(session, input_selector, 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, input_selector, label, opts, fun)
end

defp input(session, input_selector, label, opts, fun) do
selector =
session
|> maybe_within()
|> Selector.concat(Selector.css(input_selector))
|> Selector.and(Selector.label(label, opts))

selector
|> fun.(%{timeout: timeout(opts)})
|> handle_response(input_selector, label)

%{session | last_selector: selector}
end

defp maybe_within(session) do
case session.within do
:none -> "*"
selector -> "css=#{selector}"
end
end

defp handle_response(result, selector, label \\ nil) do
case result do
{:error, %{name: "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, %{name: "Error", message: "Error: strict mode violation" <> _}} ->
raise(ArgumentError, "Found more than one element with selector #{inspect(selector)}")

{: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_selector, "Enter")
session
end

def open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do
{: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.(session.frame_id)
session
end

def current_path(session) do
resp =
session.frame_id
|> Connection.msg_history()
|> 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
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
Loading

0 comments on commit fbfd376

Please sign in to comment.