defmodule Runbox.Runtime.Sandbox do
@moduledoc group: :dev_tools
@moduledoc """
Sandbox is a helper for executing runs without started Altworx application.
Sandbox can be used for scenarios unit testing and also for interactive development, e.g. in
LiveBook.
This is a scenario-type agnostic version of `Runbox.Runtime.Stage.Sandbox` and
`Runbox.Runtime.Simple.Sandbox`. This module will identify the type of the scenario and will route
the request to the appropriate Sandbox implementation.
For interactive development of scenarios, run `iex --erl "-runbox mode slave" -S mix` from command
line in your scenarios application root directory and then use Sandbox functions to execute your
runs (of course use `recompile` before run execution if you changed scenario code).
"""
alias Mix.Project
alias Runbox.Message
alias Runbox.Scenario
alias Runbox.Scenario.OutputAction
alias Runbox.Scenario.Type
alias Runbox.ScenarioRelease.SlaveFunc
@type topics :: %{topic_name => [Message.t()]}
@type topic_name :: String.t()
@type opts :: [opt()]
@type opt :: {:start_from, non_neg_integer()} | {:modules, [module()]}
@callback execute_run(topics(), scenario_id :: String.t(), [opt() | {:scenario, Scenario.t()}]) ::
{:ok, [OutputAction.t()]} | {:error, any()}
@sandboxes %{
Type.simple() => Runbox.Runtime.Simple.Sandbox,
Type.stage() => Runbox.Runtime.Stage.Sandbox
}
@doc """
Starts a new run for `scenario_id` and processes all input messages.
Returns all output actions generated by run execution. Input messages for each runtime topic,
referenced by its logical name, are passed in the `topics` argument. Undefined topics are
considered empty.
There are few options that can be specified.
- `:start_from` - specify the start from value for the run. The value is used to filter out
messages and is passed to the scenraio's `init` callback.
- `:modules` - list of modules containing the scenario (manifest and template, which can be just
one module). If not specified, modules are auto-discovered from the current Mix app. This opens
up the possibility to write ad-hoc scenarios with only Runbox as dependency, e.g. in
[Livebook](https://livebook.dev/).
"""
@spec execute_run(topics(), scenario_id :: String.t(), opts()) ::
{:ok, [OutputAction.t()]} | {:error, any()}
def execute_run(topics, scenario_id, opts \\ []) do
modules = Keyword.get(opts, :modules) || modules_from_mix_app()
with {:ok, scenario} <- find_scenario(scenario_id, modules),
{:ok, module} <- find_sandbox(scenario.manifest.type) do
module.execute_run(topics, scenario_id, Keyword.put(opts, :scenario, scenario))
end
end
# for internal use between Sandboxes
@doc false
def modules_from_mix_app do
if Code.ensure_loaded?(Project) and function_exported?(Project, :config, 0) do
case Keyword.fetch(Project.config(), :app) do
{:ok, app} -> Application.spec(app, :modules)
:error -> nil
end
end
end
@doc false
def find_scenario(scenario_id, modules) do
case Enum.find(SlaveFunc.release_info(modules), &(&1.manifest.id == scenario_id)) do
nil -> {:error, :not_found}
scenario -> {:ok, scenario}
end
end
defp find_sandbox(type) do
with :error <- Map.fetch(@sandboxes, type) do
{:error, :sandbox_not_available}
end
end
end