defmodule Cerberus do @moduledoc """ Session-first test API for non-browser Phoenix mode and browser execution. Architecture contract (ADR-0001 + ADR-0002): - The public API is driver-agnostic and session-first. - All public operations take a `Cerberus.Session` and return a `Cerberus.Session`. - `locator` is the first argument after `session` for locator-based operations. - Browser unwrap payloads are exposed as `Cerberus.Browser.Native` handles, not raw internals. - v0 does not expose a public located-element pipeline type. Slice 1 provides one-shot operations over deterministic adapters. Locator sigil quick look: ~l"Save" # text ~l"Save"e # exact text ~l"Save"i # inexact text ~l"button:Save"r # role form (ROLE:NAME) ~l"#save"c # css ~l"save-btn"t # testid (defaults exact: true) Locator composition quick look: button("Run Search") |> testid("submit-secondary-button") # AND (same element) or_(css("#primary"), css("#secondary")) # OR (union) button("Run Search") |> has(testid("submit-secondary-marker")) # nesting (descendant) """ alias Cerberus.Assertions alias Cerberus.Browser.Native, as: BrowserNative alias Cerberus.Driver.Browser, as: BrowserSession alias Cerberus.Driver.Live, as: LiveSession alias Cerberus.Driver.Static, as: StaticSession alias Cerberus.Html alias Cerberus.Locator alias Cerberus.OpenBrowser alias Cerberus.Options alias Cerberus.Path alias Cerberus.Phoenix.Conn, as: DriverConn alias Cerberus.Phoenix.LiveViewTimeout alias Cerberus.Session alias Ecto.Adapters.SQL.Sandbox, as: EctoSandbox alias ExUnit.AssertionError alias Phoenix.Ecto.SQL.Sandbox, as: PhoenixSandbox alias Phoenix.LiveViewTest.View @type driver_kind :: Session.driver_kind() @type locator_input :: Locator.input() @type scope_locator_input :: Locator.input() @locator_kind_keys [:text, :label, :link, :button, :placeholder, :title, :alt, :role, :css, :testid, :and, :or] @locator_kind_string_keys Enum.map(@locator_kind_keys, &Atom.to_string/1) @spec session() :: Session.t() def session, do: session([]) @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. """ @spec session(:browser, Options.session_browser_opts()) :: Session.t() def session(:browser, opts) when is_list(opts) do opts |> Options.validate_session_browser!() |> BrowserSession.new_session() end @spec session(:chrome, Options.session_browser_opts()) :: Session.t() def session(:chrome, opts) when is_list(opts) do opts |> Options.validate_session_browser!() |> Keyword.put(:browser_name, :chrome) |> BrowserSession.new_session() end @spec session(:firefox, Options.session_browser_opts()) :: Session.t() def session(:firefox, opts) when is_list(opts) do opts |> Options.validate_session_browser!() |> Keyword.put(:browser_name, :firefox) |> BrowserSession.new_session() 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 @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 @spec open_tab(arg) :: arg when arg: var def open_tab(%BrowserSession{} = session), do: BrowserSession.open_tab(session) def open_tab(%StaticSession{} = session), do: StaticSession.open_tab(session) def open_tab(%LiveSession{} = session), do: LiveSession.open_tab(session) @spec switch_tab(Session.t(), Session.t()) :: Session.t() def switch_tab(%BrowserSession{} = session, %BrowserSession{} = target_session) do BrowserSession.switch_tab(session, target_session) end def switch_tab(%BrowserSession{}, _target_session) do raise ArgumentError, "cannot switch browser tab to a non-browser session" end def switch_tab(%StaticSession{} = session, %StaticSession{} = target_session) do ensure_same_endpoint!(session, target_session) StaticSession.switch_tab(session, target_session) end def switch_tab(%StaticSession{} = session, %LiveSession{} = target_session) do ensure_same_endpoint!(session, target_session) StaticSession.switch_tab(session, target_session) end def switch_tab(%LiveSession{} = session, %StaticSession{} = target_session) do ensure_same_endpoint!(session, target_session) LiveSession.switch_tab(session, target_session) end def switch_tab(%LiveSession{} = session, %LiveSession{} = target_session) do ensure_same_endpoint!(session, target_session) LiveSession.switch_tab(session, target_session) end @spec close_tab(arg) :: arg when arg: var def close_tab(%BrowserSession{} = session), do: BrowserSession.close_tab(session) def close_tab(%StaticSession{} = session), do: StaticSession.close_tab(session) def close_tab(%LiveSession{} = session), do: LiveSession.close_tab(session) @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(%StaticSession{} = session, fun) do session.conn |> DriverConn.ensure_conn() |> fun.() |> unwrap_conn_result(session, :static) end def unwrap(%LiveSession{view: nil}, _fun) do raise ArgumentError, "unwrap/2 requires an active LiveView; visit a live route first" end def unwrap(%LiveSession{} = session, fun) do session.view |> fun.() |> unwrap_live_result(session) end def unwrap(%BrowserSession{} = session, fun) do _ = fun.(%BrowserNative{ user_context_pid: session.user_context_pid, tab_id: session.tab_id }) session end @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 @spec text(String.t() | Regex.t()) :: Locator.t() def text(value) when is_binary(value) or is_struct(value, Regex), do: text(value, []) @spec text(String.t() | Regex.t(), keyword()) :: 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 @spec text(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec link(String.t() | Regex.t()) :: Locator.t() def link(value) when is_binary(value) or is_struct(value, Regex), do: link(value, []) @spec link(String.t() | Regex.t(), keyword()) :: 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 @spec link(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec button(String.t() | Regex.t()) :: Locator.t() def button(value) when is_binary(value) or is_struct(value, Regex), do: button(value, []) @spec button(String.t() | Regex.t(), keyword()) :: 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 @spec button(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec label(String.t() | Regex.t()) :: Locator.t() def label(value) when is_binary(value) or is_struct(value, Regex), do: label(value, []) @spec label(String.t() | Regex.t(), keyword()) :: 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 @spec label(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec placeholder(String.t() | Regex.t()) :: Locator.t() def placeholder(value) when is_binary(value) or is_struct(value, Regex), do: placeholder(value, []) @spec placeholder(String.t() | Regex.t(), keyword()) :: 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 @spec placeholder(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec title(String.t() | Regex.t()) :: Locator.t() def title(value) when is_binary(value) or is_struct(value, Regex), do: title(value, []) @spec title(String.t() | Regex.t(), keyword()) :: 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 @spec title(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec alt(String.t() | Regex.t()) :: Locator.t() def alt(value) when is_binary(value) or is_struct(value, Regex), do: alt(value, []) @spec alt(String.t() | Regex.t(), keyword()) :: 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 @spec alt(locator_input(), String.t() | Regex.t(), keyword()) :: 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 @spec css(String.t()) :: Locator.t() def css(value) when is_binary(value), do: css(value, []) @spec css(String.t(), keyword()) :: 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 @spec css(locator_input(), String.t(), keyword()) :: Locator.t() def css(locator, value, opts) when is_binary(value) and is_list(opts) do and_(locator, css(value, opts)) end @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, []) @spec role(String.t() | atom(), keyword()) :: Locator.t() def role(role, opts) when (is_binary(role) or is_atom(role)) and is_list(opts) do [role: role, name: Keyword.get(opts, :name)] |> maybe_put_locator_opt(opts, :exact) |> maybe_put_locator_opt(opts, :selector) |> maybe_put_locator_opt(opts, :has) |> Locator.normalize() 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 @spec role(locator_input(), String.t() | atom(), keyword()) :: 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 @spec testid(String.t()) :: Locator.t() def testid(value) when is_binary(value), do: testid(value, []) @spec testid(String.t(), keyword()) :: 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 @spec testid(locator_input(), String.t(), keyword()) :: 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 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) @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")) """ @spec closest(locator_input(), keyword()) :: Locator.t() def closest(locator, opts) when is_list(opts) do from_locator_input = case Keyword.fetch(opts, :from) do {:ok, value} -> value :error -> raise ArgumentError, "closest/2 expects :from locator option" end case Keyword.keys(opts) -- [:from] do [] -> :ok extra -> raise ArgumentError, "closest/2 supports only :from option, got: #{inspect(extra)}" end from_locator = Locator.normalize(from_locator_input) if Keyword.has_key?(from_locator.opts, :from) do raise ArgumentError, "closest/2 does not support nested :from locators" end Locator.put_from(locator, from_locator) end def closest(_locator, _opts) do raise ArgumentError, "closest/2 expects keyword options" end @doc """ Builds a locator using `~l`. Supported forms: - `~l"text"` text locator - `~l"text"e` exact text - `~l"text"i` inexact text - `~l"ROLE:NAME"r` role locator form - `~l"selector"c` CSS locator form - `~l"test-id"t` testid locator form (defaults to exact matching) Rules: - use at most one locator-kind modifier (`r`, `c`, 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) @spec visit(arg, String.t(), keyword()) :: arg when arg: var def visit(session, path, opts \\ []) when is_binary(path) do driver_module_for_session!(session).visit(session, path, opts) end @spec reload_page(arg, keyword()) :: arg when arg: var def reload_page(session, opts \\ []) do visit(session, current_path(session) || "/", opts) end @spec current_path(Session.t()) :: String.t() | nil def current_path(session) do session |> Session.current_path() |> Path.normalize() 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). For one-shot assertions, you can also use scoped assertion overloads: session |> assert_has(closest(~l".fieldset"c, from: ~l"textbox:Email"r), ~l"can't be blank") Browser note: when locator-based `within/3` matches an `