diff --git a/CHANGELOG.md b/CHANGELOG.md index aa38099..9896923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `PlaywrightEx.Frame.wait_for_selector/2`: `state` and `strict` options. #22, [@oliver-kriska] - `PlaywrightEx.unsubscribe/2` and connection-level unsubscribe support. - `PlaywrightEx.Frame.wait_for_load_state/2` and `PlaywrightEx.Frame.wait_for_url/2` with event-based navigation waiting. -- Per-frame event recorder process to keep waiter subscriptions continuous across waits. +- Per-frame resource process to keep navigation state and waiter subscriptions continuous across waits. - `PlaywrightEx.Page.expect_url/2` for explicit URL expectations on pages. - Regex support in argument serialization/deserialization using protocol-native `{r: %{p, f}}` values. - `PlaywrightEx.Page.reload/2` to reload current page. diff --git a/lib/playwright_ex/channels/frame.ex b/lib/playwright_ex/channels/frame.ex index cdfbabd..ab90dc0 100644 --- a/lib/playwright_ex/channels/frame.ex +++ b/lib/playwright_ex/channels/frame.ex @@ -10,7 +10,7 @@ defmodule PlaywrightEx.Frame do alias PlaywrightEx.ChannelResponse alias PlaywrightEx.Connection - alias PlaywrightEx.FrameEventRecorder + alias PlaywrightEx.Resource.Frame, as: FrameResource alias PlaywrightEx.Serialization schema = @@ -1244,7 +1244,7 @@ defmodule PlaywrightEx.Frame do {timeout, opts} = Keyword.pop!(opts, :timeout) wait_state = opts |> Keyword.fetch!(:state) |> normalize_wait_state() - FrameEventRecorder.wait_for_load_state(connection, frame_id, wait_state, timeout) + FrameResource.await_load_state(connection, frame_id, wait_state, timeout) end schema = @@ -1291,7 +1291,7 @@ defmodule PlaywrightEx.Frame do wait_state = opts |> Keyword.fetch!(:wait_until) |> normalize_wait_state() url_matcher = opts |> Keyword.fetch!(:url) |> build_url_matcher() - FrameEventRecorder.wait_for_url(connection, frame_id, url_matcher, wait_state, timeout) + FrameResource.await_url(connection, frame_id, url_matcher, wait_state, timeout) end defp normalize_wait_state(state) when is_atom(state), do: normalize_wait_state(Atom.to_string(state)) diff --git a/lib/playwright_ex/processes/connection.ex b/lib/playwright_ex/processes/connection.ex index d9177a4..c80d9ff 100644 --- a/lib/playwright_ex/processes/connection.ex +++ b/lib/playwright_ex/processes/connection.ex @@ -11,7 +11,7 @@ defmodule PlaywrightEx.Connection do import Kernel, except: [send: 2] - alias PlaywrightEx.FrameEventRecorder + alias PlaywrightEx.Resource @timeout_grace_factor 1.5 @min_genserver_timeout to_timeout(second: 1) @@ -41,6 +41,11 @@ defmodule PlaywrightEx.Connection do :gen_statem.cast(name, {:subscribe, pid, guid}) end + @doc false + def subscribe_sync(name, pid \\ self(), guid) do + :gen_statem.call(name, {:subscribe, pid, guid}) + end + @doc """ Unsubscribe from messages for a guid. """ @@ -48,6 +53,11 @@ defmodule PlaywrightEx.Connection do :gen_statem.cast(name, {:unsubscribe, pid, guid}) end + @doc false + def unsubscribe_sync(name, pid \\ self(), guid) do + :gen_statem.call(name, {:unsubscribe, pid, guid}) + end + @doc false def handle_playwright_msg(name, msg) do :gen_statem.cast(name, {:playwright_msg, msg}) @@ -83,6 +93,11 @@ defmodule PlaywrightEx.Connection do :gen_statem.call(name, :remote?) end + @doc false + def pg_scope(name) do + :gen_statem.call(name, :pg_scope) + end + # Internal @impl :gen_statem @@ -108,7 +123,7 @@ defmodule PlaywrightEx.Connection do @doc false def pending(:cast, {:playwright_msg, %{method: :__create__, params: %{guid: "Playwright"}} = msg}, data) do - {:next_state, :started, handle_create(data, msg)} + {:next_state, :started, cache_initializer(data, msg)} end def pending(:cast, _msg, _data), do: {:keep_state_and_data, [:postpone]} @@ -129,20 +144,27 @@ defmodule PlaywrightEx.Connection do {:keep_state_and_data, [{:reply, from, transport_module != PlaywrightEx.PortTransport}]} end - def started(:cast, {:subscribe, recipient, guid}, data) do - group = pg_group(guid) + def started({:call, from}, :pg_scope, data) do + {:keep_state_and_data, [{:reply, from, data.config.pg_scope}]} + end - if recipient in :pg.get_members(data.config.pg_scope, group) do - :ok - else - :ok = :pg.join(data.config.pg_scope, group, recipient) - end + def started({:call, from}, {:subscribe, recipient, guid}, data) do + join_subscriber(data, recipient, guid) + {:keep_state_and_data, [{:reply, from, :ok}]} + end + def started({:call, from}, {:unsubscribe, recipient, guid}, data) do + leave_subscriber(data, recipient, guid) + {:keep_state_and_data, [{:reply, from, :ok}]} + end + + def started(:cast, {:subscribe, recipient, guid}, data) do + join_subscriber(data, recipient, guid) :keep_state_and_data end def started(:cast, {:unsubscribe, recipient, guid}, data) do - _ = :pg.leave(data.config.pg_scope, pg_group(guid), recipient) + leave_subscriber(data, recipient, guid) :keep_state_and_data end @@ -151,7 +173,7 @@ defmodule PlaywrightEx.Connection do module.log(:error, msg.params.error, msg) end - :keep_state_and_data + {:keep_state, notify_subscribers(data, msg)} end def started(:cast, {:playwright_msg, %{method: :console} = msg}, data) do @@ -160,7 +182,7 @@ defmodule PlaywrightEx.Connection do module.log(level, msg.params.text, msg) end - :keep_state_and_data + {:keep_state, notify_subscribers(data, msg)} end def started(:cast, {:playwright_msg, msg}, data) when is_map_key(data.pending_response, msg.id) do @@ -171,38 +193,29 @@ defmodule PlaywrightEx.Connection do end def started(:cast, {:playwright_msg, msg}, data) do - {:keep_state, - data |> handle_create(msg) |> maybe_start_frame_event_recorder(msg) |> notify_subscribers(msg) |> handle_dispose(msg)} - end + data = cache_initializer(data, msg) + resource_context = %{connection: data.config.name, pg_scope: data.config.pg_scope} + Enum.each(Resource.modules(), & &1.maybe_start(resource_context, msg)) + data = notify_subscribers(data, msg) - defp handle_create(data, %{method: :__create__} = msg) do - put_in(data.initializers[msg.params.guid], msg.params.initializer) + {:keep_state, release_disposed_guid(data, msg)} end - defp handle_create(data, _msg), do: data - - defp maybe_start_frame_event_recorder(data, %{ - method: :__create__, - params: %{guid: guid, initializer: %{url: _url, load_states: _load_states} = initializer} - }) do - _ = FrameEventRecorder.ensure_started(data.config.name, guid, initializer) - data + defp cache_initializer(data, %{method: :__create__} = msg) do + put_in(data.initializers[msg.params.guid], msg.params.initializer) end - defp maybe_start_frame_event_recorder(data, %{method: :__create__}) do - data - end + defp cache_initializer(data, _msg), do: data - defp maybe_start_frame_event_recorder(data, _msg), do: data + defp release_disposed_guid(data, %{method: :__dispose__} = msg) do + Enum.each(Resource.modules(), & &1.maybe_stop(data.config.name, msg.guid)) - defp handle_dispose(data, %{method: :__dispose__} = msg) do data |> Map.update!(:initializers, &Map.delete(&1, msg.guid)) - |> stop_disposed_frame_event_recorder(msg.guid) |> clear_disposed_guid_subscribers(msg.guid) end - defp handle_dispose(data, _msg), do: data + defp release_disposed_guid(data, _msg), do: data defp notify_subscribers(data, %{guid: guid} = msg) do for pid <- :pg.get_members(data.config.pg_scope, pg_group(guid)) do @@ -226,9 +239,18 @@ defmodule PlaywrightEx.Connection do data end - defp stop_disposed_frame_event_recorder(data, guid) do - _ = FrameEventRecorder.terminate_frame(data.config.name, guid) - data + defp join_subscriber(data, recipient, guid) do + group = pg_group(guid) + + if recipient in :pg.get_members(data.config.pg_scope, group) do + :ok + else + :ok = :pg.join(data.config.pg_scope, group, recipient) + end + end + + defp leave_subscriber(data, recipient, guid) do + _ = :pg.leave(data.config.pg_scope, pg_group(guid), recipient) end defp log_level_from_js("error"), do: :error diff --git a/lib/playwright_ex/resource.ex b/lib/playwright_ex/resource.ex new file mode 100644 index 0000000..211fdcb --- /dev/null +++ b/lib/playwright_ex/resource.ex @@ -0,0 +1,130 @@ +defmodule PlaywrightEx.Resource do + @moduledoc false + + alias PlaywrightEx.Resource.Frame + alias PlaywrightEx.Resource.Page + + @type resource_module :: module() + + @spec modules() :: [resource_module()] + def modules do + [Page, Frame] + end + + @spec children(atom()) :: [Supervisor.child_spec()] + def children(connection) do + Enum.flat_map(modules(), fn module -> + [ + {Registry, keys: :unique, name: module.registry_name(connection)}, + {DynamicSupervisor, strategy: :one_for_one, name: module.supervisor_name(connection)} + ] + end) + end + + @spec ensure_started(resource_module(), atom(), PlaywrightEx.guid(), map() | nil, map()) :: + {:ok, pid()} | {:error, map()} + def ensure_started(module, connection, resource_id, initializer \\ nil, extra_opts \\ %{}) do + case lookup(module, connection, resource_id) do + {:ok, pid} -> + {:ok, pid} + + :not_found -> + start_child(module, connection, resource_id, initializer, extra_opts) + end + end + + @spec maybe_stop(resource_module(), atom(), PlaywrightEx.guid()) :: :ok + def maybe_stop(module, connection, resource_id) do + case lookup(module, connection, resource_id) do + {:ok, pid} -> Process.exit(pid, :normal) + :not_found -> :ok + end + + :ok + end + + @spec info(resource_module(), atom(), PlaywrightEx.guid()) :: map() + def info(module, connection, resource_id) do + call(module, connection, resource_id, :info) + end + + @spec events(resource_module(), atom(), PlaywrightEx.guid(), pos_integer()) :: [map()] + def events(module, connection, resource_id, limit) do + call(module, connection, resource_id, {:events, limit}) + end + + @spec child_resources(resource_module(), atom(), PlaywrightEx.guid(), atom() | :all) :: map() | [map()] + def child_resources(module, connection, resource_id, type) do + call(module, connection, resource_id, {:child_resources, type}) + end + + @spec await(resource_module(), atom(), PlaywrightEx.guid(), term(), timeout()) :: any() + def await(module, connection, resource_id, request, timeout) do + with {:ok, pid} <- ensure_started(module, connection, resource_id) do + module.call_resource(pid, request, timeout) + end + end + + @spec registry_name(resource_module(), atom()) :: atom() + def registry_name(module, connection) do + Module.concat(connection, "#{resource_name(module)}Registry") + end + + @spec supervisor_name(resource_module(), atom()) :: atom() + def supervisor_name(module, connection) do + Module.concat(connection, "#{resource_name(module)}Supervisor") + end + + @spec lookup(resource_module(), atom(), PlaywrightEx.guid()) :: {:ok, pid()} | :not_found + def lookup(module, connection, resource_id) do + registry = registry_name(module, connection) + + case Registry.lookup(registry, resource_id) do + [{pid, _value}] -> {:ok, pid} + [] -> :not_found + end + rescue + ArgumentError -> :not_found + end + + defp call(module, connection, resource_id, request) do + with {:ok, pid} <- ensure_started(module, connection, resource_id) do + GenServer.call(pid, request) + end + end + + defp start_child(module, connection, resource_id, initializer, extra_opts) do + child_opts = + %{connection: connection, resource_id: resource_id} + |> maybe_put_initializer(initializer) + |> Map.merge(Map.take(extra_opts, [:pg_scope])) + + child_spec = {module, child_opts} + + case DynamicSupervisor.start_child(supervisor_name(module, connection), child_spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, reason} -> {:error, %{message: "Failed to start #{resource_label(module)} resource: #{inspect(reason)}"}} + end + catch + :exit, reason -> + {:error, %{message: "Failed to start #{resource_label(module)} resource: #{Exception.format_exit(reason)}"}} + end + + defp maybe_put_initializer(opts, initializer) when is_map(initializer), do: Map.put(opts, :initializer, initializer) + defp maybe_put_initializer(opts, _initializer), do: opts + + defp resource_label(module) do + module + |> Module.split() + |> List.last() + |> Macro.underscore() + end + + defp resource_name(module) do + module + |> Module.split() + |> Enum.drop_while(&(&1 != "Resource")) + |> Enum.join() + end +end diff --git a/lib/playwright_ex/resource/behaviour.ex b/lib/playwright_ex/resource/behaviour.ex new file mode 100644 index 0000000..a57db76 --- /dev/null +++ b/lib/playwright_ex/resource/behaviour.ex @@ -0,0 +1,6 @@ +defmodule PlaywrightEx.Resource.Behaviour do + @moduledoc false + + @callback maybe_start(map(), map()) :: :ok + @callback call_resource(pid(), term(), timeout()) :: any() +end diff --git a/lib/playwright_ex/processes/frame_event_recorder.ex b/lib/playwright_ex/resource/frame.ex similarity index 54% rename from lib/playwright_ex/processes/frame_event_recorder.ex rename to lib/playwright_ex/resource/frame.ex index b9a83ff..e466aab 100644 --- a/lib/playwright_ex/processes/frame_event_recorder.ex +++ b/lib/playwright_ex/resource/frame.ex @@ -1,115 +1,179 @@ -defmodule PlaywrightEx.FrameEventRecorder do +defmodule PlaywrightEx.Resource.Frame do @moduledoc false + @behaviour PlaywrightEx.Resource.Behaviour + use GenServer - alias PlaywrightEx.Connection - alias PlaywrightEx.FrameWaiter + alias PlaywrightEx.Resource.Frame.Waiter @waiter_grace_ms 100 + @max_recent_events 50 @frame_detached_error "Navigating frame was detached!" @page_closed_error "Navigation failed because page was closed!" @page_crashed_error "Navigation failed because page crashed!" defstruct connection: nil, - frame_id: nil, + pg_scope: nil, + resource_id: nil, page_id: nil, + status: :open, url: "", load_states: MapSet.new(), - waiters: %{} + waiters: %{}, + recent_events: [], + child_resources: %{} @typep wait_state :: String.t() @typep url_matcher :: (String.t() -> boolean()) - @typep initializer :: map() - @spec wait_for_load_state(atom(), PlaywrightEx.guid(), wait_state(), timeout()) :: - {:ok, nil} | {:error, map()} - def wait_for_load_state(connection, frame_id, wait_state, timeout) do - with {:ok, pid} <- ensure_started(connection, frame_id) do - call_waiter(pid, {:wait_for_load_state, wait_state, timeout}, timeout) - end + @spec ensure_started(atom(), PlaywrightEx.guid(), map() | nil) :: {:ok, pid()} | {:error, map()} + def ensure_started(connection, frame_id, initializer \\ nil) do + PlaywrightEx.Resource.ensure_started(__MODULE__, connection, frame_id, initializer) end - @spec wait_for_url(atom(), PlaywrightEx.guid(), url_matcher(), wait_state(), timeout()) :: - {:ok, nil} | {:error, map()} - def wait_for_url(connection, frame_id, url_matcher, wait_state, timeout) do - with {:ok, pid} <- ensure_started(connection, frame_id) do - call_waiter(pid, {:wait_for_url, url_matcher, wait_state, timeout}, timeout) - end + @spec maybe_stop(atom(), PlaywrightEx.guid()) :: :ok + def maybe_stop(connection, frame_id) do + PlaywrightEx.Resource.maybe_stop(__MODULE__, connection, frame_id) end - @spec ensure_started(atom(), PlaywrightEx.guid(), initializer() | nil) :: {:ok, pid()} | {:error, map()} - def ensure_started(connection, frame_id, initializer \\ nil) do - case lookup(connection, frame_id) do - {:ok, pid} -> - {:ok, pid} + @spec info(atom(), PlaywrightEx.guid()) :: map() + def info(connection, frame_id) do + PlaywrightEx.Resource.info(__MODULE__, connection, frame_id) + end - :not_found -> - start_recorder(connection, frame_id, initializer) - end + @spec events(atom(), PlaywrightEx.guid(), pos_integer()) :: [map()] + def events(connection, frame_id, limit \\ 50) do + PlaywrightEx.Resource.events(__MODULE__, connection, frame_id, limit) + end + + @spec child_resources(atom(), PlaywrightEx.guid(), atom() | :all) :: map() | [map()] + def child_resources(connection, frame_id, type \\ :all) do + PlaywrightEx.Resource.child_resources(__MODULE__, connection, frame_id, type) end @spec registry_name(atom()) :: atom() - def registry_name(connection), do: Module.concat(connection, "FrameEventRecorderRegistry") + def registry_name(connection), do: PlaywrightEx.Resource.registry_name(__MODULE__, connection) @spec supervisor_name(atom()) :: atom() - def supervisor_name(connection), do: Module.concat(connection, "FrameEventRecorderSupervisor") + def supervisor_name(connection), do: PlaywrightEx.Resource.supervisor_name(__MODULE__, connection) - def child_spec(%{connection: connection, frame_id: frame_id} = opts) do + def child_spec(%{connection: connection, resource_id: resource_id} = opts) do %{ - id: {__MODULE__, {connection, frame_id}}, + id: {__MODULE__, {connection, resource_id}}, start: {__MODULE__, :start_link, [opts]}, restart: :temporary } end @doc false - def start_link(%{connection: connection, frame_id: frame_id} = opts) do - GenServer.start_link(__MODULE__, opts, name: via(connection, frame_id)) + def start_link(%{connection: connection, resource_id: resource_id} = opts) do + GenServer.start_link(__MODULE__, opts, name: via(connection, resource_id)) + end + + @spec await_load_state(atom(), PlaywrightEx.guid(), wait_state(), timeout()) :: + {:ok, nil} | {:error, map()} + def await_load_state(connection, frame_id, wait_state, timeout) do + PlaywrightEx.Resource.await(__MODULE__, connection, frame_id, {:await_load_state, wait_state, timeout}, timeout) + end + + @spec await_url(atom(), PlaywrightEx.guid(), url_matcher(), wait_state(), timeout()) :: + {:ok, nil} | {:error, map()} + def await_url(connection, frame_id, url_matcher, wait_state, timeout) do + PlaywrightEx.Resource.await(__MODULE__, connection, frame_id, {:await_url, url_matcher, wait_state, timeout}, timeout) + end + + @spec maybe_start(map(), map()) :: :ok + @impl true + def maybe_start(%{connection: connection, pg_scope: pg_scope}, %{ + method: :__create__, + params: %{guid: guid, initializer: %{url: _url, load_states: _load_states} = initializer} + }) do + _ = PlaywrightEx.Resource.ensure_started(__MODULE__, connection, guid, initializer, %{pg_scope: pg_scope}) + :ok end + def maybe_start(_connection_context, _msg), do: :ok + @impl true - def init(%{connection: connection, frame_id: frame_id} = opts) do - frame_initializer = Map.get(opts, :initializer) || Connection.initializer!(connection, frame_id) + def init(%{connection: connection, resource_id: frame_id} = opts) do + frame_initializer = Map.get(opts, :initializer) || PlaywrightEx.Connection.initializer!(connection, frame_id) page_id = extract_page_id(frame_initializer) + pg_scope = Map.get(opts, :pg_scope) || PlaywrightEx.Connection.pg_scope(connection) - Connection.subscribe(connection, self(), frame_id) - maybe_subscribe_page(connection, page_id) + :ok = :pg.join(pg_scope, {:guid, frame_id}, self()) + :ok = :pg.join(pg_scope, {:guid, page_id}, self()) state = %__MODULE__{ connection: connection, - frame_id: frame_id, + pg_scope: pg_scope, + resource_id: frame_id, page_id: page_id, url: frame_initializer[:url] || "", - load_states: FrameWaiter.normalize_load_states(frame_initializer[:load_states]) + load_states: Waiter.normalize_load_states(frame_initializer[:load_states]), + recent_events: [%{method: :__create__, params: %{guid: frame_id, initializer: frame_initializer}}] } {:ok, state} end @impl true - def handle_call({:wait_for_load_state, wait_state, timeout}, from, state) do - add_waiter(state, from, FrameWaiter.new_load_state_waiter(wait_state), timeout) + def terminate(_reason, state) do + _ = :pg.leave(state.pg_scope, {:guid, state.resource_id}, self()) + _ = :pg.leave(state.pg_scope, {:guid, state.page_id}, self()) + :ok + end + + @impl true + def handle_call(:info, _from, state) do + {:reply, public_info(state), state} + end + + def handle_call({:events, limit}, _from, state) do + {:reply, state.recent_events |> Enum.take(limit) |> Enum.reverse(), state} + end + + def handle_call({:child_resources, :all}, _from, state) do + {:reply, %{}, state} + end + + def handle_call({:child_resources, _type}, _from, state) do + {:reply, [], state} end - def handle_call({:wait_for_url, url_matcher, wait_state, timeout}, from, state) do - add_waiter(state, from, FrameWaiter.new_url_waiter(url_matcher, wait_state), timeout) + def handle_call({:await_load_state, wait_state, timeout}, from, state) do + add_waiter(state, from, Waiter.new_load_state_waiter(wait_state), timeout) + end + + def handle_call({:await_url, url_matcher, wait_state, timeout}, from, state) do + add_waiter(state, from, Waiter.new_url_waiter(url_matcher, wait_state), timeout) end @impl true - def handle_info({:playwright_msg, %{guid: frame_id, method: :loadstate, params: params}}, %{frame_id: frame_id} = state) do - state = %{state | load_states: FrameWaiter.update_load_states(state.load_states, params)} + def handle_info( + {:playwright_msg, %{guid: frame_id, method: :loadstate, params: params} = event}, + %{resource_id: frame_id} = state + ) do + state = + state + |> Map.update!(:load_states, &Waiter.update_load_states(&1, params)) + |> record_event(event) + {:noreply, process_waiters(state)} end def handle_info( - {:playwright_msg, %{guid: frame_id, method: :navigated, params: %{error: error}}}, - %{frame_id: frame_id} = state + {:playwright_msg, %{guid: frame_id, method: :navigated, params: %{error: error}} = event}, + %{resource_id: frame_id} = state ) when is_binary(error) do + state = record_event(state, event) {:noreply, fail_waiters(state, &url_waiter?/1, {:error, %{message: error}})} end - def handle_info({:playwright_msg, %{guid: frame_id, method: :navigated, params: params}}, %{frame_id: frame_id} = state) do + def handle_info( + {:playwright_msg, %{guid: frame_id, method: :navigated, params: params} = event}, + %{resource_id: frame_id} = state + ) do load_states = if Map.has_key?(params, :new_document) do MapSet.new(["commit"]) @@ -117,12 +181,21 @@ defmodule PlaywrightEx.FrameEventRecorder do state.load_states end - state = %{state | url: params.url || state.url, load_states: load_states} + state = + state + |> Map.merge(%{url: params.url || state.url, load_states: load_states}) + |> record_event(event) + {:noreply, process_waiters(state)} end - def handle_info({:playwright_msg, %{guid: frame_id, method: :__dispose__}}, %{frame_id: frame_id} = state) do - state = fail_waiters(state, fn _waiter -> true end, {:error, %{message: @frame_detached_error}}) + def handle_info({:playwright_msg, %{guid: frame_id, method: :__dispose__} = event}, %{resource_id: frame_id} = state) do + state = + state + |> Map.put(:status, :disposed) + |> record_event(event) + |> fail_waiters(fn _waiter -> true end, {:error, %{message: @frame_detached_error}}) + {:stop, {:shutdown, :frame_detached}, state} end @@ -154,42 +227,6 @@ defmodule PlaywrightEx.FrameEventRecorder do def handle_info(_msg, state), do: {:noreply, state} - defp lookup(connection, frame_id) do - registry = registry_name(connection) - - case Registry.lookup(registry, frame_id) do - [{pid, _value}] -> {:ok, pid} - [] -> :not_found - end - rescue - ArgumentError -> :not_found - end - - @spec terminate_frame(atom(), PlaywrightEx.guid()) :: :ok - def terminate_frame(connection, frame_id) do - case lookup(connection, frame_id) do - {:ok, pid} -> Process.exit(pid, :normal) - :not_found -> :ok - end - - :ok - end - - defp start_recorder(connection, frame_id, initializer) do - child_opts = maybe_put_initializer(%{connection: connection, frame_id: frame_id}, initializer) - - child_spec = {__MODULE__, child_opts} - - case DynamicSupervisor.start_child(supervisor_name(connection), child_spec) do - {:ok, pid} -> {:ok, pid} - {:error, {:already_started, pid}} -> {:ok, pid} - {:error, reason} -> {:error, %{message: "Failed to start frame event recorder: #{inspect(reason)}"}} - end - catch - :exit, reason -> - {:error, %{message: "Failed to start frame event recorder: #{Exception.format_exit(reason)}"}} - end - defp call_waiter(pid, request, timeout) do GenServer.call(pid, request, timeout + @waiter_grace_ms) catch @@ -223,8 +260,14 @@ defmodule PlaywrightEx.FrameEventRecorder do defp classify_call_waiter_exit_reason({:noproc, _}), do: {:noproc, @page_closed_error} defp classify_call_waiter_exit_reason(reason), do: {nil, Exception.format_exit(reason)} + @doc false + @impl true + def call_resource(pid, request, timeout) do + call_waiter(pid, request, timeout) + end + defp add_waiter(state, from, waiter, timeout) do - case FrameWaiter.evaluate(waiter, %{url: state.url, load_states: state.load_states}) do + case Waiter.evaluate(waiter, %{url: state.url, load_states: state.load_states}) do {:done, reply} -> {:reply, reply, state} @@ -244,7 +287,7 @@ defmodule PlaywrightEx.FrameEventRecorder do {waiters, replies} = Enum.reduce(state.waiters, {%{}, []}, fn {waiter_ref, waiter_entry}, {acc_waiters, acc_replies} -> - case FrameWaiter.evaluate(waiter_entry.waiter, frame_state) do + case Waiter.evaluate(waiter_entry.waiter, frame_state) do {:done, reply} -> {acc_waiters, [{waiter_entry, reply} | acc_replies]} @@ -276,26 +319,35 @@ defmodule PlaywrightEx.FrameEventRecorder do %{state | waiters: Map.new(waiters)} end - defp maybe_put_initializer(opts, initializer) when is_map(initializer), do: Map.put(opts, :initializer, initializer) - defp maybe_put_initializer(opts, _initializer), do: opts - defp extract_page_id(%{page: %{guid: guid}}) when is_binary(guid), do: guid defp extract_page_id(%{page: guid}) when is_binary(guid), do: guid defp extract_page_id(_initializer), do: nil - defp maybe_subscribe_page(_connection, nil), do: :ok - - defp maybe_subscribe_page(connection, page_id) do - Connection.subscribe(connection, self(), page_id) - end - defp url_waiter?({:url, _url_matcher, _wait_state, _phase}), do: true defp url_waiter?(_waiter), do: false - defp via(connection, frame_id), do: {:via, Registry, {registry_name(connection), frame_id}} - defp cancel_timer(nil), do: :ok defp cancel_timer(timer_ref), do: _ = Process.cancel_timer(timer_ref, async: true, info: false) defp timeout_error(timeout), do: {:error, %{message: "Timeout #{timeout}ms exceeded."}} + + defp public_info(state) do + %{ + id: state.resource_id, + status: state.status, + page_id: state.page_id, + url: state.url, + load_states: state.load_states |> MapSet.to_list() |> Enum.sort(), + child_resources: %{}, + recent_events_count: length(state.recent_events) + } + end + + defp record_event(state, event) do + %{state | recent_events: [event | Enum.take(state.recent_events, @max_recent_events - 1)]} + end + + defp via(connection, resource_id) do + {:via, Registry, {registry_name(connection), resource_id}} + end end diff --git a/lib/playwright_ex/processes/frame_waiter.ex b/lib/playwright_ex/resource/frame/waiter.ex similarity index 98% rename from lib/playwright_ex/processes/frame_waiter.ex rename to lib/playwright_ex/resource/frame/waiter.ex index c7283c4..2615735 100644 --- a/lib/playwright_ex/processes/frame_waiter.ex +++ b/lib/playwright_ex/resource/frame/waiter.ex @@ -1,4 +1,4 @@ -defmodule PlaywrightEx.FrameWaiter do +defmodule PlaywrightEx.Resource.Frame.Waiter do @moduledoc false @type url_matcher :: (String.t() -> boolean()) diff --git a/lib/playwright_ex/resource/page.ex b/lib/playwright_ex/resource/page.ex new file mode 100644 index 0000000..31cb20c --- /dev/null +++ b/lib/playwright_ex/resource/page.ex @@ -0,0 +1,322 @@ +defmodule PlaywrightEx.Resource.Page do + @moduledoc false + @behaviour PlaywrightEx.Resource.Behaviour + + use GenServer + + alias PlaywrightEx.Connection + + @waiter_grace_ms 100 + @max_recent_events 50 + @page_closed_error "Navigation failed because page was closed!" + @page_crashed_error "Navigation failed because page crashed!" + + defstruct connection: nil, + pg_scope: nil, + resource_id: nil, + main_frame_id: nil, + status: :open, + recent_events: [], + child_resources: %{}, + event_waiters: %{} + + @typep event_matcher :: (map() -> boolean()) + + @spec ensure_started(atom(), PlaywrightEx.guid(), map() | nil) :: {:ok, pid()} | {:error, map()} + def ensure_started(connection, page_id, initializer \\ nil) do + PlaywrightEx.Resource.ensure_started(__MODULE__, connection, page_id, initializer) + end + + @spec maybe_stop(atom(), PlaywrightEx.guid()) :: :ok + def maybe_stop(connection, page_id) do + PlaywrightEx.Resource.maybe_stop(__MODULE__, connection, page_id) + end + + @spec info(atom(), PlaywrightEx.guid()) :: map() + def info(connection, page_id) do + PlaywrightEx.Resource.info(__MODULE__, connection, page_id) + end + + @spec events(atom(), PlaywrightEx.guid(), pos_integer()) :: [map()] + def events(connection, page_id, limit \\ 50) do + PlaywrightEx.Resource.events(__MODULE__, connection, page_id, limit) + end + + @spec child_resources(atom(), PlaywrightEx.guid(), atom() | :all) :: map() | [map()] + def child_resources(connection, page_id, type \\ :all) do + PlaywrightEx.Resource.child_resources(__MODULE__, connection, page_id, type) + end + + @spec registry_name(atom()) :: atom() + def registry_name(connection), do: PlaywrightEx.Resource.registry_name(__MODULE__, connection) + + @spec supervisor_name(atom()) :: atom() + def supervisor_name(connection), do: PlaywrightEx.Resource.supervisor_name(__MODULE__, connection) + + def child_spec(%{connection: connection, resource_id: resource_id} = opts) do + %{ + id: {__MODULE__, {connection, resource_id}}, + start: {__MODULE__, :start_link, [opts]}, + restart: :temporary + } + end + + @doc false + def start_link(%{connection: connection, resource_id: resource_id} = opts) do + GenServer.start_link(__MODULE__, opts, name: via(connection, resource_id)) + end + + @spec await_event(atom(), PlaywrightEx.guid(), event_matcher(), timeout()) :: + {:ok, map()} | {:error, map()} + def await_event(connection, page_id, matcher, timeout) do + PlaywrightEx.Resource.await(__MODULE__, connection, page_id, {:await_event, matcher, timeout}, timeout) + end + + @spec await_child_resource(atom(), PlaywrightEx.guid(), atom(), timeout()) :: + {:ok, map()} | {:error, map()} + def await_child_resource(connection, page_id, type, timeout) when is_atom(type) do + matcher = fn event -> + match?(%{method: :__create__, params: %{guid: _, type: ^type}}, normalize_create_type(event)) + end + + with {:ok, event} <- await_event(connection, page_id, matcher, timeout) do + {:ok, child_resource_from_event(event)} + end + end + + @spec maybe_start(map(), map()) :: :ok + @impl true + def maybe_start(_connection_context, _msg), do: :ok + + @doc false + @impl true + def call_resource(pid, request, timeout) do + GenServer.call(pid, request, timeout + @waiter_grace_ms) + catch + :exit, {:timeout, _} -> + {:error, %{message: "Timeout #{timeout}ms exceeded."}} + + :exit, reason -> + classify_call_exit(reason) + end + + @impl true + def init(%{connection: connection, resource_id: page_id} = opts) do + page_initializer = Map.get(opts, :initializer) || Connection.initializer!(connection, page_id) + main_frame_id = extract_main_frame_id(page_initializer) + pg_scope = Map.get(opts, :pg_scope) || Connection.pg_scope(connection) + + :ok = :pg.join(pg_scope, {:guid, page_id}, self()) + + state = + record_event( + %__MODULE__{ + connection: connection, + pg_scope: pg_scope, + resource_id: page_id, + main_frame_id: main_frame_id, + child_resources: put_child_resource(%{}, :frame, %{guid: main_frame_id, initializer: %{}}) + }, + %{method: :__create__, params: %{guid: page_id, initializer: page_initializer}} + ) + + {:ok, state} + end + + @impl true + def terminate(_reason, state) do + _ = :pg.leave(state.pg_scope, {:guid, state.resource_id}, self()) + :ok + end + + @impl true + def handle_call(:info, _from, state) do + {:reply, public_info(state), state} + end + + def handle_call({:events, limit}, _from, state) do + {:reply, state.recent_events |> Enum.take(limit) |> Enum.reverse(), state} + end + + def handle_call({:child_resources, :all}, _from, state) do + {:reply, public_child_resources(state.child_resources), state} + end + + def handle_call({:child_resources, type}, _from, state) when is_atom(type) do + {:reply, public_child_resources(state.child_resources, type), state} + end + + def handle_call({:await_event, matcher, timeout}, from, state) do + case Enum.find(Enum.reverse(state.recent_events), &safe_match?(matcher, &1)) do + nil -> + waiter_ref = make_ref() + timer_ref = Process.send_after(self(), {:event_waiter_timeout, waiter_ref, timeout}, timeout) + waiter = %{matcher: matcher, from: from, timer_ref: timer_ref} + {:noreply, put_in(state.event_waiters[waiter_ref], waiter)} + + event -> + {:reply, {:ok, event}, state} + end + end + + @impl true + def handle_info({:playwright_msg, %{guid: page_id} = event}, %{resource_id: page_id} = state) do + event = normalize_create_type(event) + + state = + state + |> update_status(event) + |> maybe_track_child_resource(event) + |> record_event(event) + |> process_event_waiters(event) + + case state.status do + :closed -> + state = fail_event_waiters(state, {:error, %{message: @page_closed_error}}) + {:stop, {:shutdown, :page_closed}, state} + + :crashed -> + state = fail_event_waiters(state, {:error, %{message: @page_crashed_error}}) + {:stop, {:shutdown, :page_crashed}, state} + + :disposed -> + state = fail_event_waiters(state, {:error, %{message: @page_closed_error}}) + {:stop, {:shutdown, :page_closed}, state} + + _ -> + {:noreply, state} + end + end + + def handle_info({:event_waiter_timeout, waiter_ref, timeout}, state) do + case Map.pop(state.event_waiters, waiter_ref) do + {nil, _waiters} -> + {:noreply, state} + + {waiter, waiters} -> + GenServer.reply(waiter.from, {:error, %{message: "Timeout #{timeout}ms exceeded."}}) + {:noreply, %{state | event_waiters: waiters}} + end + end + + def handle_info(_msg, state), do: {:noreply, state} + + defp classify_call_exit({:shutdown, :page_closed}), do: {:error, %{message: @page_closed_error, reason: :page_closed}} + + defp classify_call_exit({{:shutdown, :page_closed}, _}), + do: {:error, %{message: @page_closed_error, reason: :page_closed}} + + defp classify_call_exit({:shutdown, :page_crashed}), + do: {:error, %{message: @page_crashed_error, reason: :page_crashed}} + + defp classify_call_exit({{:shutdown, :page_crashed}, _}), + do: {:error, %{message: @page_crashed_error, reason: :page_crashed}} + + defp classify_call_exit(:normal), do: {:error, %{message: @page_closed_error, reason: :page_closed}} + defp classify_call_exit({:shutdown, :normal}), do: {:error, %{message: @page_closed_error, reason: :page_closed}} + defp classify_call_exit({{:shutdown, :normal}, _}), do: {:error, %{message: @page_closed_error, reason: :page_closed}} + defp classify_call_exit({:noproc, _}), do: {:error, %{message: @page_closed_error, reason: :page_closed}} + defp classify_call_exit(reason), do: {:error, %{message: Exception.format_exit(reason)}} + + defp public_info(state) do + %{ + id: state.resource_id, + status: state.status, + main_frame_id: state.main_frame_id, + child_resources: public_child_resources(state.child_resources), + recent_events_count: length(state.recent_events) + } + end + + defp public_child_resources(child_resources) do + Map.new(child_resources, fn {type, resources} -> {type, Enum.reverse(resources)} end) + end + + defp public_child_resources(child_resources, type) do + child_resources |> Map.get(type, []) |> Enum.reverse() + end + + defp normalize_create_type(%{method: :__create__, params: %{type: type}} = event) when is_binary(type) do + put_in(event, [:params, :type], type |> Macro.underscore() |> String.to_atom()) + end + + defp normalize_create_type(event), do: event + + defp update_status(state, %{method: :close}), do: %{state | status: :closed} + defp update_status(state, %{method: :crash}), do: %{state | status: :crashed} + defp update_status(state, %{method: :__dispose__}), do: %{state | status: :disposed} + defp update_status(state, _event), do: state + + defp maybe_track_child_resource(state, %{method: :__create__, params: %{guid: guid, type: type} = params}) + when is_atom(type) and is_binary(guid) do + child = %{guid: guid, initializer: Map.get(params, :initializer, %{})} + %{state | child_resources: put_child_resource(state.child_resources, type, child)} + end + + defp maybe_track_child_resource(state, _event), do: state + + defp record_event(state, event) do + %{state | recent_events: [event | Enum.take(state.recent_events, @max_recent_events - 1)]} + end + + defp put_child_resource(child_resources, _type, %{guid: nil}), do: child_resources + + defp put_child_resource(child_resources, type, child) do + Map.update(child_resources, type, [child], fn children -> + if Enum.any?(children, &(&1.guid == child.guid)) do + children + else + [child | children] + end + end) + end + + defp process_event_waiters(state, event) do + {waiters, replies} = + Enum.reduce(state.event_waiters, {%{}, []}, fn {waiter_ref, waiter}, {acc_waiters, acc_replies} -> + if safe_match?(waiter.matcher, event) do + {acc_waiters, [{waiter, {:ok, event}} | acc_replies]} + else + {Map.put(acc_waiters, waiter_ref, waiter), acc_replies} + end + end) + + Enum.each(replies, fn {waiter, reply} -> + cancel_timer(waiter.timer_ref) + GenServer.reply(waiter.from, reply) + end) + + %{state | event_waiters: waiters} + end + + defp fail_event_waiters(state, reply) do + Enum.each(state.event_waiters, fn {_ref, waiter} -> + cancel_timer(waiter.timer_ref) + GenServer.reply(waiter.from, reply) + end) + + %{state | event_waiters: %{}} + end + + defp safe_match?(matcher, event) do + matcher.(event) + rescue + _error -> false + catch + _kind, _reason -> false + end + + defp child_resource_from_event(%{params: %{guid: guid, type: type} = params}) do + %{guid: guid, type: type, initializer: Map.get(params, :initializer, %{})} + end + + defp extract_main_frame_id(%{main_frame: %{guid: guid}}) when is_binary(guid), do: guid + defp extract_main_frame_id(%{main_frame: guid}) when is_binary(guid), do: guid + defp extract_main_frame_id(_initializer), do: nil + + defp cancel_timer(timer_ref), do: _ = Process.cancel_timer(timer_ref, async: true, info: false) + + defp via(connection, resource_id) do + {:via, Registry, {registry_name(connection), resource_id}} + end +end diff --git a/lib/playwright_ex/supervisor.ex b/lib/playwright_ex/supervisor.ex index a7393ca..8b771ef 100644 --- a/lib/playwright_ex/supervisor.ex +++ b/lib/playwright_ex/supervisor.ex @@ -24,8 +24,8 @@ defmodule PlaywrightEx.Supervisor do use Supervisor alias PlaywrightEx.Connection - alias PlaywrightEx.FrameEventRecorder alias PlaywrightEx.PortTransport + alias PlaywrightEx.Resource alias PlaywrightEx.WebSocketTransport @doc """ @@ -53,27 +53,27 @@ defmodule PlaywrightEx.Supervisor do def init(config) do connection_name = connection_name(config.name) pg_scope = pg_scope_name(config.name) - frame_event_recorder_registry = FrameEventRecorder.registry_name(connection_name) - frame_event_recorder_supervisor = FrameEventRecorder.supervisor_name(connection_name) {transport_child, transport} = transport_child_spec(config, connection_name) pg_child = %{id: pg_scope, start: {:pg, :start_link, [pg_scope]}} - children = [ - transport_child, - pg_child, - {Registry, keys: :unique, name: frame_event_recorder_registry}, - {DynamicSupervisor, strategy: :one_for_one, name: frame_event_recorder_supervisor}, - {Connection, - [ - [ - name: connection_name, - timeout: config.timeout, - js_logger: config.js_logger, - transport: transport, - pg_scope: pg_scope - ] - ]} - ] + children = + [ + transport_child, + pg_child + | Resource.children(connection_name) + ] ++ + [ + {Connection, + [ + [ + name: connection_name, + timeout: config.timeout, + js_logger: config.js_logger, + transport: transport, + pg_scope: pg_scope + ] + ]} + ] Supervisor.init(children, strategy: :rest_for_one) end diff --git a/test/playwright_ex/processes/frame_waiter_test.exs b/test/playwright_ex/processes/frame_waiter_test.exs deleted file mode 100644 index 3fadc0a..0000000 --- a/test/playwright_ex/processes/frame_waiter_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule PlaywrightEx.FrameWaiterTest do - use ExUnit.Case, async: true - - alias PlaywrightEx.FrameWaiter - - test "evaluate load-state waiter completes when state reached" do - waiter = FrameWaiter.new_load_state_waiter("load") - frame_state = %{url: "about:blank", load_states: MapSet.new(["load"])} - - assert {:done, {:ok, nil}} = FrameWaiter.evaluate(waiter, frame_state) - end - - test "evaluate url waiter transitions and then completes on load-state" do - waiter = FrameWaiter.new_url_waiter(&(&1 == "about:blank#ok"), "load") - - assert {:update, waiter} = - FrameWaiter.evaluate(waiter, %{url: "about:blank", load_states: MapSet.new(["commit"])}) - - assert {:update, waiter} = - FrameWaiter.evaluate(waiter, %{url: "about:blank#ok", load_states: MapSet.new(["commit"])}) - - assert {:done, {:ok, nil}} = - FrameWaiter.evaluate(waiter, %{url: "about:blank#ok", load_states: MapSet.new(["load"])}) - end - - test "evaluate returns error when url matcher raises" do - waiter = FrameWaiter.new_url_waiter(fn _url -> raise "boom" end, "load") - frame_state = %{url: "about:blank", load_states: MapSet.new(["commit"])} - - assert {:error, {:error, %{message: "boom"}}} = FrameWaiter.evaluate(waiter, frame_state) - end - - test "update_load_states adds and removes states without aliases" do - load_states = - MapSet.new() - |> FrameWaiter.update_load_states(%{add: "networkidle"}) - |> FrameWaiter.update_load_states(%{add: :load}) - |> FrameWaiter.update_load_states(%{remove: "networkidle"}) - - assert MapSet.member?(load_states, "load") - refute MapSet.member?(load_states, "networkidle") - end -end diff --git a/test/playwright_ex/resource/frame/waiter_test.exs b/test/playwright_ex/resource/frame/waiter_test.exs new file mode 100644 index 0000000..235ece3 --- /dev/null +++ b/test/playwright_ex/resource/frame/waiter_test.exs @@ -0,0 +1,43 @@ +defmodule PlaywrightEx.Resource.Frame.WaiterTest do + use ExUnit.Case, async: true + + alias PlaywrightEx.Resource.Frame.Waiter + + test "evaluate load-state waiter completes when state reached" do + waiter = Waiter.new_load_state_waiter("load") + frame_state = %{url: "about:blank", load_states: MapSet.new(["load"])} + + assert {:done, {:ok, nil}} = Waiter.evaluate(waiter, frame_state) + end + + test "evaluate url waiter transitions and then completes on load-state" do + waiter = Waiter.new_url_waiter(&(&1 == "about:blank#ok"), "load") + + assert {:update, waiter} = + Waiter.evaluate(waiter, %{url: "about:blank", load_states: MapSet.new(["commit"])}) + + assert {:update, waiter} = + Waiter.evaluate(waiter, %{url: "about:blank#ok", load_states: MapSet.new(["commit"])}) + + assert {:done, {:ok, nil}} = + Waiter.evaluate(waiter, %{url: "about:blank#ok", load_states: MapSet.new(["load"])}) + end + + test "evaluate returns error when url matcher raises" do + waiter = Waiter.new_url_waiter(fn _url -> raise "boom" end, "load") + frame_state = %{url: "about:blank", load_states: MapSet.new(["commit"])} + + assert {:error, {:error, %{message: "boom"}}} = Waiter.evaluate(waiter, frame_state) + end + + test "update_load_states adds and removes states without aliases" do + load_states = + MapSet.new() + |> Waiter.update_load_states(%{add: "networkidle"}) + |> Waiter.update_load_states(%{add: :load}) + |> Waiter.update_load_states(%{remove: "networkidle"}) + + assert MapSet.member?(load_states, "load") + refute MapSet.member?(load_states, "networkidle") + end +end diff --git a/test/playwright_ex/processes/frame_event_recorder_test.exs b/test/playwright_ex/resource/frame_test.exs similarity index 61% rename from test/playwright_ex/processes/frame_event_recorder_test.exs rename to test/playwright_ex/resource/frame_test.exs index 6eb86de..8a19b3e 100644 --- a/test/playwright_ex/processes/frame_event_recorder_test.exs +++ b/test/playwright_ex/resource/frame_test.exs @@ -1,8 +1,9 @@ -defmodule PlaywrightEx.FrameEventRecorderTest do +defmodule PlaywrightEx.Resource.FrameTest do use ExUnit.Case, async: true alias PlaywrightEx.Connection - alias PlaywrightEx.FrameEventRecorder + alias PlaywrightEx.Resource.Frame + alias PlaywrightEx.Resource.Page defmodule DummyTransport do @moduledoc false @@ -12,27 +13,27 @@ defmodule PlaywrightEx.FrameEventRecorderTest do def post(_name, _msg), do: :ok end - test "starts one recorder per {connection, frame}" do + test "starts one resource per {connection, frame}" do %{connection: connection, frame_id: frame_id} = start_connection_with_frame!() assert_eventually(fn -> match?( [{_pid, _}], - Registry.lookup(FrameEventRecorder.registry_name(connection), frame_id) + Registry.lookup(Frame.registry_name(connection), frame_id) ) end) - assert {:ok, pid1} = FrameEventRecorder.ensure_started(connection, frame_id) - assert {:ok, pid2} = FrameEventRecorder.ensure_started(connection, frame_id) + assert {:ok, pid1} = Frame.ensure_started(connection, frame_id) + assert {:ok, pid2} = Frame.ensure_started(connection, frame_id) assert pid1 == pid2 end - test "wait_for_url waits for navigated + loadstate events" do + test "await_url waits for navigated + loadstate events" do %{connection: connection, frame_id: frame_id} = start_connection_with_frame!() task = Task.async(fn -> - FrameEventRecorder.wait_for_url( + Frame.await_url( connection, frame_id, &(&1 == "about:blank#done"), @@ -50,15 +51,15 @@ defmodule PlaywrightEx.FrameEventRecorderTest do test "waiters fail when frame is disposed" do %{connection: connection, frame_id: frame_id} = start_connection_with_frame!() - recorder = recorder_pid!(connection, frame_id) + frame_resource = frame_resource_pid!(connection, frame_id) task = Task.async(fn -> - FrameEventRecorder.wait_for_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) + Frame.await_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) end) assert_eventually(fn -> - map_size(:sys.get_state(recorder).waiters) == 1 + map_size(:sys.get_state(frame_resource).waiters) == 1 end) Connection.handle_playwright_msg(connection, %{method: :__dispose__, guid: frame_id}) @@ -68,15 +69,15 @@ defmodule PlaywrightEx.FrameEventRecorderTest do test "waiters fail fast when page crashes" do %{connection: connection, frame_id: frame_id, page_id: page_id} = start_connection_with_frame!() - recorder = recorder_pid!(connection, frame_id) + frame_resource = frame_resource_pid!(connection, frame_id) task = Task.async(fn -> - FrameEventRecorder.wait_for_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) + Frame.await_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) end) assert_eventually(fn -> - map_size(:sys.get_state(recorder).waiters) == 1 + map_size(:sys.get_state(frame_resource).waiters) == 1 end) Connection.handle_playwright_msg(connection, %{guid: page_id, method: :crash, params: %{}}) @@ -86,15 +87,15 @@ defmodule PlaywrightEx.FrameEventRecorderTest do test "waiters fail fast when page is closed" do %{connection: connection, frame_id: frame_id, page_id: page_id} = start_connection_with_frame!() - recorder = recorder_pid!(connection, frame_id) + frame_resource = frame_resource_pid!(connection, frame_id) task = Task.async(fn -> - FrameEventRecorder.wait_for_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) + Frame.await_url(connection, frame_id, &(&1 == "about:blank#never"), "load", 500) end) assert_eventually(fn -> - map_size(:sys.get_state(recorder).waiters) == 1 + map_size(:sys.get_state(frame_resource).waiters) == 1 end) Connection.handle_playwright_msg(connection, %{method: :__dispose__, guid: page_id}) @@ -102,20 +103,37 @@ defmodule PlaywrightEx.FrameEventRecorderTest do assert {:error, %{message: "Navigation failed because page was closed!"}} = Task.await(task, 1_000) end + test "info and events expose generic resource state" do + %{connection: connection, frame_id: frame_id} = start_connection_with_frame!() + + Connection.handle_playwright_msg(connection, %{ + guid: frame_id, + method: :navigated, + params: %{url: "about:blank#event"} + }) + + assert_eventually(fn -> + match?( + %{id: ^frame_id, status: :open, page_id: "page-1", url: "about:blank#event"}, + Frame.info(connection, frame_id) + ) + end) + + assert [%{method: :__create__}, %{method: :navigated}] = Frame.events(connection, frame_id, 10) + assert %{} = Frame.child_resources(connection, frame_id) + end + defp start_connection_with_frame! do - connection = String.to_atom("recorder_connection_#{System.unique_integer([:positive])}") - scope = String.to_atom("recorder_scope_#{System.unique_integer([:positive])}") + connection = String.to_atom("frame_resource_connection_#{System.unique_integer([:positive])}") + scope = String.to_atom("frame_resource_scope_#{System.unique_integer([:positive])}") frame_id = "frame-1" page_id = "page-1" {:ok, _} = :pg.start_link(scope) - {:ok, _} = Registry.start_link(keys: :unique, name: FrameEventRecorder.registry_name(connection)) - - {:ok, _} = - DynamicSupervisor.start_link( - strategy: :one_for_one, - name: FrameEventRecorder.supervisor_name(connection) - ) + {:ok, _} = Registry.start_link(keys: :unique, name: Page.registry_name(connection)) + {:ok, _} = DynamicSupervisor.start_link(strategy: :one_for_one, name: Page.supervisor_name(connection)) + {:ok, _} = Registry.start_link(keys: :unique, name: Frame.registry_name(connection)) + {:ok, _} = DynamicSupervisor.start_link(strategy: :one_for_one, name: Frame.supervisor_name(connection)) {:ok, _pid} = Connection.start_link( @@ -161,8 +179,8 @@ defmodule PlaywrightEx.FrameEventRecorderTest do :exit, _reason -> :error end - defp recorder_pid!(connection, frame_id) do - [{pid, _}] = Registry.lookup(FrameEventRecorder.registry_name(connection), frame_id) + defp frame_resource_pid!(connection, frame_id) do + [{pid, _}] = Registry.lookup(Frame.registry_name(connection), frame_id) pid end diff --git a/test/playwright_ex/resource/page_test.exs b/test/playwright_ex/resource/page_test.exs new file mode 100644 index 0000000..c11fc7c --- /dev/null +++ b/test/playwright_ex/resource/page_test.exs @@ -0,0 +1,159 @@ +defmodule PlaywrightEx.Resource.PageTest do + use ExUnit.Case, async: true + + alias PlaywrightEx.Connection + alias PlaywrightEx.Resource.Page + + defmodule DummyTransport do + @moduledoc false + @behaviour PlaywrightEx.Transport + + @impl PlaywrightEx.Transport + def post(_name, _msg), do: :ok + end + + test "starts one resource per {connection, page}" do + %{connection: connection, page_id: page_id} = start_connection_with_page!() + + assert {:ok, pid1} = Page.ensure_started(connection, page_id) + assert {:ok, pid2} = Page.ensure_started(connection, page_id) + assert pid1 == pid2 + end + + test "tracks child resources and exposes generic info" do + %{connection: connection, page_id: page_id, frame_id: frame_id} = start_connection_with_page!() + + Connection.handle_playwright_msg(connection, %{ + guid: page_id, + method: :__create__, + params: %{guid: "dialog-1", type: "Dialog", initializer: %{message: "Are you sure?"}} + }) + + assert_eventually(fn -> + match?( + %{ + id: ^page_id, + status: :open, + main_frame_id: ^frame_id, + recent_events_count: 2, + child_resources: %{frame: [%{guid: ^frame_id}], dialog: [%{guid: "dialog-1"}]} + }, + Page.info(connection, page_id) + ) + end) + + assert_eventually(fn -> + match?( + [%{guid: "dialog-1", initializer: %{message: "Are you sure?"}}], + Page.child_resources(connection, page_id, :dialog) + ) + end) + end + + test "await_event resolves matching page event" do + %{connection: connection, page_id: page_id} = start_connection_with_page!() + + task = + Task.async(fn -> + Page.await_event(connection, page_id, &match?(%{method: :crash}, &1), 500) + end) + + Connection.handle_playwright_msg(connection, %{guid: page_id, method: :crash, params: %{}}) + + assert {:ok, %{method: :crash, guid: ^page_id}} = Task.await(task, 1_000) + end + + test "await_child_resource resolves created dialog" do + %{connection: connection, page_id: page_id} = start_connection_with_page!() + + task = + Task.async(fn -> + Page.await_child_resource(connection, page_id, :dialog, 500) + end) + + Connection.handle_playwright_msg(connection, %{ + guid: page_id, + method: :__create__, + params: %{guid: "dialog-2", type: "Dialog", initializer: %{message: "Proceed?"}} + }) + + assert {:ok, %{guid: "dialog-2", type: :dialog, initializer: %{message: "Proceed?"}}} = + Task.await(task, 1_000) + end + + test "events returns oldest-first recent history" do + %{connection: connection, page_id: page_id} = start_connection_with_page!() + + Connection.handle_playwright_msg(connection, %{guid: page_id, method: :console, params: %{text: "one"}}) + Connection.handle_playwright_msg(connection, %{guid: page_id, method: :console, params: %{text: "two"}}) + + assert_eventually(fn -> + match?( + [ + %{method: :__create__}, + %{method: :console, params: %{text: "one"}}, + %{method: :console, params: %{text: "two"}} + ], + Page.events(connection, page_id, 10) + ) + end) + end + + defp start_connection_with_page! do + connection = String.to_atom("page_resource_connection_#{System.unique_integer([:positive])}") + scope = String.to_atom("page_resource_scope_#{System.unique_integer([:positive])}") + frame_id = "frame-1" + page_id = "page-1" + + {:ok, _} = :pg.start_link(scope) + {:ok, _} = Registry.start_link(keys: :unique, name: Page.registry_name(connection)) + {:ok, _} = DynamicSupervisor.start_link(strategy: :one_for_one, name: Page.supervisor_name(connection)) + + {:ok, _pid} = + Connection.start_link( + name: connection, + timeout: 1_000, + transport: {DummyTransport, :dummy}, + js_logger: nil, + pg_scope: scope + ) + + Connection.handle_playwright_msg(connection, %{method: :__create__, params: %{guid: "Playwright", initializer: %{}}}) + + assert_eventually(fn -> + match?({:started, _}, :sys.get_state(connection)) + end) + + Connection.handle_playwright_msg(connection, %{ + method: :__create__, + params: %{guid: page_id, initializer: %{main_frame: %{guid: frame_id}}} + }) + + assert_eventually(fn -> + case safe_initializer(connection, page_id) do + {:ok, initializer} -> match?({:ok, _pid}, Page.ensure_started(connection, page_id, initializer)) + :error -> false + end + end) + + %{connection: connection, page_id: page_id, frame_id: frame_id} + end + + defp safe_initializer(connection, guid) do + {:ok, Connection.initializer!(connection, guid)} + catch + :exit, _reason -> :error + end + + defp assert_eventually(fun, attempts \\ 20) + defp assert_eventually(fun, attempts) when attempts <= 0, do: assert(fun.()) + + defp assert_eventually(fun, attempts) do + if fun.() do + :ok + else + Process.sleep(10) + assert_eventually(fun, attempts - 1) + end + end +end