defmodule Chaperon.Scenario do @moduledoc """ Helper module to be used by scenario definition modules. Imports `Chaperon.Session` and other helper modules for easy scenario definitions. ## Example defmodule MyScenario do use Chaperon.Scenario def init(session) do # Possibly do something with session before running scenario delay = :rand.uniform if delay > 0.5 do {:ok, session |> with_delay(delay |> seconds)} else {:ok, session} end end def run(session) do session |> post("/api/messages", json: %{message: "what's up?"}) |> get("/api/messages") end def with_delay(session, delay) do put_in session.config[:delay], delay end end """ defstruct module: nil @type t :: %Chaperon.Scenario{ module: module } defmodule Sequence do @moduledoc """ Implements `Chaperon.Scenario` and runs a configured list of scenarios in sequence, passing along any session assignments as config values to the next scenario in the list. Makes it easy to define a new scenario as a pipeline of a list of existing scenarios. Example usage: alias Chaperon.Scenario alias MyScenarios.{A, B, C} Chaperon.Worker.start( Scenario.Sequence, Scenario.Sequence.config_for([A, B, C]) ) """ alias Chaperon.Session def config_for(scenarios, config \\ %{}) do config |> Map.put(:compound_scenarios, scenarios) end def run(initial_session = %Session{config: %{compound_scenarios: scenarios}}) do scenarios |> Enum.reduce(initial_session, fn scenario, session -> session |> Session.run_scenario(scenario, session.assigned) end) end end defmacro __using__(_opts) do quote do require Logger require Chaperon.Scenario require Chaperon.Session require Chaperon.Session.Logging import Chaperon.Scenario import Chaperon.Timing import Chaperon.Session import Chaperon.Session.Logging @spec new_session(map) :: Chaperon.Session.t() def new_session(config) do scenario = %Chaperon.Scenario{module: __MODULE__} %Chaperon.Session{ id: Chaperon.Session.new_id(), name: session_name(scenario, config), scenario: scenario, config: config } end end end require Logger alias Chaperon.Session use Chaperon.Session.Logging alias Chaperon.Scenario @doc """ Runs a given scenario module with a given config and returns the scenario's session annotated with histogram metrics via the `Chaperon.Scenario.Metrics` module. The returned `Chaperon.Session` will include histogram data for all performed `Chaperon.Actionable`s, including for all run actions run asynchronously as part of the scenario. """ @spec execute(module, map) :: Session.t() def execute(scenario_mod, config) do scenario = %Scenario{module: scenario_mod} session = scenario |> new_session(config) session = scenario |> run(scenario_mod |> init(session)) scenario |> teardown(session) end @spec execute_nested(Scenario.t(), Session.t(), map) :: Session.t() def execute_nested(scenario, session, config) do session = scenario |> nested_session(session, config) session = scenario |> run(scenario.module |> init(session)) scenario |> teardown(session) end @spec run( Scenario.t(), Session.t() | {:ok, Session.t()} | {:error, any} ) :: Session.t() | {:error, any} def run(scenario, {:ok, session = %Session{cancellation: reason}}) when is_binary(reason) do scenario |> log_cancellation(session) end def run(scenario, {:ok, session}) do scenario |> run(session) end def run(scenario, {:error, reason}) do Logger.error("Error running #{scenario}: #{inspect(reason)}") {:error, reason} end def run(scenario, session = %Session{cancellation: reason}) when is_binary(reason) do scenario |> log_cancellation(session) end def run(scenario, session) do session |> log_info("Starting") session = session |> with_scenario(scenario, fn session -> session |> initial_delay |> scenario.module.run end) session = session.async_tasks |> Enum.reduce(session, fn {k, v}, acc -> acc |> Session.await(k, v) end) if session.config[:merge_scenario_sessions] do session else session |> Scenario.Metrics.add_histogram_metrics() end end defp log_cancellation(scenario, session = %Session{cancellation: reason}) do session |> log_warn("Skipping scenario #{scenario.module} due to cancellation: #{reason}") end defp with_scenario(session, scenario, func) do s2 = func.(%{session | scenario: scenario}) %{s2 | scenario: session.scenario} end @spec initial_delay(Session.t()) :: Session.t() def initial_delay(session = %Session{config: %{delay: delay}}) do session |> Session.delay(delay) end def initial_delay(session = %Session{config: %{random_delay: delay}}) do session |> Session.delay(:rand.uniform(delay)) end def initial_delay(session), do: session @doc """ Initializes a `Chaperon.Scenario` for a given `session`. If `scenario_mod` defines an `init/1` callback function, calls it with `session` and returns its return value. Otherwise defaults to returning `{:ok, session}`. """ @spec init(module, Session.t()) :: {:ok, Session.t()} def init(scenario_mod, session) do # for some reason Kernel.function_exported? only works on first compile # but not for successive runs. Must be some bug in the compiler ?? if scenario_mod.module_info(:exports)[:init] do session |> scenario_mod.init else {:ok, session} end end @doc """ Cleans up any resources after the Scenario was run (if needed). Can be overriden. If `scenario`'s implementation module defines a `teardown/1` callback function, calls it with `session` to clean up resources as needed. Returns the given session afterwards. """ @spec teardown(Scenario.t(), Session.t()) :: Session.t() def teardown(scenario, session) do if scenario.module.module_info(:exports)[:teardown] do session |> scenario.module.teardown end session end @spec new_session(Scenario.t(), map) :: Session.t() def new_session(scenario, config) do %Session{ id: Chaperon.Session.new_id(), name: session_name(scenario, config), scenario: scenario, config: config } end @spec nested_session(Scenario.t(), Session.t(), map) :: Session.t() def nested_session(scenario, session, config) do config = session.config |> DeepMerge.deep_merge(config) %{ session | id: session.id, name: session_name(scenario, config), scenario: scenario, config: config, cookies: session.cookies } end @doc """ Returns the name of a `Chaperon.Scenario` based on the `module` its referring to. ## Example iex> alias Chaperon.Scenario iex> Scenario.name %Scenario{module: Scenarios.Bruteforce.Login} "Scenarios.Bruteforce.Login" """ @spec name(Scenario.t()) :: String.t() def name(%Scenario{module: mod}), do: name(mod) def name(mod) when is_atom(mod), do: Chaperon.Util.module_name(mod) @spec session_name(Scenario.t(), map) :: String.t() def session_name(_scenario, %{name: name}), do: name def session_name(scenario, _config), do: scenario |> name end