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 defmacro sigil_HOLO(term, modifiers) do quote do require Hologram.Template Hologram.Template.sigil_HOLO(unquote(term), unquote(modifiers)) end end @doc """ Entry point to create a session. Takes a `Hologram.Page` and, optional, any params. It returns a session which the rest of `Mirage` can use. """ @spec visit(module(), keyword()) :: Session.t() def visit(page_module, params \\ []) do init_page(page_module, Map.new(params), %Hologram.Server{}) end @doc false def navigate(page_module, params, server) 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) 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: []} 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 @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 SignUpPage |> visit() |> fill_in("Name", with: "Bender") |> fill_in("Password", with: "killallhumans") |> click("button", "Submit") |> assert_page(WelcomePage) HomePage |> visit() |> 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 """ 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 Profile |> visit() |> 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 `