|
| 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 |
0 commit comments