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