defmodule Mirage do
@moduledoc """
Test framework for the Hologram web framework.
The entry point is `visit/2`, which initializes a page and expands its
template into a fully-resolved DOM that tests can make assertions 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: %{},
components: %{}
}
]
@typep bookkeeping :: %{
checked_radios: map(),
checked_checkboxes: map(),
selected_options: 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.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
params = Map.new(params)
{page, server} = DOM.init_component(page_module, params, %Hologram.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: %{},
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"}])
"""
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 `