Macaw.Host.Ctl.Recorder (macaw_host v0.1.4)

Copy Markdown View Source

A recording stand-in for a generated script's Ctl.World, so an app's tests drive every <app>-ctl command without docker on the box and assert each command's exact docker argv.

It ships in lib/ rather than test/support/ because a dependency's test support is not compiled into the app that depends on it: this way every app's tests use the one recorder that matches the generated Ctl.World, instead of copying it. It has no dependencies and does nothing until install/2 names it in the calling process.

# test/test_helper.exs: once per VM, before any test runs, since the
# script's modules (`Ctl`, `Ctl.Env` …) are global.
Macaw.Host.Ctl.Recorder.load!("container/my_app-ctl")

# a test, with `@moduletag :tmp_dir`
alias Macaw.Host.Ctl.Recorder

test "start runs the container", %{tmp_dir: tmp_dir} do
  {script, _home} = Recorder.home!(tmp_dir, "my_app-ctl")
  Recorder.install([{["docker"], {"", 0}}])
  Ctl.dispatch(["start"], %{}, script)
  run = Enum.find(Recorder.docker_calls(), &match?(["run", "-d" | _], &1))
  assert ["run", "-d", "--name", "my_app" | _] = run
end

install/2 takes the canned answers: a list of {matcher, reply}, the matcher a list the command's [exe | args] must start with, or a function of (exe, args), and the reply {output, status}. The first matching entry answers; anything unmatched is {"", 1}, a docker that says no. Every call is recorded, with its options (where a secret's value travels, in :env). Everything lives in the calling process's dictionary, so async tests do not see each other's calls.

Summary

Functions

Every call so far, in order: {exe, args, opts}, {:sleep, ms} or {:prompt, question}.

The argv of each docker invocation, in order.

A host under tmp_dir: home/data/ and a script script_name naming home as its data directory. {script, home}.

Makes this process's Ctl.World the recorder. Options: :answer, what prompt/1 and prompt_secret/1 answer (default "").

Loads a generated script's modules into this VM without running it: :ctl_no_main keeps its last line from calling main/1. Loading the same path again is a no-op; a script is loaded once per VM, as its modules are global.

Writes a minimal script at path: its Ctl.Home naming data_dir, the only part of the file the loaded modules read from disk.

Functions

calls()

@spec calls() :: [tuple()]

Every call so far, in order: {exe, args, opts}, {:sleep, ms} or {:prompt, question}.

docker_calls()

@spec docker_calls() :: [[String.t()]]

The argv of each docker invocation, in order.

home!(tmp_dir, script_name)

@spec home!(Path.t(), String.t()) :: {Path.t(), Path.t()}

A host under tmp_dir: home/data/ and a script script_name naming home as its data directory. {script, home}.

install(replies \\ [], opts \\ [])

@spec install(
  [{list() | (String.t(), [String.t()] -> boolean()), {String.t(), integer()}}],
  keyword()
) ::
  :ok

Makes this process's Ctl.World the recorder. Options: :answer, what prompt/1 and prompt_secret/1 answer (default "").

load!(path)

@spec load!(Path.t()) :: :ok

Loads a generated script's modules into this VM without running it: :ctl_no_main keeps its last line from calling main/1. Loading the same path again is a no-op; a script is loaded once per VM, as its modules are global.

script!(path, data_dir \\ nil)

@spec script!(Path.t(), Path.t() | nil) :: Path.t()

Writes a minimal script at path: its Ctl.Home naming data_dir, the only part of the file the loaded modules read from disk.