Headless testing of Drafter applications under ExUnit.
start_headless/3 starts an app against an in-memory terminal and returns a
context map. Every other function in this module takes that context as its first
argument. stop/1 shuts the instance down.
Each function that sends input blocks until the app has finished handling it, so a send and the assertion that follows need no sleep between them.
defmodule CounterTest do
use ExUnit.Case, async: false
import Drafter.Test
setup do
ctx = start_headless(Counter)
on_exit(fn -> stop(ctx) end)
%{ctx: ctx}
end
test "increment", %{ctx: ctx} do
send_click(ctx, query_one(ctx, "Button"))
assert get_state(ctx).count == 1
assert screen_text(ctx) =~ "Count: 1"
end
endSelectors take the forms Drafter.query_one/1 documents: a widget type as the
module's last segment in CamelCase or snake_case ("Button", "TextInput",
"text_input"), "#id", and ".class".
What has to be running
The :drafter application must be started before start_headless/3 is called:
the harness starts its own session services, but the widget supervisor, the
skin manager, the stylesheet loader and the PubSub the widgets use belong to
:drafter's own supervision tree. In a Mix project that lists :drafter as a
dependency this is automatic — mix test starts the applications of every dep,
and nothing extra goes in test_helper.exs. No terminal, tty, or TERM is
needed; the in-memory driver replaces all of it.
A single-file test outside a project works the same way once Mix.install/2 has
pulled the dependency in and started it. Building it compiles a C NIF and a
helper binary through elixir_make, so make and a C compiler must be present.
ExUnit.start/1 runs the tests when the script ends, so elixir the_file.exs
is the whole command. The app module — Counter below — is defined in the same
file or loaded by it.
Mix.install([{:drafter, github: "jaman/drafter"}])
ExUnit.start()
defmodule CounterTest do
use ExUnit.Case, async: false
import Drafter.Test
test "starts" do
ctx = start_headless(Counter)
assert screen_text(ctx) =~ "Count: 0"
stop(ctx)
end
end
Summary
Functions
Asserts that a widget matching selector is in the hierarchy.
Asserts that the first widget matching selector has the value expected.
Waits for the app to render.
Everything the app has written to the terminal since it started, as iodata.
The app's current state — whatever its mount/1 and callbacks have produced.
The app's current Drafter.WidgetHierarchy struct.
The full state struct of the widget with widget_id.
The primary value of the widget with widget_id.
The ids of every widget matching selector.
The id of the first widget matching selector, or nil.
Asserts that no widget matching selector is in the hierarchy.
The visible characters of each screen row, trailing blanks removed.
What the app currently has on screen, as plain text.
Injects a {:char, codepoint} event and returns once the app has handled it.
Clicks a widget by id and returns once the app has handled it.
Clicks at a screen cell and returns once the app has handled it.
Injects a key press and returns once the app has handled it.
Injects a raw mouse event and returns once the app has handled it.
Starts app_module against an in-memory terminal and returns the test context.
Stops the app and every service start_headless/3 started. Returns :ok.
Returns once every event injected before this call has been handled by the app.
Polls condition_fn until it returns a truthy value.
Functions
Asserts that a widget matching selector is in the hierarchy.
ctx is the context from start_headless/3; selector is a String.t() in the
forms Drafter.query_one/1 documents. Returns the matched widget's id, so it can
be bound and used for a follow-up interaction. Raises ExUnit.AssertionError when
nothing matches.
A macro: import Drafter.Test or require Drafter.Test before calling it.
id = assert_widget_present(ctx, "Button.primary")
send_click(ctx, id)
Asserts that the first widget matching selector has the value expected.
Compares expected with get_widget_value/2 using ==; the per-widget value
types are the ones Drafter.get_widget_value/1 lists. Returns :ok. Raises
ExUnit.AssertionError when nothing matches the selector and when the value
differs. A macro, as assert_widget_present/2 is.
assert_widget_value(ctx, "#name", "Ada")
Waits for the app to render.
opts:
:timeout- milliseconds to wait. Default1000.:min_count- wait until the driver's total render count reaches this integer, polling every 10 ms. Defaultnil, which instead waits for one{:render, count}message in the calling process's mailbox.
Returns :ok, or :timeout if the wait expired. The context argument is accepted
and unused.
:min_count is the reliable form. The driver sends {:render, count} to the
:test_pid on every frame it writes and nothing ever drains that mailbox:
send_key/3, send_char/2, send_click/2, send_click/3 and send_mouse/2
synchronise with the app but leave their render messages queued. A bare
await_render(ctx) after any of them therefore takes one of those stale
messages and returns :ok without a new frame having been drawn — a test that
passes whether or not the thing it waits for happened. Read the count first and
wait for one more:
before = Drafter.Test.HeadlessDriver.get_render_count()
send_key(ctx, :enter)
:ok = await_render(ctx, min_count: before + 1)The mailbox form is only usable in the process start_headless/3 was given as
:test_pid, since that is the only one notifications reach, and each call
consumes one message. The input functions already wait for the app to finish, so
neither form is needed after an input; this is for frames that arrive on their
own — a timer tick, a pushed data channel.
Everything the app has written to the terminal since it started, as iodata.
Raw bytes including ANSI escape sequences and cursor moves. Use screen_text/1 for
the rendered characters. The context argument is accepted and unused; the buffer
belongs to the single headless driver.
The app's current state — whatever its mount/1 and callbacks have produced.
Raises RuntimeError if the app does not answer within 1000 ms.
@spec get_widget_hierarchy(map()) :: Drafter.WidgetHierarchy.t() | nil
The app's current Drafter.WidgetHierarchy struct.
Returns nil when the app fails to answer within 1000 ms. The struct's thirteen
fields:
:root- the id of the outermost widget, ornilbefore the first render. The whole tree is walked from here through:children.:widgets- id to%{module:, state:, parent:, children:, order:, pid:}.:stateis the widget's own state term,:pidis set only for a widget running in its own process, and:orderis its position among its siblings.:widget_rects- id to%{x:, y:, width:, height:}in absolute screen cells. A widget that was not laid out has no entry.:focused_widget- the id holding focus, ornil.:hover_widget- the id under the pointer, ornil.:widget_counter- the number of ids generated so far, used to name anonymous widgets.:scroll_containers- id to%{viewport_rect:, content_height:, content_width:, click_to_scroll:, scroll_exceptions:}for each scrollable container.:widget_scroll_parents- id to the id of the scroll container that clips it.:drag_capture_widget- the id receiving drag events until the button is released, ornil.:preferred_sizes- id to the height the widget asked for.:hidden_widgets- aMapSetof ids that are mounted but not drawn. Defaults to an empty set.:event_consumed-boolean(), whether the last event dispatched into the hierarchy was claimed by a widget. Defaults tofalse. This is the field to assert on when checking that a key or click reached a widget rather than falling through to the app.:widget_overflow- id to:ellipsisfor the widgets whose text is ellipsised when it does not fit.:clipis the default and is not stored, so the map holds only the exceptions. Defaults to%{}.hierarchy = get_widget_hierarchy(ctx) assert hierarchy.event_consumed assert Map.has_key?(hierarchy.widget_rects, hierarchy.root)
The full state struct of the widget with widget_id.
Returns nil when no widget has that id, and also when the app fails to answer
within 1000 ms.
The primary value of the widget with widget_id.
The per-widget types are the ones Drafter.get_widget_value/1 lists. Returns nil
when no widget has that id, and also when the app fails to answer within 1000 ms.
The ids of every widget matching selector.
selector is a String.t() in the forms Drafter.query_one/1 documents. Returns
[] when nothing matches, and also when the app fails to answer within 1000 ms.
The id of the first widget matching selector, or nil.
selector is a String.t() in the forms Drafter.query_one/1 documents.
Also returns nil when the app fails to answer within 1000 ms.
Asserts that no widget matching selector is in the hierarchy.
Returns :ok, or raises ExUnit.AssertionError when one matches. A macro, as
assert_widget_present/2 is.
The visible characters of each screen row, trailing blanks removed.
What the app currently has on screen, as plain text.
Replays the terminal writes onto a blank grid and returns the visible characters, one screen row per line.
assert Drafter.Test.screen_text(ctx) =~ "Counter: 3"
Injects a {:char, codepoint} event and returns once the app has handled it.
char is a codepoint integer, or a binary whose first byte is taken as the
codepoint — so only single-byte ASCII binaries round-trip correctly. Pass a
codepoint integer for anything above U+007F: send_char(ctx, "中") injects the
first UTF-8 byte, 228, while send_char(ctx, ?中) injects the character.
Returns :ok.
A real terminal sends printable ASCII as {:key, key}, not {:char, codepoint};
use send_key/3 to reproduce a plain letter press and this to reproduce a
non-ASCII one.
send_char(ctx, ?x)
send_char(ctx, 0x4E2D)
Clicks a widget by id and returns once the app has handled it.
widget_id is an atom — the :id given to an element, or the id query_one/2
returned. A left-button mouse_up is injected at the centre of the widget's
current rect, so the click lands on whatever is drawn on top there. Returns :ok;
an id that is not in the hierarchy is ignored and nothing is clicked.
send_click(ctx, query_one(ctx, "Button.primary"))
Clicks at a screen cell and returns once the app has handled it.
x and y are zero-based column and row. Injects a left-button
%{type: :mouse_up, ...} event, which is what activates a widget. Returns :ok.
Injects a key press and returns once the app has handled it.
key is a key atom as Drafter.Terminal.ANSI produces them: :enter, :up,
:f1, or a printable ASCII character as an atom (:q, :" "). modifiers is a
list drawn from :ctrl, :alt, and :shift; default [].
Injects {:key, key} when modifiers is empty and {:key, key, modifiers}
otherwise — the same shapes handle_event/2 and keybinding/3 match. Returns :ok.
send_key(ctx, :enter)
send_key(ctx, :s, [:ctrl])
Injects a raw mouse event and returns once the app has handled it.
event is the payload map, wrapped as {:mouse, event}. Its shapes are those
Drafter.Terminal.ANSI documents: %{type: :mouse_down | :mouse_up | :drag, button: button, x: x, y: y}, %{type: :move, x: x, y: y}, and
%{type: :scroll, direction: :up | :down | :left | :right, x: x, y: y}.
Returns :ok.
send_mouse(ctx, %{type: :scroll, direction: :down, x: 10, y: 5})
Starts app_module against an in-memory terminal and returns the test context.
props is the map handed to the app's mount/1; default %{}.
opts is a keyword list:
:size- the terminal size as a{columns, rows}tuple. Default{80, 24}.:test_pid- the process the headless driver notifies on each render. Defaultself(), which is whatawait_render/2waits on.
No other keys are read; unknown ones are silently ignored.
Returns a context map carrying :app_module, :app_pid, :app_monitor,
:props, :test_pid, and :session_pids. Raises RuntimeError if the app fails
to start. The app has completed its first render before this returns.
The headless driver is a globally registered process, so only one instance can run
at a time: a test using this must be async: false, and stop/1 must run before
the next one starts.
ctx = Drafter.Test.start_headless(MyApp, %{user_id: 7}, size: {120, 40})
@spec stop(map()) :: :ok
Stops the app and every service start_headless/3 started. Returns :ok.
Safe to call on an app that has already exited. Call it from on_exit/1 so a
failing test still frees the globally named services for the next one.
@spec sync(map()) :: :ok
Returns once every event injected before this call has been handled by the app.
An injected event passes from the headless driver to the event manager to the app loop. This makes a synchronous round trip to each of those in that order, so by the time it returns the event has been forwarded at every hop and processed by the loop.
Call this between injecting an event and asserting on state or screen contents.
@spec wait_for(map(), (map() -> as_boolean(term())), keyword()) :: :ok | :timeout
Polls condition_fn until it returns a truthy value.
condition_fn is a one-arity function called with ctx.
opts:
:timeout- milliseconds before giving up. Default1000.:interval- milliseconds between calls. Default50.
Returns :ok once the condition holds, or :timeout. The condition is evaluated
before the first sleep, so a condition that already holds returns immediately.
wait_for(ctx, fn ctx -> get_state(ctx).loaded end, timeout: 5000)