Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/plugins/testing/plugin_test_framework.ex
defmodule PluginRunner do
@moduledoc """
Mock plugin runner for testing
"""
def load_plugin(terminal, _plugin_module, _config) do
{:ok, terminal}
end
end
defmodule MockTerminal do
@moduledoc """
Mock terminal for testing plugins
"""
def new(width, height, _opts) do
%{
width: width,
height: height,
buffer: %{},
loaded_plugins: [],
visible_panels: [],
status_line: "",
state: %{}
}
end
def get_buffer(terminal) do
terminal.buffer
end
def get_loaded_plugins(terminal) do
terminal.loaded_plugins
end
def get_panel_content(_terminal, _plugin_name) do
""
end
def get_state(terminal) do
terminal.state
end
def get_status_line(terminal) do
terminal.status_line
end
def get_visible_panels(terminal) do
terminal.visible_panels
end
def send_keypress(terminal, _key) do
# Mock implementation
{:ok, terminal}
end
end
defmodule Raxol.Plugins.Testing.PluginTestFramework do
@moduledoc """
Plugin Testing Framework for Raxol
Provides comprehensive testing utilities for plugin development including:
- Plugin manifest validation
- Mock terminal environments
- UI rendering tests
- Keyboard event simulation
- Integration testing helpers
- Performance benchmarking
"""
defmacro __using__(opts) do
quote do
use ExUnit.Case, unquote(opts)
import Raxol.Plugins.Testing.PluginTestFramework
import Raxol.Plugins.Testing.Assertions
import Raxol.Plugins.Testing.Helpers
import Raxol.Plugins.Testing.FrameworkHelpers
alias Raxol.Plugins.Testing.MockTerminal
alias Raxol.Plugins.Testing.PluginRunner
end
end
@doc """
Validates a plugin manifest against the required schema
"""
def validate_manifest(plugin_module) do
manifest = plugin_module.manifest()
with :ok <- validate_required_fields(manifest),
:ok <- validate_field_types(manifest),
:ok <- validate_capabilities(manifest),
:ok <- validate_config_schema(manifest) do
{:ok, manifest}
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Creates a mock terminal environment for testing
"""
def create_mock_terminal(opts \\ []) do
width = Keyword.get(opts, :width, 80)
height = Keyword.get(opts, :height, 24)
MockTerminal.new(width, height, opts)
end
@doc """
Loads a plugin in the test environment
"""
def load_plugin(terminal, plugin_module, config \\ %{}) do
case validate_manifest(plugin_module) do
{:ok, _manifest} ->
PluginRunner.load_plugin(terminal, plugin_module, config)
{:error, reason} ->
{:error, {:invalid_manifest, reason}}
end
end
@doc """
Simulates a keypress event
"""
def send_keypress(terminal, key) do
MockTerminal.send_keypress(terminal, key)
end
@doc """
Simulates multiple keypress events
"""
def send_keypresses(terminal, keys) when is_list(keys) do
Enum.each(keys, fn key ->
_ = send_keypress(terminal, key)
# Small delay to simulate realistic typing
Process.sleep(10)
end)
end
@doc """
Gets the current terminal buffer content
"""
def get_terminal_buffer(terminal) do
MockTerminal.get_buffer(terminal)
end
@doc """
Waits for a plugin to reach a specific state
"""
def wait_for_state(plugin_pid, expected_state, timeout \\ 5000) do
start_time = System.monotonic_time(:millisecond)
wait_for_state_loop(plugin_pid, expected_state, start_time, timeout)
end
defp wait_for_state_loop(plugin_pid, expected_state, start_time, timeout) do
current_time = System.monotonic_time(:millisecond)
if current_time - start_time > timeout do
{:error, :timeout}
else
current_state = GenServer.call(plugin_pid, :get_state)
if states_match?(current_state, expected_state) do
{:ok, current_state}
else
Process.sleep(50)
wait_for_state_loop(plugin_pid, expected_state, start_time, timeout)
end
end
end
defp states_match?(current, expected) when is_map(expected) do
Enum.all?(expected, fn {key, value} ->
Map.get(current, key) == value
end)
end
defp states_match?(current, expected), do: current == expected
# Private validation functions
defp validate_required_fields(manifest) do
required = [:name, :version, :description, :author]
missing =
Enum.filter(required, fn field ->
not Map.has_key?(manifest, field) or is_nil(Map.get(manifest, field))
end)
if Enum.empty?(missing) do
:ok
else
{:error, {:missing_fields, missing}}
end
end
defp validate_field_types(manifest) do
validations = [
{:name, :string},
{:version, :string},
{:description, :string},
{:author, :string},
{:capabilities, :list},
{:dependencies, :map},
{:config_schema, :map}
]
errors =
Enum.flat_map(validations, fn {field, expected_type} ->
value = Map.get(manifest, field)
if value != nil and not type?(value, expected_type) do
[{field, expected_type, typeof(value)}]
else
[]
end
end)
if Enum.empty?(errors) do
:ok
else
{:error, {:type_errors, errors}}
end
end
defp validate_capabilities(manifest) do
valid_capabilities = [
:ui_panel,
:keyboard_input,
:shell_command,
:file_system,
:file_watcher,
:status_line,
:theme_provider,
:clipboard,
:network
]
capabilities = Map.get(manifest, :capabilities, [])
invalid =
Enum.filter(capabilities, fn cap ->
cap not in valid_capabilities
end)
if Enum.empty?(invalid) do
:ok
else
{:error, {:invalid_capabilities, invalid}}
end
end
defp validate_config_schema(manifest) do
case Map.get(manifest, :config_schema) do
nil ->
:ok
schema when is_map(schema) ->
validate_config_fields(schema)
_ ->
{:error, :invalid_config_schema}
end
end
defp validate_config_fields(schema) do
errors =
Enum.flat_map(schema, fn {field_name, field_config} ->
validate_config_field(field_name, field_config)
end)
if Enum.empty?(errors) do
:ok
else
{:error, {:config_schema_errors, errors}}
end
end
defp validate_config_field(field_name, field_config) do
errors = []
# Check required keys
errors =
case Map.has_key?(field_config, :type) do
true -> errors
false -> [{field_name, :missing_type} | errors]
end
# Check type validity
errors =
case Map.get(field_config, :type) do
type when type in [:string, :integer, :boolean, :list, :map] -> errors
type -> [{field_name, {:invalid_type, type}} | errors]
end
errors
end
defp type?(value, :string), do: is_binary(value)
defp type?(value, :integer), do: is_integer(value)
defp type?(value, :boolean), do: is_boolean(value)
defp type?(value, :list), do: is_list(value)
defp type?(value, :map), do: is_map(value)
defp typeof(value) when is_binary(value), do: :string
defp typeof(value) when is_integer(value), do: :integer
defp typeof(value) when is_boolean(value), do: :boolean
defp typeof(value) when is_list(value), do: :list
defp typeof(value) when is_map(value), do: :map
defp typeof(_), do: :unknown
end
defmodule Raxol.Plugins.Testing.FrameworkAssertions do
@moduledoc """
Custom assertions for plugin testing
"""
import ExUnit.Assertions
def assert_plugin_loaded(terminal, plugin_name) do
loaded_plugins = MockTerminal.get_loaded_plugins(terminal)
assert plugin_name in loaded_plugins,
"Expected plugin '#{plugin_name}' to be loaded, but got: #{inspect(loaded_plugins)}"
end
def assert_panel_visible(terminal, plugin_name) do
visible_panels = MockTerminal.get_visible_panels(terminal)
assert plugin_name in visible_panels,
"Expected panel for '#{plugin_name}' to be visible, but got: #{inspect(visible_panels)}"
end
def assert_panel_content(terminal, plugin_name, expected_content) do
actual_content = MockTerminal.get_panel_content(terminal, plugin_name)
case expected_content do
content when is_binary(content) ->
assert String.contains?(actual_content, content),
"Expected panel content to contain '#{content}', but got: #{actual_content}"
%Regex{} = regex ->
assert Regex.match?(regex, actual_content),
"Expected panel content to match #{inspect(regex)}, but got: #{actual_content}"
content when is_list(content) ->
Enum.each(content, fn line ->
assert String.contains?(actual_content, line),
"Expected panel content to contain '#{line}', but got: #{actual_content}"
end)
end
end
def assert_status_line_contains(terminal, expected_text) do
status_line = MockTerminal.get_status_line(terminal)
assert String.contains?(status_line, expected_text),
"Expected status line to contain '#{expected_text}', but got: '#{status_line}'"
end
def assert_keypress_handled(terminal, key) do
# Send keypress and check if it was handled
old_state = MockTerminal.get_state(terminal)
_ = MockTerminal.send_keypress(terminal, key)
new_state = MockTerminal.get_state(terminal)
refute old_state == new_state,
"Expected keypress '#{key}' to change terminal state, but it was ignored"
end
def assert_plugin_state(plugin_pid, expected_state) do
actual_state = GenServer.call(plugin_pid, :get_state)
case expected_state do
state when is_map(state) ->
Enum.each(state, fn {key, expected_value} ->
actual_value = Map.get(actual_state, key)
assert actual_value == expected_value,
"Expected plugin state.#{key} to be #{inspect(expected_value)}, but got #{inspect(actual_value)}"
end)
state ->
assert actual_state == state,
"Expected plugin state to be #{inspect(state)}, but got #{inspect(actual_state)}"
end
end
def assert_plugin_output(plugin_pid, expected_output) do
output = GenServer.call(plugin_pid, :get_output)
assert output == expected_output,
"Expected plugin output to be #{inspect(expected_output)}, but got #{inspect(output)}"
end
end
defmodule Raxol.Plugins.Testing.FrameworkHelpers do
@moduledoc """
Test helper functions for common plugin testing scenarios.
Note: This module provides additional helpers specific to the PluginTestFramework.
For basic test helpers, see Raxol.Plugins.Testing.Helpers.
"""
@doc """
Creates a test config for git plugin testing with git-specific defaults.
Returns as keyword list for compatibility with BaseManager.
"""
def create_git_test_config(overrides \\ %{}) do
default_config = %{
enabled: true,
hotkey: "ctrl+t",
panel_width: 30,
refresh_interval: 1000,
auto_refresh: false,
show_untracked: true,
show_ignored: false,
position: "right",
graph_depth: 20,
diff_context: 3
}
# Return as keyword list for compatibility with BaseManager
default_config
|> Map.merge(overrides)
|> Map.to_list()
end
def simulate_file_change(plugin_pid, path, events \\ [:modified]) do
GenServer.cast(plugin_pid, {:file_event, path, events})
end
def simulate_timer_tick(plugin_pid) do
send(plugin_pid, :refresh)
end
def capture_plugin_logs(fun) do
ExUnit.CaptureLog.capture_log(fun)
end
def with_temp_directory(fun) do
# Force use of /tmp directly to avoid macOS symlink issues with /private/tmp
temp_dir = Path.join("/tmp", "plugin_test_#{:rand.uniform(1_000_000)}")
File.mkdir_p!(temp_dir)
# Always use the real path to ensure git commands work properly
real_temp_dir = Path.expand(temp_dir)
try do
fun.(real_temp_dir)
after
File.rm_rf!(temp_dir)
end
end
def create_temp_directory do
# Create temp directory but don't clean it up automatically
temp_dir = Path.join("/tmp", "plugin_test_#{:rand.uniform(1_000_000)}")
File.mkdir_p!(temp_dir)
Path.expand(temp_dir)
end
def cleanup_temp_directory(temp_dir) do
File.rm_rf!(temp_dir)
end
def create_test_files(base_path, file_specs) do
Enum.each(file_specs, fn
{path, :directory} ->
File.mkdir_p!(Path.join(base_path, path))
{path, content} when is_binary(content) ->
full_path = Path.join(base_path, path)
File.mkdir_p!(Path.dirname(full_path))
File.write!(full_path, content)
end)
end
end
defmodule Raxol.Plugins.Testing.MockTerminal do
@moduledoc """
Mock terminal implementation for plugin testing
"""
use Raxol.Core.Behaviours.BaseManager
defstruct [
:width,
:height,
:buffer,
:loaded_plugins,
:visible_panels,
:panel_contents,
:status_line,
:cursor_position,
:state
]
def new(width, height, opts \\ []) do
{:ok, pid} = start_link({width, height, opts})
pid
end
def send_keypress(terminal, key) do
GenServer.cast(terminal, {:keypress, key})
end
def get_buffer(terminal) do
GenServer.call(terminal, :get_buffer)
end
def get_loaded_plugins(terminal) do
GenServer.call(terminal, :get_loaded_plugins)
end
def get_visible_panels(terminal) do
GenServer.call(terminal, :get_visible_panels)
end
def get_panel_content(terminal, plugin_name) do
GenServer.call(terminal, {:get_panel_content, plugin_name})
end
def get_status_line(terminal) do
GenServer.call(terminal, :get_status_line)
end
def get_state(terminal) do
GenServer.call(terminal, :get_state)
end
# BaseManager callbacks
@impl true
def init_manager({width, height, _opts}) do
state = %__MODULE__{
width: width,
height: height,
buffer: create_empty_buffer(width, height),
loaded_plugins: [],
visible_panels: [],
panel_contents: %{},
status_line: "",
cursor_position: {0, 0},
state: %{}
}
{:ok, state}
end
@impl true
def handle_manager_call(:get_buffer, _from, state) do
{:reply, state.buffer, state}
end
def handle_manager_call(:get_loaded_plugins, _from, state) do
{:reply, state.loaded_plugins, state}
end
def handle_manager_call(:get_visible_panels, _from, state) do
{:reply, state.visible_panels, state}
end
def handle_manager_call({:get_panel_content, plugin_name}, _from, state) do
content = Map.get(state.panel_contents, plugin_name, "")
{:reply, content, state}
end
def handle_manager_call(:get_status_line, _from, state) do
{:reply, state.status_line, state}
end
def handle_manager_call(:get_state, _from, state) do
{:reply, state.state, state}
end
@impl true
def handle_manager_cast({:keypress, key}, state) do
# Simulate keypress handling
new_state = handle_keypress_simulation(key, state)
{:noreply, new_state}
end
def handle_manager_cast({:register_plugin, plugin_name, _pid}, state) do
# Add plugin to loaded plugins list
new_state = %{state | loaded_plugins: [plugin_name | state.loaded_plugins]}
{:noreply, new_state}
end
defp create_empty_buffer(width, height) do
for _y <- 0..(height - 1) do
for _x <- 0..(width - 1) do
%{char: " ", style: %{}}
end
end
end
defp handle_keypress_simulation(key, state) do
# Update state to indicate keypress was handled
new_internal_state = Map.put(state.state, :last_keypress, key)
%{state | state: new_internal_state}
end
end
defmodule Raxol.Plugins.Testing.PluginRunner do
@moduledoc """
Plugin runner for test environments
"""
def load_plugin(terminal, plugin_module, config) do
case plugin_module.start_link(config) do
{:ok, pid} ->
# Register plugin with mock terminal
GenServer.cast(
terminal,
{:register_plugin, plugin_module.manifest().name, pid}
)
{:ok, pid}
error ->
error
end
end
end
defmodule Raxol.Plugins.Testing.BenchmarkHelper do
@moduledoc """
Benchmarking utilities for plugin performance testing
"""
def benchmark_plugin_render(plugin_pid, iterations \\ 1000) do
{time, _result} =
:timer.tc(fn ->
Enum.each(1..iterations, fn _ ->
GenServer.call(plugin_pid, {:render_panel, 80, 24})
end)
end)
time_per_iteration = time / iterations
%{
total_time: time,
iterations: iterations,
time_per_iteration: time_per_iteration,
fps: 1_000_000 / time_per_iteration
}
end
def benchmark_keypress_handling(plugin_pid, keys, iterations \\ 100) do
{time, _result} =
:timer.tc(fn ->
Enum.each(1..iterations, fn _ ->
Enum.each(keys, fn key ->
GenServer.call(plugin_pid, {:handle_keypress, key})
end)
end)
end)
total_keypresses = length(keys) * iterations
time_per_keypress = time / total_keypresses
%{
total_time: time,
total_keypresses: total_keypresses,
time_per_keypress: time_per_keypress
}
end
end