defmodule Cerberus.Driver.Browser.BrowsingContextProcess do @moduledoc false use GenServer alias Cerberus.Driver.Browser.BiDi alias Cerberus.Driver.Browser.Types @default_ready_timeout_ms 1_500 @default_ready_quiet_ms 40 @bidi_events [ "browsingContext.navigationStarted", "browsingContext.domContentLoaded", "browsingContext.load", "browsingContext.downloadWillBegin", "browsingContext.downloadEnd", "browsingContext.userPromptOpened", "browsingContext.userPromptClosed" ] @download_events ["browsingContext.downloadWillBegin", "browsingContext.downloadEnd"] @dialog_events ["browsingContext.userPromptOpened", "browsingContext.userPromptClosed"] @download_history_limit 50 @dialog_history_limit 50 @call_timeout_padding_ms 5_000 @spec child_spec(keyword()) :: Supervisor.child_spec() def child_spec(opts) do %{ id: {__MODULE__, make_ref()}, start: {__MODULE__, :start_link, [opts]}, restart: :temporary, shutdown: 5_000, type: :worker } end @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do GenServer.start_link(__MODULE__, opts) end @spec id(pid()) :: String.t() def id(pid) when is_pid(pid) do GenServer.call(pid, :id) end @spec navigate(pid(), String.t()) :: Types.bidi_response() def navigate(pid, url) when is_pid(pid) and is_binary(url) do GenServer.call(pid, {:navigate, url}, 10_000) end @spec reload(pid()) :: Types.bidi_response() def reload(pid) when is_pid(pid) do GenServer.call(pid, :reload, 10_000) end @spec evaluate(pid(), String.t()) :: Types.bidi_response() def evaluate(pid, expression) when is_pid(pid) and is_binary(expression) do evaluate(pid, expression, 10_000) end @spec evaluate(pid(), String.t(), pos_integer()) :: Types.bidi_response() def evaluate(pid, expression, timeout_ms) when is_pid(pid) and is_binary(expression) and is_integer(timeout_ms) and timeout_ms > 0 do started_us = System.monotonic_time(:microsecond) GenServer.call(pid, {:evaluate, expression, timeout_ms, started_us}, command_call_timeout_ms(timeout_ms)) end @spec await_ready(pid(), keyword()) :: {:ok, Types.readiness_payload()} | {:error, String.t(), Types.readiness_payload()} def await_ready(pid, opts \\ []) when is_pid(pid) and is_list(opts) do timeout_ms = normalize_positive_integer(Keyword.get(opts, :timeout_ms), @default_ready_timeout_ms) GenServer.call(pid, {:await_ready, opts}, timeout_ms + 5_000) end @spec last_readiness(pid()) :: Types.readiness_payload() def last_readiness(pid) when is_pid(pid) do GenServer.call(pid, :last_readiness) end @spec download_events(pid()) :: [Types.payload()] def download_events(pid) when is_pid(pid) do GenServer.call(pid, :download_events) end @spec dialog_events(pid()) :: [Types.payload()] def dialog_events(pid) when is_pid(pid) do GenServer.call(pid, :dialog_events) end @spec active_dialog(pid()) :: Types.payload() | nil def active_dialog(pid) when is_pid(pid) do GenServer.call(pid, :active_dialog) end @spec await_download(pid(), String.t(), pos_integer()) :: {:ok, Types.payload()} | {:error, :timeout, [Types.payload()]} def await_download(pid, expected_filename, timeout_ms) when is_pid(pid) and is_binary(expected_filename) and is_integer(timeout_ms) and timeout_ms > 0 do GenServer.call( pid, {:await_download, expected_filename, timeout_ms}, timeout_ms + @call_timeout_padding_ms ) end @spec await_dialog_open(pid(), pos_integer()) :: {:ok, Types.payload()} | {:error, :timeout, [Types.payload()]} def await_dialog_open(pid, timeout_ms) when is_pid(pid) and is_integer(timeout_ms) and timeout_ms > 0 do GenServer.call(pid, {:await_dialog_open, timeout_ms}, timeout_ms + @call_timeout_padding_ms) end @impl true def init(opts) do user_context_id = Keyword.fetch!(opts, :user_context_id) viewport = Keyword.get(opts, :viewport) context_id = Keyword.get(opts, :context_id) bidi_opts = Keyword.get(opts, :bidi_opts, opts) with {:ok, browsing_context_id} <- resolve_browsing_context_id(user_context_id, context_id, bidi_opts), :ok <- maybe_set_viewport_for_context(context_id, browsing_context_id, viewport, bidi_opts), :ok <- BiDi.subscribe(self(), bidi_opts), {:ok, _} <- BiDi.command("session.subscribe", %{"events" => @bidi_events, "contexts" => [browsing_context_id]}, bidi_opts) do {:ok, %{ id: browsing_context_id, user_context_id: user_context_id, bidi_opts: bidi_opts, last_bidi_event: nil, last_readiness: %{}, download_events: [], dialog_events: [], active_dialog: nil, download_waiters: %{}, dialog_waiters: %{}, pending_evaluations: %{} }} else {:error, reason, details} -> {:stop, {:create_browsing_context_failed, reason, details}} {:error, reason} -> {:stop, {:create_browsing_context_failed, reason, %{}}} end end @impl true def handle_call(:id, _from, state) do {:reply, state.id, state} end def handle_call(:last_readiness, _from, state) do {:reply, state.last_readiness, state} end def handle_call(:download_events, _from, state) do {:reply, state.download_events, state} end def handle_call(:dialog_events, _from, state) do {:reply, state.dialog_events, state} end def handle_call(:active_dialog, _from, state) do {:reply, state.active_dialog, state} end def handle_call({:await_download, expected_filename, timeout_ms}, from, state) do case find_download_event(state.download_events, expected_filename) do %{} = event -> {:reply, {:ok, event}, state} nil -> waiter_id = make_ref() timer = Process.send_after(self(), {:download_waiter_timeout, waiter_id}, timeout_ms) download_waiters = Map.put(state.download_waiters, waiter_id, %{from: from, timer: timer, expected: expected_filename}) {:noreply, %{state | download_waiters: download_waiters}} end end def handle_call({:await_dialog_open, timeout_ms}, from, state) do case state.active_dialog do %{} = dialog -> {:reply, {:ok, dialog}, state} _ -> waiter_id = make_ref() timer = Process.send_after(self(), {:dialog_waiter_timeout, waiter_id}, timeout_ms) dialog_waiters = Map.put(state.dialog_waiters, waiter_id, %{from: from, timer: timer}) {:noreply, %{state | dialog_waiters: dialog_waiters}} end end def handle_call({:navigate, url}, _from, state) do result = BiDi.command( "browsingContext.navigate", %{ "context" => state.id, "url" => url, "wait" => "complete" }, state.bidi_opts ) {:reply, result, state} end def handle_call(:reload, _from, state) do result = BiDi.command( "browsingContext.reload", %{ "context" => state.id, "wait" => "complete" }, state.bidi_opts ) {:reply, result, state} end def handle_call({:await_ready, opts}, _from, state) do timeout_ms = normalize_positive_integer(Keyword.get(opts, :timeout_ms), @default_ready_timeout_ms) quiet_ms = normalize_positive_integer(Keyword.get(opts, :quiet_ms), @default_ready_quiet_ms) case evaluate_readiness(state.id, timeout_ms, quiet_ms, state.bidi_opts) do {:ok, %{"ok" => true} = readiness} -> readiness = Map.put_new(readiness, "lastBidiEvent", state.last_bidi_event) {:reply, {:ok, readiness}, %{state | last_readiness: readiness}} {:ok, readiness} -> readiness = Map.put_new(readiness, "lastBidiEvent", state.last_bidi_event) {:reply, {:error, "browser readiness timeout", readiness}, %{state | last_readiness: readiness}} {:error, reason, details} -> {:reply, {:error, reason, details}, state} end end def handle_call({:evaluate, expression, timeout_ms, started_us}, from, state) do record_transport_delay(:browsing_context_queue, started_us) {:noreply, start_pending_evaluation(state, expression, timeout_ms, from)} end @impl true def handle_info({ref, result}, state) when is_reference(ref) do case Map.pop(state.pending_evaluations, ref) do {nil, _pending_evaluations} -> {:noreply, state} {from, pending_evaluations} -> Process.demonitor(ref, [:flush]) GenServer.reply(from, result) {:noreply, %{state | pending_evaluations: pending_evaluations}} end end def handle_info({:cerberus_bidi_event, %{"method" => method, "params" => params}}, state) when is_binary(method) and is_map(params) do event = if params["context"] == state.id do %{ "method" => method, "context" => params["context"], "url" => params["url"], "navigation" => params["navigation"], "suggestedFilename" => params["suggestedFilename"], "type" => params["type"], "message" => params["message"], "handler" => params["handler"], "accepted" => params["accepted"], "userText" => params["userText"], "status" => params["status"], "timestampMs" => System.monotonic_time(:millisecond) } end cond do is_map(event) and method in @download_events -> state = %{ state | last_bidi_event: event, download_events: push_download_event(state.download_events, event) } {:noreply, resolve_download_waiters(state, event)} is_map(event) and method in @dialog_events -> next_dialog = next_active_dialog(method, event, state.active_dialog) state = %{ state | last_bidi_event: event, dialog_events: push_dialog_event(state.dialog_events, event), active_dialog: next_dialog } {:noreply, resolve_dialog_waiters(state, next_dialog)} is_map(event) -> {:noreply, %{state | last_bidi_event: event}} true -> {:noreply, state} end end def handle_info({:download_waiter_timeout, waiter_id}, state) do case Map.pop(state.download_waiters, waiter_id) do {nil, _waiters} -> {:noreply, state} {waiter, download_waiters} -> GenServer.reply(waiter.from, {:error, :timeout, state.download_events}) {:noreply, %{state | download_waiters: download_waiters}} end end def handle_info({:dialog_waiter_timeout, waiter_id}, state) do case Map.pop(state.dialog_waiters, waiter_id) do {nil, _waiters} -> {:noreply, state} {waiter, dialog_waiters} -> GenServer.reply(waiter.from, {:error, :timeout, state.dialog_events}) {:noreply, %{state | dialog_waiters: dialog_waiters}} end end def handle_info({:DOWN, ref, :process, _pid, reason}, state) do case Map.pop(state.pending_evaluations, ref) do {nil, _pending_evaluations} -> {:noreply, state} {from, pending_evaluations} -> GenServer.reply(from, {:error, "evaluate task crashed", %{reason: Exception.format_exit(reason)}}) {:noreply, %{state | pending_evaluations: pending_evaluations}} end end def handle_info(_message, state) do {:noreply, state} end @impl true def terminate(_reason, state) do _ = BiDi.command("session.unsubscribe", %{"events" => @bidi_events, "contexts" => [state.id]}, state.bidi_opts) _ = BiDi.unsubscribe(self(), state.bidi_opts) _ = BiDi.command("browsingContext.close", %{"context" => state.id}, state.bidi_opts) :ok end defp create_browsing_context(user_context_id, bidi_opts) do create_browsing_context(user_context_id, bidi_opts, 2) end defp resolve_browsing_context_id(_user_context_id, context_id, _bidi_opts) when is_binary(context_id) and context_id != "" do {:ok, context_id} end defp resolve_browsing_context_id(user_context_id, nil, bidi_opts), do: create_browsing_context(user_context_id, bidi_opts) defp resolve_browsing_context_id(_user_context_id, context_id, _bidi_opts) do {:error, "invalid browsing context", %{"context_id" => inspect(context_id)}} end defp maybe_set_viewport_for_context(nil, context_id, viewport, bidi_opts), do: maybe_set_viewport(context_id, viewport, bidi_opts) defp maybe_set_viewport_for_context(_context_id, _resolved_context_id, _viewport, _bidi_opts), do: :ok defp create_browsing_context(user_context_id, bidi_opts, retries_left) when is_integer(retries_left) and retries_left >= 0 do with {:ok, result} <- BiDi.command( "browsingContext.create", %{ "type" => "tab", "userContext" => user_context_id }, bidi_opts ), browsing_context_id when is_binary(browsing_context_id) <- result["context"] do {:ok, browsing_context_id} else {:error, reason, details} -> if retries_left > 0 and transient_create_browsing_context_error?(reason, details) do Process.sleep(25) create_browsing_context(user_context_id, bidi_opts, retries_left - 1) else {:error, reason, details} end _ -> {:error, "unexpected browsingContext.create response", %{}} end end defp transient_create_browsing_context_error?(reason, details) do combined = "#{reason} #{inspect(details)}" String.contains?(combined, "DiscardedBrowsingContextError") or String.contains?(combined, "no such frame") or String.contains?(combined, "argument is not a global object") end defp maybe_set_viewport(_context_id, nil, _bidi_opts), do: :ok defp maybe_set_viewport(context_id, %{width: width, height: height}, bidi_opts) when is_integer(width) and is_integer(height) and width > 0 and height > 0 do params = %{ "context" => context_id, "viewport" => %{"width" => width, "height" => height} } case BiDi.command("browsingContext.setViewport", params, bidi_opts) do {:ok, _result} -> :ok {:error, reason, details} -> {:error, reason, details} end end defp maybe_set_viewport(_context_id, viewport, _bidi_opts) do {:error, "invalid viewport", %{viewport: inspect(viewport)}} end defp evaluate_script(context_id, expression, bidi_opts) do BiDi.command( "script.evaluate", %{ "target" => %{"context" => context_id}, "expression" => expression, "awaitPromise" => true, "resultOwnership" => "none" }, bidi_opts ) end defp evaluate_json(context_id, expression, bidi_opts) do with {:ok, result} <- evaluate_script(context_id, expression, bidi_opts), {:ok, json} <- decode_remote_json(result) do {:ok, json} else {:error, reason, details} -> {:error, reason, details} {:error, reason} -> {:error, reason, %{}} end end defp evaluate_readiness(context_id, timeout_ms, quiet_ms, bidi_opts) do expression = readiness_expression(timeout_ms, quiet_ms) case evaluate_json(context_id, expression, bidi_opts) do {:error, reason, details} -> if transient_readiness_error?(reason, details) do Process.sleep(25) evaluate_json(context_id, expression, bidi_opts) else {:error, reason, details} end result -> result end end defp transient_readiness_error?(reason, details) do combined = "#{reason} #{inspect(details)}" Enum.any?( [ "JSWindowActorChild cannot send", "argument is not a global object", "Inspected target navigated or closed", "Cannot find context with specified id", "execution contexts cleared", "DiscardedBrowsingContextError", "no such frame" ], &String.contains?(combined, &1) ) end defp decode_remote_json(%{"result" => %{"type" => "string", "value" => payload}}) when is_binary(payload) do case JSON.decode(payload) do {:ok, json} -> {:ok, json} {:error, reason} -> {:error, "invalid json payload from browser: #{inspect(reason)}"} end end defp decode_remote_json(result) do {:error, "unexpected script.evaluate result: #{inspect(result)}"} end defp record_transport_delay(bucket, started_us) when is_atom(bucket) and is_integer(started_us) do Cerberus.Profiling.record_us( {:browser_transport, bucket}, max(System.monotonic_time(:microsecond) - started_us, 0) ) end defp start_pending_evaluation(state, expression, timeout_ms, from) when is_map(state) and is_binary(expression) and is_integer(timeout_ms) do task = Task.async(fn -> try do bidi_opts = Keyword.put(state.bidi_opts, :timeout, timeout_ms) Cerberus.Profiling.measure({:browser_transport, :browsing_context_dispatch}, fn -> evaluate_script(state.id, expression, bidi_opts) end) catch :exit, reason -> {:error, "evaluate task crashed", %{reason: Exception.format_exit(reason)}} end end) Process.unlink(task.pid) %{state | pending_evaluations: Map.put(state.pending_evaluations, task.ref, from)} end defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value defp normalize_positive_integer(_value, default), do: default defp push_download_event(download_events, event) when is_list(download_events) and is_map(event) do download_events |> Kernel.++([event]) |> Enum.take(-@download_history_limit) end defp push_dialog_event(dialog_events, event) when is_list(dialog_events) and is_map(event) do dialog_events |> Kernel.++([event]) |> Enum.take(-@dialog_history_limit) end defp next_active_dialog("browsingContext.userPromptOpened", event, _active_dialog), do: event defp next_active_dialog("browsingContext.userPromptClosed", _event, _active_dialog), do: nil defp next_active_dialog(_method, _event, active_dialog), do: active_dialog defp find_download_event(download_events, expected_filename) when is_list(download_events) and is_binary(expected_filename) do Enum.find(download_events, &download_event_match?(&1, expected_filename)) end defp download_event_match?( %{"method" => "browsingContext.downloadWillBegin", "suggestedFilename" => filename}, expected_filename ) when is_binary(filename) and is_binary(expected_filename) do filename == expected_filename end defp download_event_match?(_event, _expected_filename), do: false defp resolve_download_waiters(%{download_waiters: waiters} = state, event) when map_size(waiters) == 0 or not is_map(event) do state end defp resolve_download_waiters(%{download_waiters: waiters} = state, event) do {resolved, pending} = Enum.split_with(waiters, fn {_waiter_id, waiter} -> download_event_match?(event, waiter.expected) end) Enum.each(resolved, fn {_waiter_id, waiter} -> Process.cancel_timer(waiter.timer) GenServer.reply(waiter.from, {:ok, event}) end) %{state | download_waiters: Map.new(pending)} end defp resolve_dialog_waiters(%{dialog_waiters: waiters} = state, dialog) when map_size(waiters) == 0 or not is_map(dialog) do state end defp resolve_dialog_waiters(%{dialog_waiters: waiters} = state, dialog) do Enum.each(waiters, fn {_waiter_id, waiter} -> Process.cancel_timer(waiter.timer) GenServer.reply(waiter.from, {:ok, dialog}) end) %{state | dialog_waiters: %{}} end defp command_call_timeout_ms(timeout_ms) when is_integer(timeout_ms) and timeout_ms > 0 do timeout_ms + @call_timeout_padding_ms end defp readiness_expression(timeout_ms, quiet_ms) do """ (() => { const timeoutMs = #{timeout_ms}; const quietMs = #{quiet_ms}; const awaited = [ "phx:page-loading-stop", "dom-mutation", "window-load", "liveview-connected", "liveview-down" ]; const safePath = () => { try { return window.location.pathname + window.location.search; } catch (_error) { return ""; } }; const payload = (ok, reason, lastSignal, liveState, details = {}) => JSON.stringify({ ok, reason, path: safePath(), awaited, lastSignal, lastLiveState: liveState, details }); try { return new Promise((resolve) => { let inFlight = false; let resolved = false; let quietTimer = null; let timeoutTimer = null; let lastSignal = "initial"; const cleanupFns = []; const roots = () => { try { return Array.from(document.querySelectorAll("[data-phx-session]")); } catch (_error) { return []; } }; const liveState = () => { try { const currentRoots = roots(); if (currentRoots.length === 0) return "down"; const connectedCount = currentRoots.filter((root) => { try { return !!(root && root.classList && root.classList.contains("phx-connected")); } catch (_error) { return false; } }).length; return connectedCount > 0 ? "connected" : "disconnected"; } catch (_error) { return "unknown"; } }; const cleanup = () => { cleanupFns.forEach((fn) => { try { fn(); } catch (_error) { // ignored } }); cleanupFns.length = 0; clearTimeout(quietTimer); clearTimeout(timeoutTimer); }; const finish = (ok, reason, details = {}) => { if (resolved) return; resolved = true; const currentState = liveState(); cleanup(); resolve(payload(ok, reason, lastSignal, currentState, details)); }; const scheduleQuiet = () => { clearTimeout(quietTimer); quietTimer = setTimeout(() => finish(true, "settled"), quietMs); }; const note = (signal) => { lastSignal = signal; }; const handleStateChange = (sourceSignal) => { const currentState = liveState(); if (currentState === "down") { note("liveview-down"); scheduleQuiet(); return; } if (currentState === "connected") { note(sourceSignal); if (!inFlight) scheduleQuiet(); return; } if (currentState === "unknown") { note("liveview-state-unknown"); clearTimeout(quietTimer); return; } note("liveview-disconnected"); clearTimeout(quietTimer); }; const onPageLoadingStart = () => { inFlight = true; note("phx:page-loading-start"); clearTimeout(quietTimer); }; const onPageLoadingStop = () => { inFlight = false; note("phx:page-loading-stop"); if (liveState() !== "disconnected") scheduleQuiet(); }; window.addEventListener("phx:page-loading-start", onPageLoadingStart); window.addEventListener("phx:page-loading-stop", onPageLoadingStop); cleanupFns.push(() => window.removeEventListener("phx:page-loading-start", onPageLoadingStart)); cleanupFns.push(() => window.removeEventListener("phx:page-loading-stop", onPageLoadingStop)); const onLoad = () => { note("window-load"); if (!inFlight) scheduleQuiet(); }; window.addEventListener("load", onLoad); cleanupFns.push(() => window.removeEventListener("load", onLoad)); const setupObserver = () => { try { const root = document.documentElement || document.body || document; if (!root || typeof root.nodeType !== "number") { note("observer-root-missing"); return false; } const observer = new MutationObserver(() => { try { handleStateChange("dom-mutation"); } catch (_error) { note("observer-callback-error"); } }); observer.observe(root, { subtree: true, childList: true, attributes: true, characterData: true }); cleanupFns.push(() => observer.disconnect()); note("observer-attached"); return true; } catch (_error) { note("observer-attach-error"); return false; } }; if (!setupObserver()) { const pollDelayMs = Math.max(quietMs, 50); const pollRef = setInterval(() => { if (resolved) return; try { handleStateChange("dom-poll"); } catch (_error) { note("poll-error"); } }, pollDelayMs); cleanupFns.push(() => clearInterval(pollRef)); } const initialState = liveState(); if (initialState === "connected") { note("liveview-connected"); } else if (initialState === "down") { note("liveview-down"); } else if (initialState === "unknown") { note("liveview-state-unknown"); } else { note("liveview-disconnected"); } if (!inFlight && initialState !== "disconnected" && initialState !== "unknown") { scheduleQuiet(); } timeoutTimer = setTimeout(() => finish(false, "timeout"), timeoutMs); }); } catch (error) { return payload(false, "setup-error", "setup-error", "unknown", { error: "" + error }); } })() """ end end