defmodule Cerberus do @moduledoc """ Public session-first facade for Cerberus drivers. This module validates option schemas, normalizes locator inputs, and dispatches operations to static/live/browser session implementations while preserving a consistent API shape. Technical guarantees: - Public operations accept and return `Cerberus.Session` structs. - Locator-based operations keep `session` first and support locator composition forms. - Path and assertion operations resolve timeout defaults per driver. - Profiling buckets are emitted per operation and driver kind. """ alias Cerberus.Assertions alias Cerberus.Driver.Browser, as: BrowserSession alias Cerberus.Driver.Live, as: LiveSession alias Cerberus.Driver.Static, as: StaticSession alias Cerberus.Locator alias Cerberus.OpenBrowser alias Cerberus.Options alias Cerberus.Path alias Cerberus.Profiling alias Cerberus.Session alias Ecto.Adapters.SQL.Sandbox, as: EctoSandbox alias Phoenix.Ecto.SQL.Sandbox, as: PhoenixSandbox @type locator_input :: Locator.input() @type scope_locator_input :: Locator.input() @session_common_options_doc NimbleOptions.docs(Options.session_common_schema()) @session_browser_options_doc NimbleOptions.docs(Options.session_browser_schema()) @path_options_doc NimbleOptions.docs(Options.path_schema()) @assert_download_options_doc NimbleOptions.docs(Options.assert_download_schema()) @click_options_doc NimbleOptions.docs(Options.click_schema()) @fill_in_options_doc NimbleOptions.docs(Options.fill_in_schema()) @submit_options_doc NimbleOptions.docs(Options.submit_schema()) @upload_options_doc NimbleOptions.docs(Options.upload_schema()) @select_options_doc NimbleOptions.docs(Options.select_schema()) @assert_options_doc NimbleOptions.docs(Options.assert_schema()) @return_result_options_doc NimbleOptions.docs(Options.return_result_schema()) @doc """ Starts a default non-browser (`:phoenix`) session with default options. """ @spec session() :: Session.t() def session, do: session([]) @doc """ Starts a non-browser (`:phoenix`) session. This arity also accepts `Plug.Conn` and driver atoms: - `session(conn)` seeds the new session from an existing conn. - `session(:phoenix)` starts non-browser mode. - `session(:browser | :chrome | :firefox)` starts browser mode with defaults. ## Options #{@session_common_options_doc} """ @spec session(Options.session_common_opts()) :: Session.t() def session(opts) when is_list(opts) do opts |> Options.validate_session_common!() |> StaticSession.new_session() end @spec session(Plug.Conn.t()) :: Session.t() def session(%Plug.Conn{} = conn), do: session(conn: conn) @spec session(:phoenix) :: Session.t() def session(:phoenix), do: session([]) @spec session(:browser) :: Session.t() def session(:browser), do: session(:browser, []) @spec session(:chrome) :: Session.t() def session(:chrome), do: session(:chrome, []) @spec session(:firefox) :: Session.t() def session(:firefox), do: session(:firefox, []) def session(driver) when is_atom(driver) do raise ArgumentError, "unsupported public driver #{inspect(driver)}; use session()/session(:phoenix) for non-browser and session(:browser|:chrome|:firefox) for browser" end @spec session(:phoenix, Options.session_common_opts()) :: Session.t() def session(:phoenix, opts) when is_list(opts), do: session(opts) @doc """ Starts a browser session. Browser context defaults can be configured globally via `config :cerberus, :browser` and overridden per session with: - `user_agent: "..."` - `browser: [viewport: [width: ..., height: ...] | {w, h}]` - `browser: [user_agent: "..."]` - `browser: [popup_mode: :allow | :same_tab]` to control `window.open` behavior (`:same_tab` is currently unsupported on Firefox) - `browser: [init_script: "..."]` or `browser: [init_scripts: ["...", ...]]` - `webdriver_url: "http://remote-webdriver:4444"` to use a remote WebDriver endpoint without local browser/chromedriver launch. ## Options #{@session_browser_options_doc} """ @spec session(:browser, Options.session_browser_opts()) :: Session.t() def session(:browser, opts) when is_list(opts) do new_browser_session(opts) end @spec session(:chrome, Options.session_browser_opts()) :: Session.t() def session(:chrome, opts) when is_list(opts) do new_browser_session(opts, :chrome) end @spec session(:firefox, Options.session_browser_opts()) :: Session.t() def session(:firefox, opts) when is_list(opts) do new_browser_session(opts, :firefox) end def session(driver, opts) when is_atom(driver) and is_list(opts) do raise ArgumentError, "unsupported public driver #{inspect(driver)}; use session()/session(:phoenix) for non-browser and session(:browser|:chrome|:firefox) for browser" end defp new_browser_session(opts, browser_name \\ nil) when is_list(opts) do opts = opts |> Options.validate_session_browser!() |> maybe_put_browser_name(browser_name) BrowserSession.new_session(opts) end defp maybe_put_browser_name(opts, nil), do: opts defp maybe_put_browser_name(opts, browser_name) when is_atom(browser_name), do: Keyword.put(opts, :browser_name, browser_name) @doc """ Returns the encoded SQL sandbox user-agent marker for an ExUnit test context. This helper is intended for browser-session sandbox wiring: metadata = Cerberus.sql_sandbox_user_agent(MyApp.Repo, context) session(:browser, user_agent: metadata) The returned value can also be used for raw conn headers: conn |> Plug.Conn.delete_req_header("user-agent") |> Plug.Conn.put_req_header("user-agent", metadata) """ @spec sql_sandbox_user_agent(module() | [module()], map()) :: String.t() def sql_sandbox_user_agent(repo, context) when (is_atom(repo) or is_list(repo)) and is_map(context) do checkout_ecto_repos(repo, context) end @doc """ Returns the encoded SQL sandbox user-agent marker for the first configured Ecto repo. """ @spec sql_sandbox_user_agent(map()) :: String.t() def sql_sandbox_user_agent(context) when is_map(context) do if repos = Application.get_env(:cerberus, :ecto_repos) do sql_sandbox_user_agent(repos, context) else raise ArgumentError, "sql_sandbox_user_agent/1 requires :cerberus, :ecto_repos to include at least one repo; use sql_sandbox_user_agent/2 to pass an explicit repo" end end @doc """ Opens a new tab for browser sessions and returns the updated session. """ @spec open_tab(arg) :: arg when arg: var def open_tab(session), do: dispatch_tab_operation!(session, :open_tab) @doc """ Switches the active tab to `target_session` for browser sessions. """ @spec switch_tab(Session.t(), Session.t()) :: Session.t() def switch_tab(session, target_session), do: dispatch_tab_operation!(session, :switch_tab, [target_session]) @doc """ Closes the current tab for browser sessions. """ @spec close_tab(arg) :: arg when arg: var def close_tab(session), do: dispatch_tab_operation!(session, :close_tab) @doc """ Executes a callback with driver-native escape-hatch data. Browser sessions receive a constrained `Cerberus.Browser.Native` handle. Prefer public Cerberus operations and `Cerberus.Browser.*` helpers for stable flows. """ @spec unwrap(arg, (term() -> term())) :: arg when arg: var def unwrap(_session, fun) when not is_function(fun, 1) do raise ArgumentError, "unwrap/2 expects a callback with arity 1" end def unwrap(session, fun) do driver_module_for_session!(session).unwrap(session, fun) end @doc """ Writes the current rendered page snapshot to a temporary HTML file and opens it. This is primarily useful for human debugging because it lets you inspect the rendered page in a real browser tab. For callback-based DOM inspection in-process (for example, by AI tooling), see `render_html/2`. """ @spec open_browser(arg) :: arg when arg: var def open_browser(session), do: open_browser(session, &OpenBrowser.open_with_system_cmd/1) @doc false @spec open_browser(arg, (String.t() -> term())) :: arg when arg: var def open_browser(session, open_fun) when is_function(open_fun, 1) do driver_module_for_session!(session).open_browser(session, open_fun) end def open_browser(_session, _open_fun) do raise ArgumentError, "open_browser/2 expects a callback with arity 1" end @doc """ Renders the current page HTML. This is primarily for debugging, and can be useful for AI-assisted workflows that need to inspect the entire DOM tree in-process. Use either: - callback form (`render_html(session, fn lazy_html -> ... end)`) to keep piping - `return_result: true` (`render_html(session, return_result: true)`) to return `LazyHTML` For human-oriented inspection in a browser, see `open_browser/1`. ## Options #{@return_result_options_doc} """ @spec render_html(arg, Options.return_result_opts()) :: arg | LazyHTML.t() when arg: var @spec render_html(arg, (LazyHTML.t() -> term())) :: arg when arg: var def render_html(session, callback_or_opts) when is_function(callback_or_opts, 1) or is_list(callback_or_opts) do case callback_or_opts do callback when is_function(callback, 1) -> driver_module_for_session!(session).render_html(session, callback) opts -> opts = Options.validate_return_result!(opts, "render_html/2") if Keyword.get(opts, :return_result, false) do render_html_result(session) else session end end end def render_html(_session, _callback_or_opts) do raise ArgumentError, "render_html/2 expects a callback with arity 1 or keyword options" end @doc """ Builds a text locator. """ @spec text(String.t() | Regex.t()) :: Locator.t() def text(value) when is_binary(value) or is_struct(value, Regex), do: text(value, []) @doc """ Builds or composes a text locator. Supported forms: - `text(value, opts)` for a leaf locator - `text(locator, value)` to compose with an existing locator """ @spec text(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def text(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:text, value, opts) end @spec text(locator_input(), String.t() | Regex.t()) :: Locator.t() def text(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, text(value)) end @doc """ Composes a text constraint into an existing locator with locator options. """ @spec text(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def text(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, text(value, opts)) end @doc """ Builds a link locator. """ @spec link(String.t() | Regex.t()) :: Locator.t() def link(value) when is_binary(value) or is_struct(value, Regex), do: link(value, []) @doc """ Builds or composes a link locator. Supported forms: - `link(value, opts)` for a leaf locator - `link(locator, value)` to compose with an existing locator """ @spec link(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def link(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:link, value, opts) end @spec link(locator_input(), String.t() | Regex.t()) :: Locator.t() def link(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, link(value)) end @doc """ Composes a link constraint into an existing locator with locator options. """ @spec link(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def link(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, link(value, opts)) end @doc """ Builds a button locator. """ @spec button(String.t() | Regex.t()) :: Locator.t() def button(value) when is_binary(value) or is_struct(value, Regex), do: button(value, []) @doc """ Builds or composes a button locator. Supported forms: - `button(value, opts)` for a leaf locator - `button(locator, value)` to compose with an existing locator """ @spec button(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def button(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:button, value, opts) end @spec button(locator_input(), String.t() | Regex.t()) :: Locator.t() def button(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, button(value)) end @doc """ Composes a button constraint into an existing locator with locator options. """ @spec button(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def button(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, button(value, opts)) end @doc """ Builds a label locator. """ @spec label(String.t() | Regex.t()) :: Locator.t() def label(value) when is_binary(value) or is_struct(value, Regex), do: label(value, []) @doc """ Builds or composes a label locator. Supported forms: - `label(value, opts)` for a leaf locator - `label(locator, value)` to compose with an existing locator """ @spec label(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def label(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:label, value, opts) end @spec label(locator_input(), String.t() | Regex.t()) :: Locator.t() def label(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, label(value)) end @doc """ Composes a label constraint into an existing locator with locator options. """ @spec label(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def label(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, label(value, opts)) end @doc """ Builds a placeholder locator. """ @spec placeholder(String.t() | Regex.t()) :: Locator.t() def placeholder(value) when is_binary(value) or is_struct(value, Regex), do: placeholder(value, []) @doc """ Builds or composes a placeholder locator. Supported forms: - `placeholder(value, opts)` for a leaf locator - `placeholder(locator, value)` to compose with an existing locator """ @spec placeholder(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def placeholder(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:placeholder, value, opts) end @spec placeholder(locator_input(), String.t() | Regex.t()) :: Locator.t() def placeholder(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, placeholder(value)) end @doc """ Composes a placeholder constraint into an existing locator with locator options. """ @spec placeholder(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def placeholder(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, placeholder(value, opts)) end @doc """ Builds a title locator. """ @spec title(String.t() | Regex.t()) :: Locator.t() def title(value) when is_binary(value) or is_struct(value, Regex), do: title(value, []) @doc """ Builds or composes a title locator. Supported forms: - `title(value, opts)` for a leaf locator - `title(locator, value)` to compose with an existing locator """ @spec title(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def title(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:title, value, opts) end @spec title(locator_input(), String.t() | Regex.t()) :: Locator.t() def title(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, title(value)) end @doc """ Composes a title constraint into an existing locator with locator options. """ @spec title(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def title(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, title(value, opts)) end @doc """ Builds an alt-text locator. """ @spec alt(String.t() | Regex.t()) :: Locator.t() def alt(value) when is_binary(value) or is_struct(value, Regex), do: alt(value, []) @doc """ Builds or composes an alt-text locator. Supported forms: - `alt(value, opts)` for a leaf locator - `alt(locator, value)` to compose with an existing locator """ @spec alt(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def alt(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:alt, value, opts) end @spec alt(locator_input(), String.t() | Regex.t()) :: Locator.t() def alt(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, alt(value)) end @doc """ Composes an alt-text constraint into an existing locator with locator options. """ @spec alt(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def alt(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, alt(value, opts)) end @doc """ Builds an `aria-label` locator. """ @spec aria_label(String.t() | Regex.t()) :: Locator.t() def aria_label(value) when is_binary(value) or is_struct(value, Regex), do: aria_label(value, []) @doc """ Builds or composes an `aria-label` locator. Supported forms: - `aria_label(value, opts)` for a leaf locator - `aria_label(locator, value)` to compose with an existing locator """ @spec aria_label(String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def aria_label(value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do Locator.leaf(:aria_label, value, opts) end @spec aria_label(locator_input(), String.t() | Regex.t()) :: Locator.t() def aria_label(locator, value) when is_binary(value) or is_struct(value, Regex) do and_(locator, aria_label(value)) end @doc """ Composes an `aria-label` constraint into an existing locator with locator options. """ @spec aria_label(locator_input(), String.t() | Regex.t(), Options.locator_leaf_opts()) :: Locator.t() def aria_label(locator, value, opts) when (is_binary(value) or is_struct(value, Regex)) and is_list(opts) do and_(locator, aria_label(value, opts)) end @doc """ Builds a CSS locator. """ @spec css(String.t()) :: Locator.t() def css(value) when is_binary(value), do: css(value, []) @doc """ Builds or composes a CSS locator. Supported forms: - `css(value, opts)` for a leaf locator - `css(locator, value)` to compose with an existing locator """ @spec css(String.t(), Options.locator_leaf_opts()) :: Locator.t() def css(value, opts) when is_binary(value) and is_list(opts) do Locator.leaf(:css, value, opts) end @spec css(locator_input(), String.t()) :: Locator.t() def css(locator, value) when is_binary(value) do and_(locator, css(value)) end @doc """ Composes a CSS constraint into an existing locator with locator options. """ @spec css(locator_input(), String.t(), Options.locator_leaf_opts()) :: Locator.t() def css(locator, value, opts) when is_binary(value) and is_list(opts) do and_(locator, css(value, opts)) end @doc """ Builds a role locator. """ @spec role(String.t() | atom()) :: Locator.t() def role(role_name) when is_binary(role_name) or is_atom(role_name), do: role(role_name, []) @doc """ Builds or composes a role locator. Supported forms: - `role(role_name, opts)` for a leaf locator - `role(locator, role_name)` to compose with an existing locator """ @spec role(String.t() | atom(), Options.role_locator_opts()) :: Locator.t() def role(role, opts) when (is_binary(role) or is_atom(role)) and is_list(opts) do Locator.role(role, opts) end @spec role(locator_input(), String.t() | atom()) :: Locator.t() def role(locator, role_name) when is_binary(role_name) or is_atom(role_name) do and_(locator, role(role_name)) end @doc """ Composes a role constraint into an existing locator with locator options. """ @spec role(locator_input(), String.t() | atom(), Options.role_locator_opts()) :: Locator.t() def role(locator, role_name, opts) when (is_binary(role_name) or is_atom(role_name)) and is_list(opts) do and_(locator, role(role_name, opts)) end @doc """ Builds a test-id locator. """ @spec testid(String.t()) :: Locator.t() def testid(value) when is_binary(value), do: testid(value, []) @doc """ Builds or composes a test-id locator. Supported forms: - `testid(value, opts)` for a leaf locator - `testid(locator, value)` to compose with an existing locator """ @spec testid(String.t(), Options.locator_leaf_opts()) :: Locator.t() def testid(value, opts) when is_binary(value) and is_list(opts) do Locator.leaf(:testid, value, opts) end @spec testid(locator_input(), String.t()) :: Locator.t() def testid(locator, value) when is_binary(value) do and_(locator, testid(value)) end @doc """ Composes a test-id constraint into an existing locator with locator options. """ @spec testid(locator_input(), String.t(), Options.locator_leaf_opts()) :: Locator.t() def testid(locator, value, opts) when is_binary(value) and is_list(opts) do and_(locator, testid(value, opts)) end @spec and_(locator_input(), locator_input()) :: Locator.t() @doc """ Composes two locators with logical AND (same-element intersection). Both sides must match the same DOM node. """ def and_(left, right), do: Locator.compose_and(left, right) @spec or_(locator_input(), locator_input()) :: Locator.t() @doc """ Composes two locators with logical OR (union). Action operations still require a unique final target at execution time. """ def or_(left, right), do: Locator.compose_or(left, right) @spec not_(locator_input()) :: Locator.t() @doc """ Negates a locator. """ def not_(locator), do: Locator.compose_not(locator) @spec not_(locator_input(), locator_input()) :: Locator.t() @doc """ Composes `left AND NOT(right)`. """ def not_(left, right), do: and_(left, not_(right)) @spec has(locator_input(), locator_input()) :: Locator.t() @doc """ Adds a descendant locator constraint (`:has`) to a locator. Example: button("Apply") |> has(testid("apply-secondary-marker")) """ def has(locator, nested_locator), do: Locator.put_has(locator, nested_locator) @spec has_not(locator_input(), locator_input()) :: Locator.t() @doc """ Adds a descendant-negation locator constraint (`:has_not`) to a locator. """ def has_not(locator, nested_locator), do: Locator.put_has_not(locator, nested_locator) @doc """ Composes a locator that matches the closest ancestor of a nested `from` locator. Example: within(session, closest(~l".fieldset"c, from: ~l"textbox:Email"r), &assert_has(&1, ~l"can't be blank"e)) """ @spec closest(locator_input(), Options.closest_opts()) :: Locator.t() def closest(locator, opts), do: Locator.closest(locator, opts) @doc """ Builds a locator using `~l`. Supported forms: - `~l"text"` exact text (default) - `~l"text"e` exact text - `~l"text"i` inexact text - `~l"ROLE:NAME"r` role locator form - `~l"selector"c` CSS locator form - `~l"text"a` `aria-label` locator form - `~l"test-id"t` testid locator form (defaults to exact matching) Rules: - use at most one locator-kind modifier (`r`, `c`, `a`, or `t`) - `e` and `i` are mutually exclusive - `r` requires `ROLE:NAME` input """ @spec sigil_l(String.t(), charlist()) :: Locator.t() def sigil_l(value, modifiers) when is_list(modifiers), do: Locator.sigil(value, modifiers) @doc """ Visits `path` and returns the updated session. """ @spec visit(arg, String.t(), Options.visit_opts()) :: arg when arg: var def visit(session, path, opts \\ []) when is_binary(path) do driver = driver_module_for_session!(session) bucket_driver = profiling_bucket_driver_kind!(session) Profiling.measure({:driver_operation, bucket_driver, :visit}, fn -> driver.visit(session, path, opts) end) end @doc """ Reloads the current path, defaulting to `/` when the session has no current path. """ @spec reload_page(arg, Options.reload_opts()) :: arg when arg: var def reload_page(session, opts \\ []) do visit(session, current_path(session, return_result: true) || "/", opts) end @doc """ Resolves the normalized current path tracked by the session. Use either: - callback form (`current_path(session, fn path -> ... end)`) to keep piping - `return_result: true` (`current_path(session, return_result: true)`) to return the path ## Options #{@return_result_options_doc} """ @spec current_path(Session.t()) :: Session.t() @spec current_path(Session.t(), Options.return_result_opts()) :: Session.t() | String.t() | nil @spec current_path(Session.t(), (String.t() | nil -> term())) :: Session.t() def current_path(session, callback_or_opts \\ []) def current_path(session, callback) when is_function(callback, 1) do callback.(current_path_result(session)) session end def current_path(session, opts) when is_list(opts) do opts = Options.validate_return_result!(opts, "current_path/2") if Keyword.get(opts, :return_result, false) do current_path_result(session) else session end end def current_path(_session, _callback_or_opts) do raise ArgumentError, "current_path/2 expects a callback with arity 1 or keyword options" end @doc """ Executes `callback` within a narrowed scope. `scope` must be a locator input (for example, `~l"#secondary-panel"c`). Use `closest/2` when scope should resolve to the nearest matching ancestor around a nested element (for example, a field wrapper around a label). Use `within/3` when you need explicit scope boundaries for mixed operations. Browser note: when locator-based `within/3` matches an `