defmodule Solana.TestValidator do
@moduledoc """
A [Solana Test Validator](https://docs.solana.com/developing/test-validator)
managed by an Elixir process. This allows you to run unit tests as if you had
the `solana-test-validator` tool running in another process.
## Requirements
Since `Solana.TestValidator` uses the `solana-test-validator` binary, you'll
need to have the [Solana tool
suite](https://docs.solana.com/cli/install-solana-cli-tools) installed.
## How to Use
You can use the `Solana.TestValidator` directly or in a supervision tree.
To use it directly, add the following lines to the beginning of your
`test/test_helper.exs` file:
```elixir
alias Solana.TestValidator
{:ok, validator} = TestValidator.start_link(ledger: "/tmp/test-ledger")
ExUnit.after_suite(fn _ -> TestValidator.stop(validator) end)
```
This will start and stop the `solana-test-validator` before and after your
tests run.
### In a supervision tree
Alternatively, you can add it to your application's supervision tree during
tests. Modify your `mix.exs` file to make the current environment available to
your application:
```elixir
def application do
[mod: {MyApp, env: Mix.env()}]
end
```
Then, adjust your application's `children` depending on the environment:
```elixir
defmodule MyApp do
use Application
def start(_type, env: env) do
Supervisor.start_link(children(env), strategy: :one_for_one)
end
defp children(:test) do
[
{Solana.TestValidator, ledger: "/tmp/test_ledger"},
# ... other children
]
end
defp children(_) do
# ...other children
end
end
```
### Options
You can pass any of the **long-form** options you would pass to a
`solana-test-validator` here.
For example, to add your own program to the validator, set the `bpf_program`
option as the path to your program's [build
artifact](https://docs.solana.com/developing/on-chain-programs/developing-rust#how-to-build).
See `Solana.TestValidator.start_link/1` for more details.
"""
use GenServer
require Logger
@schema [
bind_address: [type: :string, default: "0.0.0.0"],
bpf_program: [type: {:or, [:string, {:list, :string}]}],
clone: [type: {:custom, Solana, :pubkey, []}],
config: [type: :string, default: Path.expand("~/.config/solana/cli/config.yml")],
dynamic_port_range: [type: :string, default: "1024-65535"],
faucet_port: [type: :pos_integer, default: 9900],
faucet_sol: [type: :pos_integer, default: 1_000_000],
gossip_host: [type: :string, default: "127.0.0.1"],
gossip_port: [type: :pos_integer],
url: [type: :string],
ledger: [type: :string, default: "test-ledger"],
limit_ledger_size: [type: :pos_integer, default: 10_000],
mint: [type: {:custom, Solana, :pubkey, []}],
rpc_port: [type: :pos_integer, default: 8899],
slots_per_epoch: [type: :pos_integer],
warp_slot: [type: :string]
]
@doc """
Starts a `Solana.TestValidator` process linked to the current process.
This process runs and monitors a `solana-test-validator` in the background.
## Options
#{NimbleOptions.docs(@schema)}
"""
def start_link(config) do
case NimbleOptions.validate(config, @schema) do
{:ok, opts} -> GenServer.start_link(__MODULE__, opts, name: __MODULE__)
error -> error
end
end
@doc """
Stops a `Solana.TestValidator` process.
Should be called when you want to stop the `solana-test-validator`.
"""
def stop(validator), do: GenServer.stop(validator, :normal)
@doc """
Gets the state of a `Solana.TestValidator` process.
This is useful when you want to check the latest output of the
`solana-test-validator`.
"""
def get_state(validator), do: :sys.get_state(validator)
# Callbacks
@doc false
def init(opts) do
with ex_path when not is_nil(ex_path) <- System.find_executable("solana-test-validator"),
ledger = Keyword.get(opts, :ledger),
true <- File.exists?(Path.dirname(ledger)) do
Process.flag(:trap_exit, true)
port =
Port.open({:spawn_executable, wrapper_path()}, [
:binary,
:exit_status,
args: [ex_path | to_arg_list(opts)]
])
Port.monitor(port)
{:ok, %{port: port, latest_output: nil, exit_status: nil, ledger: ledger}}
else
false ->
Logger.error("requested ledger directory does not exist")
{:stop, :no_dir}
nil ->
Logger.error("solana-test-validator executable not found, make sure it's in your PATH")
{:stop, :no_validator}
end
end
defp to_arg_list(args) do
args
|> Enum.map(fn {k, v} -> {Atom.to_string(k), v} end)
|> Enum.map(&handle_multiples/1)
|> List.flatten()
|> Enum.map(fn {k, v} -> ["--", String.replace(k, "_", "-"), " ", to_string(v), " "] end)
|> IO.iodata_to_binary()
|> String.trim()
|> String.split()
end
defp handle_multiples({name, list}) when is_list(list) do
Enum.map(list, &{name, &1})
end
defp handle_multiples(other), do: other
@doc false
def terminate(reason, %{port: port}) do
os_pid = port |> Port.info() |> Keyword.get(:os_pid)
# if reason == :normal, do: File.rm_rf(ledger)
Logger.info("** stopped solana-test-validator (pid #{os_pid}): #{inspect(reason)}")
:normal
end
@doc false
def handle_info({port, {:data, text}}, state = %{port: port}) do
{:noreply, %{state | latest_output: String.trim(text)}}
end
@doc false
def handle_info({port, {:exit_status, status}}, state = %{port: port}) do
{:noreply, %{state | exit_status: status}}
end
@doc false
def handle_info({:DOWN, _ref, :port, _port, :normal}, state) do
{:noreply, state}
end
@doc false
def handle_info({:EXIT, _port, :normal}, state) do
{:noreply, state}
end
@doc false
def handle_info(other, state) do
Logger.info("unhandled message: #{inspect(other)}")
{:noreply, state}
end
defp wrapper_path() do
Path.expand(Path.join(Path.dirname(__ENV__.file), "./bin/wrapper-unix"))
end
end