defmodule Mirage do @moduledoc """ Browserless test framework for the Hologram web framework. Mirage initializes a page or component and expands its template into a fully-resolved DOM that tests can make assertions and trigger events against. """ defmodule Session do @moduledoc """ State container for a test. """ defstruct [ :page, :server, :ast, :page_module, :params, :scope, bookkeeping: %{ checked_radios: %{}, checked_checkboxes: MapSet.new(), selected_options: %{}, filled_inputs: %{}, components: %{} } ] @typep bookkeeping :: %{ checked_radios: map(), checked_checkboxes: map(), selected_options: map(), filled_inputs: map(), components: map() } @type t :: %__MODULE__{ page: any(), server: any(), ast: any(), page_module: module(), params: map(), scope: tuple() | nil, bookkeeping: bookkeeping() } end alias Mirage.DOM alias Mirage.Events alias Mirage.Input alias Mirage.Scoped alias Mirage.Session @doc """ Entry point to create a session. Takes a `%Hologram.Server{}` or `%Mirage.Session{}`, a page module, and optionally any params. Returns a session which the rest of `Mirage` can use. visit(%Hologram.Server{}, MyPage, user_id: 42) %Hologram.Server{} |> Hologram.Server.put_session(:user_id, 42) |> visit(MyPage) When given a session, the server state is carried over (useful for client-side navigation): visit(session, OtherPage, id: 7) """ @spec visit(Hologram.Server.t() | Session.t(), module(), keyword()) :: Session.t() def visit(server_or_session, page_module, params \\ []) def visit(%Session{server: server}, page_module, params) do init_page(page_module, Map.new(params), server) end def visit(%Hologram.Server{} = server, page_module, params) do init_page(page_module, Map.new(params), server) end defp init_page(page_module, params, server) do {page, server} = DOM.init_component(page_module, params, server) case drain_init(page_module, page, server) do {:navigate, target_module, target_params, server} -> init_page(target_module, Map.new(target_params), server) {page, server} -> page_module |> render_page(params, page, server) |> Events.drain_component_inits() end end defp render_page(page_module, params, page, server) do vars = Map.merge(params, page.state) page_dom = page_module.template().(vars) layout_props_dom = page_module.__layout_props__() |> Enum.into(%{cid: "layout"}) |> Map.merge(page.state) |> Enum.map(fn {name, value} -> {to_string(name), [expression: {value}]} end) root = {:component, page_module.__layout_module__(), layout_props_dom, page_dom} context = Map.merge(runtime_context(), page.emitted_context) env = %{context: context, slots: [], target: nil} Process.delete(:mirage_components) ast = DOM.expand(root, env, server) components = Process.delete(:mirage_components) || %{} %Session{ page: page, server: server, ast: ast, page_module: page_module, params: params, bookkeeping: %{ checked_radios: %{}, checked_checkboxes: MapSet.new(), selected_options: %{}, filled_inputs: %{}, components: components } } end defp drain_init(module, page, server) do next_action = page.next_action next_command = page.next_command next_page = page.next_page page = %{page | next_action: nil, next_command: nil, next_page: nil} server = if cmd = next_command do case module.command(cmd.name, cmd.params, server) do %Hologram.Server{} = s -> s _ -> server end else server end result = if action = next_action do {page, server} = case module.action(action.name, action.params, page) do {%Hologram.Component{} = c, %Hologram.Server{} = s} -> {c, s} %Hologram.Component{} = c -> {c, server} %Hologram.Server{} = s -> {page, s} end drain_init(module, page, server) else {page, server} end case result do {:navigate, _, _, _} = nav -> nav {page, server} -> case next_page do nil -> {page, server} {target_module, target_params} -> {:navigate, target_module, target_params, server} target_module -> {:navigate, target_module, [], server} end end end @doc """ Mount a component in isolation. Pass a `~HOLO` template containing a single component. Props, cid, and slot content are all declared in the markup itself: ~HOLO\"\"\" \"\"\" |> mount() |> click("button", "Eat a poplar") |> assert_has("p", "Number of poplars eaten: 1") Context can be provided as a `{Namespace, key: value}` tuple. Props declared with `from_context` will be populated from matching context values. ~HOLO\"\"\"

{@user.name} eats too many poplars

\"\"\" |> mount({MyApp, user: current_user, theme: "dark"}) For multiple namespaces, use a list of tuples: ~HOLO\"\"\" \"\"\" |> mount([{MyApp, user: current_user}, {Themes, mode: "dark"}]) """ @spec mount(function(), {module(), keyword()} | [{module(), keyword()}]) :: Session.t() defdelegate mount(template_fn, context \\ []), to: Mirage.Mount @doc """ Scopes all operations within the given block to descendants of the element matching `selector`. session |> within(".sidebar", fn session -> session |> assert_has("a", "Home") |> click_link("Home") end) """ @spec within(Session.t(), String.t(), (Session.t() -> Session.t())) :: Session.t() defdelegate within(session, selector, fun), to: Scoped @doc """ Scopes to the `
` whose first heading (`h1` - `h6`) matches `header`. session |> within_article("Blog Post", fn session -> assert_has(session, "p", "Post content") end) """ @spec within_article(Session.t(), String.t(), (Session.t() -> Session.t())) :: Session.t() defdelegate within_article(session, header, fun), to: Scoped @doc """ Scopes to the `
` whose first heading (`h1` - `h6`) matches `header`. session |> within_section("Settings", fn session -> assert_has(session, "Send me update", "No") end) This can also be used more generally when given a CSS selector as the second argument. session |> within_section("div[role=article]", "My header", fn session -> assert_has(session, "p", "content") end) """ @spec within_section(Session.t(), String.t(), String.t(), (Session.t() -> Session.t())) :: Session.t() def within_section(session, selector \\ "section", header, fun) do Scoped.within_section(session, selector, header, fun) end @doc """ Scopes to the `
` whose `` matches `legend`. session |> within_fieldset("Account", fn session -> assert_has(session, "input#username") end) """ @spec within_fieldset(Session.t(), String.t(), (Session.t() -> Session.t())) :: Session.t() defdelegate within_fieldset(session, legend, fun), to: Scoped @doc """ Click on a link by its text. This is simply a shorthand for `Mirage.click/3` with `"a"` as its selector. """ @spec click_link(Session.t(), String.t(), keyword(any())) :: Session.t() def click_link(session, text, opts \\ []) do click(session, "a", text, opts) end @doc """ Click on a button by its text. If it's a submit button belonging to a form, it will trigger that form's `$submit` event. This is otherwise shorthand for `Mirage.click/3` with `"button"` as its selector. """ @spec click_button(Session.t(), String.t(), keyword(any())) :: Session.t() def click_button(session, text, opts \\ []) do click(session, "button", text, opts) end @doc """ Trigger a `$click` event on the element matching the given CSS selector. Any actions or commands will be run. If the click triggers a page navigation, the new page will be loaded into the session. When clicking a submit button that belongs to a form, that form's `$submit` event will be triggered. Raises if no matching element with a `$click` handler is found, or if more than one matches. ## Examples %Hologram.Server{} |> visit(SignUpPage) |> fill_in("Name", with: "Bender") |> fill_in("Password", with: "killallhumans") |> click("button", "Submit") |> assert_page(WelcomePage) %Hologram.Server{} |> visit(HomePage) |> click("button", "Log out") ## Options * `:text` - Match on the element's inner text. * `:exact` - Set to `false` to match on a substring of an element's text. Default is `true` meaning you must provide an exact match. """ @spec click(Session.t(), String.t(), String.t() | keyword()) :: Session.t() defdelegate click(session, selector, text_or_opts \\ []), to: Events @doc false defdelegate click(session, selector, text, opts), to: Events @doc """ Trigger a focus event on an element. Accepts the same options as `Mirage.click/3`. """ @spec focus(Session.t(), String.t(), String.t() | keyword()) :: Session.t() defdelegate focus(session, selector, text_or_opts \\ []), to: Events @doc false defdelegate focus(session, selector, text, opts), to: Events @doc """ Trigger a blur event on an element. Accepts the same options as `Mirage.click/3`. """ @spec blur(Session.t(), String.t(), String.t() | keyword()) :: Session.t() defdelegate blur(session, selector, text_or_opts \\ []), to: Events @doc false defdelegate blur(session, selector, text, opts), to: Events @doc """ Fill in an `input` or `textarea` by its label. Finds an input by its associated label and triggers the input's `$change` and as well as its form's `$change` event (if it has one). Labels may be associated with their input either by wrapping the input (``) or via a `for` attribute matching the input's `id`. Matches exactly by default; pass `exact: false` to match substrings. Raises if no matching label is found, or if more than one matches. """ @spec fill_in(Session.t(), String.t(), keyword()) :: Session.t() defdelegate fill_in(session, label, opts), to: Input @doc """ Fill in a hidden input by its `name` attribute. Unlike `fill_in/3`, this targets hidden inputs (`type="hidden"`) directly by name rather than by label text. Raises if the input is not hidden, or if it is disabled or readonly. Triggers the input's `$change` event and its form's `$change` event (if it has one). ## Example %Hologram.Server{} |> visit(CheckoutPage) |> fill_in_hidden("csrf_token", with: "abc123") ### Note **This function should generally be avoided.** It is useful if you are using a JavaScript library that fills in hidden fields via non-transpiled JS. """ @spec fill_in_hidden(Session.t(), String.t(), keyword()) :: Session.t() defdelegate fill_in_hidden(session, name, opts), to: Input @doc """ Selects a radio button by its associated label. Triggers the input's `$change` event as well as its form's `$change` event (if there is one). Labels may wrap the input or reference it via a `for`/`id` pair. Matches exactly by default; pass `exact: false` to match substrings. Raises if no matching radio button is found, or if more than one matches. ## Example %Hologram.Server{} |> visit(Profile) |> choose("robot") |> assert_has("p", "Your gender is 'robot'") """ @spec choose(Session.t(), String.t(), keyword()) :: Session.t() defdelegate choose(session, label, opts \\ []), to: Input @doc """ Checks a checkbox by its associated label text. Triggers the input's `$change` as well as its form's `$change` event (if it has one). Matches exactly by default; pass `exact: false` to match substrings. Raises if no matching radio button is found, or if more than one matches. """ @spec check(Session.t(), String.t(), keyword()) :: Session.t() defdelegate check(session, label, opts \\ []), to: Input @doc """ Unchecks a checkbox by its associated label. Trigger's the input's `$change` event as well as its form's `$change` event (if it has one). Matches exactly by default; pass `exact: false` to match substrings. Raises if no matching radio button is found, or if more than one matches. """ @spec uncheck(Session.t(), String.t(), keyword()) :: Session.t() defdelegate uncheck(session, label, opts \\ []), to: Input @doc """ Selects an option in a `