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