lib/mix/tasks/lockstep.replay.ex

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