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 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 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