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 `