defmodule Runbox.Runtime.Simple.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 module is only for Simple scenarios. 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 Runbox.Message alias Runbox.RunStartContext alias Runbox.Runtime.OutputAction, as: RuntimeOutputAction alias Runbox.Runtime.Sandbox alias Runbox.Runtime.Simple.TemplateCarrier alias Runbox.Scenario.OutputAction alias Runbox.Scenario.Simple alias Runbox.StateStore.Entity @behaviour Sandbox @default_start_from 0 @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 scenario'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/). """ @impl Sandbox @spec execute_run(Sandbox.topics(), scenario_id :: String.t(), Sandbox.opts()) :: {:ok, [OutputAction.t()]} | {:error, any()} def execute_run(topics, scenario_id, opts \\ []) do start_from = Keyword.get(opts, :start_from, @default_start_from) Sandbox.setup_signer() with {:ok, scenario} <- get_scenario(scenario_id, opts), # Simple scenarios always have single template [template] <- scenario.templates, {:ok, input_topics} <- Simple.input_topics(template.module), :ok <- maybe_execute_on_start(scenario), {:ok, state, init_output_actions} <- init(scenario, template, start_from), messages = prepare_messages(topics, input_topics, start_from), {:ok, message_output_actions} <- handle_messages(messages, state) do {:ok, process_outputs(init_output_actions ++ message_output_actions)} end end defp get_scenario(id, opts) do scenario = Keyword.get(opts, :scenario) if scenario do {:ok, scenario} else modules = Keyword.get(opts, :modules) || Sandbox.modules_from_mix_app() Sandbox.find_scenario(id, modules) end end # Init the scenario - simulate the start of the template carrier, including executing the init # phase. defp init(scenario, template, start_from) do run_id = UUID.uuid4() args = %{ run_id: run_id, config: %{ module: template.module, subscribe_to: [:fake_sub], scenario_id: scenario.manifest.id, start_from: start_from, start_or_continue: :start }, state_entity: Entity.new(run_id, :template, :none, start_from, nil) } {:producer_consumer, state, _} = TemplateCarrier.init({args, nil, %RunStartContext{components_pids: %{fake_sub: self()}}}) receive do :init_state -> :ok end init_template(state) end # Execute the initialize phase of the template. defp init_template(state) do {:noreply, outputs, state} = TemplateCarrier.handle_info(:init_state, state) {:ok, state, outputs} end # Prepare messages to be handled later. This includes converting it from the sandbox format into # just a list of messages, filtering out messages we are not interested in etc. defp prepare_messages(messages_in_topics, input_topics, start_from) do consumed_topics = Map.new(input_topics, fn {topic, type} -> {topic, type} topic -> {topic, :input} end) messages_in_topics |> Enum.filter(fn {topic, _} -> Map.has_key?(consumed_topics, topic) end) |> Enum.flat_map(fn {topic, messages} -> Enum.map(messages, fn %Message{} = msg -> %{msg | from: topic} end) end) |> Enum.filter(&(consumed_topics[&1.from] == :load || &1.timestamp >= start_from)) |> Enum.sort_by(fn %Message{timestamp: time} -> time end) end defp handle_messages(messages, state) do {:noreply, outputs, _state} = TemplateCarrier.handle_events(messages, nil, state) {:ok, outputs} end # Convert outputs to the final format and remove non-OA outputs. defp process_outputs(outputs) do Enum.flat_map(outputs, fn %RuntimeOutputAction{} = oa -> [ %OutputAction{ body: oa.body, timestamp: oa.timestamp } ] # other non-OA stuff the TemplateCarrier might produce, like ticks _other -> [] end) end defp maybe_execute_on_start(scenario) do case scenario.opts[:on_start] do {m, f, a} -> apply(m, f, a) nil -> :ok end end end