defmodule Mix.Tasks.Lockstep.Replay do
@moduledoc """
Print a saved Lockstep trace as a human-readable schedule, or re-run
a specific test function under the recorded schedule.
## Print mode
mix lockstep.replay --trace traces/foo.lockstep
Prints metadata + the formatted schedule. Includes a ready-to-paste
recipe for reproducing the bug from a fresh test run.
## Run mode
mix lockstep.replay --trace traces/foo.lockstep \\
--run "MyApp.RaceTest.lost_update_body" \\
--file test/examples/foo_test.exs
Loads `--file` (so test modules become available; `mix compile`
doesn't compile test files), then calls
`Lockstep.Replay.run(&Module.function/0, trace_path)`. Run mode
raises `Lockstep.BugFound` if the bug reproduces (the expected
outcome) or `Lockstep.ReplayDivergence` if the user code is
nondeterministic.
"""
use Mix.Task
@shortdoc "Print or replay a saved Lockstep trace"
@impl true
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [trace: :string, run: :string, file: :string],
aliases: [t: :trace, r: :run, f: :file]
)
path = Keyword.get(opts, :trace) || Mix.raise("--trace <path> is required")
case Keyword.get(opts, :run) do
nil -> print_trace(path)
runref -> run_trace(path, runref, Keyword.get(opts, :file))
end
end
# ============================================================
# Print mode (no --run)
# ============================================================
defp print_trace(path) do
payload = Lockstep.Trace.load(path)
iter_seed = Map.get(payload, :iter_seed)
iteration = Map.get(payload, :iteration) || "?"
strategy = Map.get(payload, :strategy) || "?"
top_seed = Map.get(payload, :seed)
Mix.shell().info("""
Lockstep trace
path: #{path}
iteration: #{iteration}
top seed: #{inspect(top_seed)}
iter seed: #{inspect(iter_seed)}
strategy: #{inspect(strategy)}
max_steps: #{inspect(Map.get(payload, :max_steps))}
reason: #{format_reason(Map.get(payload, :reason))}
#{Lockstep.Trace.format(Map.get(payload, :trace, []))}
To reproduce this bug deterministically:
1) Re-run the suite with the same top seed and at least #{iteration} iterations:
use Lockstep.Test,
iterations: #{iteration},
strategy: #{inspect(strategy)},
seed: #{inspect(top_seed)}
2) Or, if your test body is a named function, replay it directly:
mix lockstep.replay --trace #{path} \\
--run "Mod.test_body" \\
--file test/path/to/test.exs
""")
end
defp format_reason(nil), do: "(none recorded)"
defp format_reason(reason), do: inspect(reason, limit: 8)
# ============================================================
# Run mode (--run Mod.fn)
# ============================================================
defp run_trace(path, runref, file) do
Mix.Task.run("loadpaths")
Mix.Task.run("compile")
if file do
# Test files do `use ExUnit.Case` which requires ExUnit to be started.
Application.ensure_all_started(:ex_unit)
ExUnit.start(autorun: false)
load_test_file(file)
end
{mod, fun} = parse_run_ref(runref)
Code.ensure_loaded?(mod) ||
Mix.raise(
"module #{inspect(mod)} not loaded; pass --file <path> if it lives in a test file"
)
function_exported?(mod, fun, 0) ||
Mix.raise("#{inspect(mod)}.#{fun}/0 is not exported")
Mix.shell().info("Replaying #{inspect(mod)}.#{fun}/0 against trace #{path} ...\n")
try do
Lockstep.Replay.run(fn -> apply(mod, fun, []) end, path)
Mix.shell().info("\nReplay finished WITHOUT raising -- the recorded bug did not re-fire.")
rescue
e in Lockstep.BugFound ->
Mix.shell().info("\n" <> Exception.message(e))
e in Lockstep.ReplayDivergence ->
Mix.shell().error("\n" <> Exception.message(e))
end
end
defp load_test_file(file) do
path = Path.expand(file)
if not File.exists?(path) do
Mix.raise("--file #{inspect(file)} does not exist")
end
Code.require_file(path)
end
defp parse_run_ref(ref) do
case String.split(ref, ".") |> Enum.reverse() do
[_only] ->
Mix.raise("--run must be \"Module.function\" (got #{inspect(ref)})")
[fun_str | mod_parts_rev] ->
mod_parts = Enum.reverse(mod_parts_rev) |> Enum.map(&String.to_atom/1)
{Module.concat(mod_parts), String.to_atom(fun_str)}
end
end
end