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 29, 2024
1 parent 46d30bb commit 842463a
Show file tree
Hide file tree
Showing 28 changed files with 1,195 additions and 288 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
Empty file added diff
Empty file.
6 changes: 5 additions & 1 deletion lib/phoenix_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ defmodule PhoenixTest do
LiveView or a static view. You don't need to worry about which type of page
you're visiting.
"""
def visit(conn, path) do
def visit(%Plug.Conn{} = conn, path) do
case get(conn, path) do
%{assigns: %{live_module: _}} = conn ->
PhoenixTest.Live.build(conn)
Expand All @@ -230,6 +230,10 @@ defmodule PhoenixTest do
end
end

def visit(driver, path) when is_struct(driver) do
Driver.visit(driver, path)
end

defp all_headers(conn) do
Enum.map(conn.req_headers, &elem(&1, 0))
end
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
53 changes: 53 additions & 0 deletions lib/phoenix_test/case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule PhoenixTest.Case do
@moduledoc false

use ExUnit.CaseTemplate

alias PhoenixTest.Case

@playwright_opts %{
browser: :chromium,
headless: true,
slowMo: 0
}

setup_all context do
case context do
%{playwright: opts} ->
opts = Map.merge(@playwright_opts, if(opts == true, do: %{}, else: Map.new(opts)))
browser_id = Case.Playwright.launch_browser(opts)
[playwright: true, browser_id: browser_id]

_ ->
:ok
end
end

setup context do
case context do
%{playwright: false} -> [conn: Phoenix.ConnTest.build_conn()]
%{browser_id: browser_id} -> [conn: Case.Playwright.session(browser_id)]
end
end

defmodule Playwright do
@moduledoc false
import PhoenixTest.Playwright.Connection

def launch_browser(opts) do
ensure_started(opts)
browser_id = launch_browser(opts.browser, opts)
on_exit(fn -> sync_post(guid: browser_id, method: "close") end)
browser_id
end

def session(browser_id) do
context_id = sync_post(guid: browser_id, method: "newContext").result.context.guid
page_id = sync_post(guid: context_id, method: "newPage").result.page.guid
[%{params: %{guid: "frame" <> _ = frame_id}}] = responses(page_id)
on_exit(fn -> post(guid: context_id, method: "close") end)

PhoenixTest.Playwright.build(frame_id)
end
end
end
1 change: 1 addition & 0 deletions lib/phoenix_test/driver.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defprotocol PhoenixTest.Driver do
@moduledoc false
def visit(session, path)
def render_page_title(session)
def render_html(session)
def click_link(session, selector, text)
Expand Down
2 changes: 2 additions & 0 deletions lib/phoenix_test/live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Live do
alias PhoenixTest.Assertions
alias PhoenixTest.Live

def visit(_, _), do: raise(ArgumentError, message: "Unexpected: Call visit/1 with a %Plug.Conn{}.")

defdelegate render_page_title(session), to: Live
defdelegate render_html(session), to: Live
defdelegate click_link(session, selector, text), to: Live
Expand Down
265 changes: 265 additions & 0 deletions lib/phoenix_test/playwright.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
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_input_selector, within: :none]

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

def build(frame_id) do
%__MODULE__{frame_id: frame_id}
end

def visit(session, path) do
base_url = Application.fetch_env!(:phoenix_test, :base_url)
Frame.goto(session.frame_id, base_url <> path)
session
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_or_locator(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_or_locator(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_or_locator(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_or_locator(input_selector))
|> Selector.and(Selector.label(label, opts))

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

%{session | last_input_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_input_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.responses()
|> 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 visit(session, path), to: 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 842463a

Please sign in to comment.