Skip to content

Commit 00e75c6

Browse files
authored
Browser pools: Reuse limited number of browsers (memory ⬇️, speed ⬆️) (#86)
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. Closes #75
1 parent ea5198c commit 00e75c6

File tree

7 files changed

+176
-5
lines changed

7 files changed

+176
-5
lines changed

.github/workflows/elixir.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
- name: Install chromium browser
4343
run: npm exec --prefix priv/static/assets -- playwright install chromium --with-deps --only-shell
4444
- name: Run tests
45-
run: "mix test --warnings-as-errors --max-cases 1 || if [[ $? = 2 ]]; then PW_TRACE=true mix test --max-cases 1 --failed; else false; fi"
45+
run: "mix test --warnings-as-errors || if [[ $? = 2 ]]; then PW_TRACE=true mix test --failed; else false; fi"
4646
- name: Fail if screenshot on exit failed
4747
run: |
4848
if ! ls screenshots/PhoenixTest.Playwright.CaseTest.test__tag__screenshot_saves_screenshot_on_test_exit* >/dev/null 2>&1; then
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
defmodule PhoenixTest.Playwright.BrowserPool do
2+
@moduledoc """
3+
Experimental browser pooling. Reuses browsers across test suites.
4+
This limits memory usage and is useful when running feature tests together with regular tests
5+
(high ExUnit `max_cases` concurrency such as the default: 2x number of CPU cores).
6+
7+
Pools are defined up front.
8+
Browsers are launched lazily.
9+
10+
Usage:
11+
12+
```ex
13+
# test/test_helper.exs
14+
{:ok, _} =
15+
Supervisor.start_link(
16+
[
17+
{PhoenixTest.Playwright.BrowserPool, name: :normal_chromium, size: System.schedulers_online(), browser: :chromium},
18+
{PhoenixTest.Playwright.BrowserPool, name: :slow_chromium, size: 4, browser: :chromium, slow_mo: 100},
19+
],
20+
strategy: :one_for_one
21+
)
22+
23+
# configure pool per test module
24+
# test/my_test.exs
25+
defmodule PhoenixTest.PlaywrightBrowserPoolTest do
26+
use PhoenixTest.Playwright.Case,
27+
async: true,
28+
browser_pool: :normal_chromium
29+
end
30+
31+
# or configure globally
32+
# test/test.exs
33+
config :phoenix_test,
34+
playwright: [
35+
browser_pool: :normal_chromium,
36+
browser_pool_checkout_timeout: to_timeout(minute: 10)
37+
]
38+
```
39+
"""
40+
41+
use GenServer
42+
43+
alias __MODULE__, as: State
44+
alias PhoenixTest.Playwright
45+
46+
defstruct [
47+
:size,
48+
:config,
49+
available: [],
50+
in_use: %{},
51+
waiting: []
52+
]
53+
54+
@type pool :: GenServer.server()
55+
@type browser_id :: binary()
56+
57+
## Public
58+
59+
@spec checkout(pool()) :: browser_id()
60+
def checkout(pool) do
61+
timeout = Playwright.Config.global(:browser_pool_checkout_timeout)
62+
GenServer.call(pool, :checkout, timeout)
63+
end
64+
65+
## Internal
66+
67+
@doc false
68+
def start_link(opts) do
69+
{name, opts} = Keyword.pop!(opts, :name)
70+
{size, opts} = Keyword.pop!(opts, :size)
71+
72+
GenServer.start_link(__MODULE__, %State{size: size, config: opts}, name: name)
73+
end
74+
75+
@impl GenServer
76+
def init(state) do
77+
# Trap exits so we can clean up browsers on shutdown
78+
Process.flag(:trap_exit, true)
79+
{:ok, state}
80+
end
81+
82+
@impl GenServer
83+
def handle_call(:checkout, from, state) do
84+
cond do
85+
length(state.available) > 0 ->
86+
browser_id = hd(state.available)
87+
state = do_checkout(state, from, browser_id)
88+
{:reply, browser_id, state}
89+
90+
map_size(state.in_use) < state.size ->
91+
browser_id = launch(state.config)
92+
state = do_checkout(state, from, browser_id)
93+
{:reply, browser_id, state}
94+
95+
true ->
96+
state = Map.update!(state, :waiting, &(&1 ++ [from]))
97+
{:noreply, state}
98+
end
99+
end
100+
101+
@impl GenServer
102+
def handle_info({:DOWN, ref, :process, pid, _reason}, state) do
103+
case Enum.find_value(state.in_use, fn {browser_id, tracked} -> tracked == {pid, ref} and browser_id end) do
104+
nil -> {:noreply, state}
105+
browser_id -> {:noreply, do_checkin(state, browser_id)}
106+
end
107+
end
108+
109+
defp launch(config) do
110+
config = config |> Playwright.Config.validate!() |> Keyword.take(Playwright.Config.setup_all_keys())
111+
112+
{type, config} = Keyword.pop!(config, :browser)
113+
Playwright.Connection.launch_browser(type, config)
114+
end
115+
116+
defp do_checkout(state, from, browser_id) do
117+
{from_pid, _tag} = from
118+
119+
state
120+
|> Map.update!(:available, &(&1 -- [browser_id]))
121+
|> Map.update!(:in_use, &Map.put(&1, browser_id, {from_pid, Process.monitor(from_pid)}))
122+
end
123+
124+
defp do_checkin(state, browser_id) do
125+
{{_from_pid, ref}, in_use} = Map.pop(state.in_use, browser_id)
126+
Process.demonitor(ref, [:flush])
127+
state = %{state | in_use: in_use, available: [browser_id | state.available]}
128+
129+
case state.waiting do
130+
[from | rest] ->
131+
GenServer.reply(from, browser_id)
132+
%{do_checkout(state, from, browser_id) | waiting: rest}
133+
134+
_ ->
135+
state
136+
end
137+
end
138+
end

lib/phoenix_test/playwright/case.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ defmodule PhoenixTest.Playwright.Case do
6060
def do_setup_all(context) do
6161
keys = Playwright.Config.setup_all_keys()
6262
config = context |> Map.take(keys) |> Playwright.Config.validate!() |> Keyword.take(keys)
63-
[browser_id: launch_browser(config)]
63+
64+
if pool = config[:browser_pool] do
65+
[browser_id: Playwright.BrowserPool.checkout(pool)]
66+
else
67+
[browser_id: launch_browser(config)]
68+
end
6469
end
6570

6671
@doc """

lib/phoenix_test/playwright/config.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ schema =
6868
default: to_timeout(second: 2),
6969
type: :non_neg_integer
7070
],
71+
browser_pool_checkout_timeout: [
72+
default: to_timeout(minute: 1),
73+
type: :non_neg_integer
74+
],
75+
browser_pool: [
76+
default: nil,
77+
type: :any,
78+
doc:
79+
"Reuse a browser from this pool instead of launching a new browser per test suite. See `PhoenixTest.Playwright.BrowserPool`."
80+
],
7181
slow_mo: [
7282
default: to_timeout(second: 0),
7383
type: :non_neg_integer
@@ -108,7 +118,7 @@ schema =
108118
]
109119
)
110120

111-
setup_all_keys = ~w(browser browser_launch_timeout executable_path headless slow_mo)a
121+
setup_all_keys = ~w(browser_pool browser browser_launch_timeout executable_path headless slow_mo)a
112122
setup_keys = ~w(accept_dialogs screenshot trace browser_context_opts browser_page_opts)a
113123

114124
defmodule PhoenixTest.Playwright.Config do

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ defmodule PhoenixTestPlaywright.MixProject do
9696
"credo",
9797
"compile --warnings-as-errors",
9898
"assets.build",
99-
"test --warnings-as-errors --max-cases 1"
99+
"test --warnings-as-errors"
100100
]
101101
]
102102
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule PhoenixTest.PlaywrightBrowserPoolTest do
2+
use PhoenixTest.Playwright.Case,
3+
async: true,
4+
browser_pool: :chromium,
5+
parameterize: Enum.map(1..100, &%{index: &1})
6+
7+
test "navigates to page", %{conn: conn} do
8+
conn
9+
|> visit("/page/index")
10+
|> assert_has("h1", text: "Main page")
11+
end
12+
end

test/test_helper.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
ExUnit.start(capture_log: true)
22

33
{:ok, _} =
4-
Supervisor.start_link([{Phoenix.PubSub, name: PhoenixTest.PubSub}], strategy: :one_for_one)
4+
Supervisor.start_link(
5+
[
6+
{Phoenix.PubSub, name: PhoenixTest.PubSub},
7+
{PhoenixTest.Playwright.BrowserPool, name: :chromium, size: System.schedulers_online(), browser: :chromium}
8+
],
9+
strategy: :one_for_one
10+
)
511

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

0 commit comments

Comments
 (0)