Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
- name: Install chromium browser
run: npm exec --prefix priv/static/assets -- playwright install chromium --with-deps --only-shell
- name: Run tests
run: "mix test --warnings-as-errors --max-cases 1 || if [[ $? = 2 ]]; then PW_TRACE=true mix test --max-cases 1 --failed; else false; fi"
run: "mix test --warnings-as-errors || if [[ $? = 2 ]]; then PW_TRACE=true mix test --failed; else false; fi"
- name: Fail if screenshot on exit failed
run: |
if ! ls screenshots/PhoenixTest.Playwright.CaseTest.test__tag__screenshot_saves_screenshot_on_test_exit* >/dev/null 2>&1; then
Expand Down
138 changes: 138 additions & 0 deletions lib/phoenix_test/playwright/browser_pool.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule PhoenixTest.Playwright.BrowserPool do
@moduledoc """
Experimental browser pooling. Reuses browsers across test suites.
This limits memory usage and is useful when running feature tests together with regular tests
(high ExUnit `max_cases` concurrency such as the default: 2x number of CPU cores).

Pools are defined up front.
Browsers are launched lazily.

Usage:

```ex
# test/test_helper.exs
{:ok, _} =
Supervisor.start_link(
[
{PhoenixTest.Playwright.BrowserPool, name: :normal_chromium, size: System.schedulers_online(), browser: :chromium},
{PhoenixTest.Playwright.BrowserPool, name: :slow_chromium, size: 4, browser: :chromium, slow_mo: 100},
],
strategy: :one_for_one
)

# configure pool per test module
# test/my_test.exs
defmodule PhoenixTest.PlaywrightBrowserPoolTest do
use PhoenixTest.Playwright.Case,
async: true,
browser_pool: :normal_chromium
end

# or configure globally
# test/test.exs
config :phoenix_test,
playwright: [
browser_pool: :normal_chromium,
browser_pool_checkout_timeout: to_timeout(minute: 10)
]
```
"""

use GenServer

alias __MODULE__, as: State
alias PhoenixTest.Playwright

defstruct [
:size,
:config,
available: [],
in_use: %{},
waiting: []
]

@type pool :: GenServer.server()
@type browser_id :: binary()

## Public

@spec checkout(pool()) :: browser_id()
def checkout(pool) do
timeout = Playwright.Config.global(:browser_pool_checkout_timeout)
GenServer.call(pool, :checkout, timeout)
end

## Internal

@doc false
def start_link(opts) do
{name, opts} = Keyword.pop!(opts, :name)
{size, opts} = Keyword.pop!(opts, :size)

GenServer.start_link(__MODULE__, %State{size: size, config: opts}, name: name)
end

@impl GenServer
def init(state) do
# Trap exits so we can clean up browsers on shutdown
Process.flag(:trap_exit, true)
{:ok, state}
end

@impl GenServer
def handle_call(:checkout, from, state) do
cond do
length(state.available) > 0 ->
browser_id = hd(state.available)
state = do_checkout(state, from, browser_id)
{:reply, browser_id, state}

map_size(state.in_use) < state.size ->
browser_id = launch(state.config)
state = do_checkout(state, from, browser_id)
{:reply, browser_id, state}

true ->
state = Map.update!(state, :waiting, &(&1 ++ [from]))
{:noreply, state}
end
end

@impl GenServer
def handle_info({:DOWN, ref, :process, pid, _reason}, state) do
case Enum.find_value(state.in_use, fn {browser_id, tracked} -> tracked == {pid, ref} and browser_id end) do
nil -> {:noreply, state}
browser_id -> {:noreply, do_checkin(state, browser_id)}
end
end

defp launch(config) do
config = config |> Playwright.Config.validate!() |> Keyword.take(Playwright.Config.setup_all_keys())

{type, config} = Keyword.pop!(config, :browser)
Playwright.Connection.launch_browser(type, config)
end

defp do_checkout(state, from, browser_id) do
{from_pid, _tag} = from

state
|> Map.update!(:available, &(&1 -- [browser_id]))
|> Map.update!(:in_use, &Map.put(&1, browser_id, {from_pid, Process.monitor(from_pid)}))
end

defp do_checkin(state, browser_id) do
{{_from_pid, ref}, in_use} = Map.pop(state.in_use, browser_id)
Process.demonitor(ref, [:flush])
state = %{state | in_use: in_use, available: [browser_id | state.available]}

case state.waiting do
[from | rest] ->
GenServer.reply(from, browser_id)
%{do_checkout(state, from, browser_id) | waiting: rest}

_ ->
state
end
end
end
7 changes: 6 additions & 1 deletion lib/phoenix_test/playwright/case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ defmodule PhoenixTest.Playwright.Case do
def do_setup_all(context) do
keys = Playwright.Config.setup_all_keys()
config = context |> Map.take(keys) |> Playwright.Config.validate!() |> Keyword.take(keys)
[browser_id: launch_browser(config)]

if pool = config[:browser_pool] do
[browser_id: Playwright.BrowserPool.checkout(pool)]
else
[browser_id: launch_browser(config)]
end
end

@doc """
Expand Down
12 changes: 11 additions & 1 deletion lib/phoenix_test/playwright/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ schema =
default: to_timeout(second: 2),
type: :non_neg_integer
],
browser_pool_checkout_timeout: [
default: to_timeout(minute: 1),
type: :non_neg_integer
],
browser_pool: [
default: nil,
type: :any,
doc:
"Reuse a browser from this pool instead of launching a new browser per test suite. See `PhoenixTest.Playwright.BrowserPool`."
],
slow_mo: [
default: to_timeout(second: 0),
type: :non_neg_integer
Expand Down Expand Up @@ -108,7 +118,7 @@ schema =
]
)

setup_all_keys = ~w(browser browser_launch_timeout executable_path headless slow_mo)a
setup_all_keys = ~w(browser_pool browser browser_launch_timeout executable_path headless slow_mo)a
setup_keys = ~w(accept_dialogs screenshot trace browser_context_opts browser_page_opts)a

defmodule PhoenixTest.Playwright.Config do
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ defmodule PhoenixTestPlaywright.MixProject do
"credo",
"compile --warnings-as-errors",
"assets.build",
"test --warnings-as-errors --max-cases 1"
"test --warnings-as-errors"
]
]
end
Expand Down
12 changes: 12 additions & 0 deletions test/phoenix_test/playwright_browser_pool_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule PhoenixTest.PlaywrightBrowserPoolTest do
use PhoenixTest.Playwright.Case,
async: true,
browser_pool: :chromium,
parameterize: Enum.map(1..100, &%{index: &1})

test "navigates to page", %{conn: conn} do
conn
|> visit("/page/index")
|> assert_has("h1", text: "Main page")
end
end
8 changes: 7 additions & 1 deletion test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
ExUnit.start(capture_log: true)

{:ok, _} =
Supervisor.start_link([{Phoenix.PubSub, name: PhoenixTest.PubSub}], strategy: :one_for_one)
Supervisor.start_link(
[
{Phoenix.PubSub, name: PhoenixTest.PubSub},
{PhoenixTest.Playwright.BrowserPool, name: :chromium, size: System.schedulers_online(), browser: :chromium}
],
strategy: :one_for_one
)

{:ok, _} = PhoenixTest.Endpoint.start_link()

Expand Down