Raxol.Headless (Raxol v2.7.0)

View Source

Manages headless Raxol application sessions for non-interactive use.

Starts TEA apps in :agent environment (no terminal driver, no IO output) and provides functions to inspect screen state, send keystrokes, and read the application model. Designed for use via Tidewave project_eval or programmatic testing.

Usage

# Start a session from a module
{:ok, :demo} = Raxol.Headless.start(RaxolDemo, id: :demo)

# Start from an example script (compiles module, skips boot code)
{:ok, :demo} = Raxol.Headless.start("examples/demo.exs", id: :demo)

# Take a text screenshot
{:ok, text} = Raxol.Headless.screenshot(:demo)

# Send a key and see the result
{:ok, text} = Raxol.Headless.send_key_and_screenshot(:demo, :tab)

# Inspect the model
{:ok, model} = Raxol.Headless.get_model(:demo)

# Stop
:ok = Raxol.Headless.stop(:demo)

Summary

Types

Why a name could not become a runnable module.

Functions

Returns a specification to start this module under a supervisor.

Returns the raw screen buffer for the session's current frame.

Returns the application model from the session's dispatcher.

Lists all active sessions. Returns [] if the Headless server is not running.

Takes a text screenshot of the session's current screen.

Sends a key event to the session's dispatcher.

Sends a key and returns a screenshot after waiting for re-render.

Delivers msg to the application's update/2 and waits for it to fold.

Sends a terminal resize event to the session's dispatcher.

Starts a headless session.

Starts the Headless session manager.

Stops a headless session.

Types

module_refusal()

@type module_refusal() ::
  {:module_not_found, module()} | {:not_a_raxol_application, module()}

Why a name could not become a runnable module.

:not_a_raxol_application is deliberately distinct from :module_not_found. One says nothing on the code path answers to that name at all; the other says the name resolved to real code that does not implement TEA. An operator fixes those two by doing completely different things, so collapsing them into one refusal costs them the diagnosis.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get_buffer(id)

@spec get_buffer(atom()) :: {:ok, map()} | {:error, term()}

Returns the raw screen buffer for the session's current frame.

Unlike screenshot/1, which returns a text capture, this returns the Raxol.Core.Buffer itself, preserving per-cell style for downstream rendering targets such as the LiveView-to-video pipeline.

get_model(id)

@spec get_model(atom()) :: {:ok, term()} | {:error, term()}

Returns the application model from the session's dispatcher.

list()

@spec list() :: [atom()]

Lists all active sessions. Returns [] if the Headless server is not running.

screenshot(id)

@spec screenshot(atom()) :: {:ok, String.t()} | {:error, term()}

Takes a text screenshot of the session's current screen.

send_key(id, key, opts \\ [])

@spec send_key(atom(), String.t() | atom(), keyword()) :: :ok | {:error, term()}

Sends a key event to the session's dispatcher.

send_key_and_screenshot(id, key, opts \\ [])

@spec send_key_and_screenshot(atom(), String.t() | atom(), keyword()) ::
  {:ok, String.t()} | {:error, term()}

Sends a key and returns a screenshot after waiting for re-render.

Options

  • :wait_ms - Milliseconds to wait for dispatch processing (default: 50)
  • All key modifier options (:ctrl, :alt, :shift)

send_message(id, msg)

@spec send_message(atom(), term()) :: :ok | {:error, term()}

Delivers msg to the application's update/2 and waits for it to fold.

The message takes the same path a subscription tick does, so send_message(id, :tick) is indistinguishable from the app's own subscribe_interval firing. Paired with subscriptions: false at start, this is how a caller advances an app by an exact number of ticks: the return means the model has folded the message, so a following get_buffer/1 renders its effect.

send_resize(id, width, height)

@spec send_resize(atom(), pos_integer(), pos_integer()) :: :ok | {:error, term()}

Sends a terminal resize event to the session's dispatcher.

The dispatcher forwards the new dimensions to the rendering engine (resizing its buffer) and to the application's update/2 as a %Event{type: :resize, data: %{width: w, height: h}}.

start(module_or_path, opts \\ [])

@spec start(
  module() | String.t(),
  keyword()
) :: {:ok, atom()} | {:error, term()}

Starts a headless session.

First argument is either a module atom or a file path string. Either way the module has to be a Raxol application: it must export init/1, update/2 and view/1. Declaring Raxol.Core.Runtime.Application is not sufficient on its own, since the attribute can be present with none of the callbacks behind it. When given a path, the file is compiled and the first module meeting that contract is used.

Passing a path executes code

Compiling a defmodule runs its body, so whoever chooses the path string chooses what runs in this VM, with the VM's privileges. That is arbitrary code execution, not a file read, and it is a property of this function rather than of any one caller.

The AST filter that keeps only defmodule nodes is a convenience -- it skips an example script's boot code -- and never a sandbox. What bounds the compile is a monitored child on a budget, which bounds the DAMAGE to this process (a body that raises, throws, exits, or never returns is answered rather than taking the session manager down with it). It does not bound what the body may do while it runs.

So the path argument is only ever as safe as the caller that chooses it. Callers that take one from outside the VM must confine it themselves: Raxol.Headless.McpTools refuses the argument entirely unless a deployment sets RAXOL_HEADLESS_PATH_ROOT, and then confines it under that root with Raxol.Core.Boundary.Path.confine/3. The programmatic callers (Raxol.Recording.Video, Raxol.MCP.Test) pass a path they already chose.

The module argument does not compile source, but loading a beam can execute an @on_load callback. The TEA contract is checked after the module loads and before a session starts; callers must therefore treat the VM's code path as trusted deployment input.

Options

  • :id - Session identifier (default: module name as atom)
  • :width - Screen width (default: 120)
  • :height - Screen height (default: 40)
  • :subscriptions - false boots the app with its declared subscriptions (timers, event sources) unarmed, so the model advances only on messages delivered explicitly via send_message/2 or send_key/3. This is what makes a recording reproducible: with a timer running, which tick a sampled frame shows is a property of scheduler timing rather than of the caller. (default: true)

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts the Headless session manager.

stop(id)

@spec stop(atom()) :: :ok | {:error, term()}

Stops a headless session.