defmodule MixTester do @moduledoc """ Small tool to automate mix project creation specifically for testing. """ alias MixTester.AST defmodule Project do @moduledoc """ Structure which contains path to project root directory and a name of the project """ defstruct [:root, :name, env: %{}] @type t :: %__MODULE__{root: Path.t(), name: String.t(), env: %{String.t() => String.t()}} end @typedoc "Output and exit code returned by executed command" @type command_result :: {binary(), non_neg_integer()} @type application_env :: %{ (config_file :: String.t()) => %{{app :: atom(), key :: atom()} => value :: any()} } @typedoc """ - `:name` (string) — Name of the mix project (doesn't have to be unique) - `:new` (string) — String of options for `mix new name` - `:application_env` (application_env) — A configuration for the project - `:system_env` (env maps) — System env configuration - `:project` - A kv to override what's written in `mix.exs`'s `project` function - `:application` - A kv to override what's written in `mix.exs`'s `application` function """ @type setup_option :: {:name, String.t()} | {:new, String.t()} | {:application_env, application_env()} @typedoc """ Accepts same options as `System.cmd` function """ @type command_option :: {atom(), any()} @doc """ Sets up the mix project for testing ## Example: iex> deps = [pathex: "~> 2.5"] iex> project = MixTester.setup(name: :example, project: [deps: deps]) iex> "defmodule Example" <> _ = MixTester.read(project, "lib/example.ex") """ @spec setup([setup_option()]) :: Project.t() def setup(opts \\ []) do unique_dir = Integer.to_string(:erlang.unique_integer([:positive])) tmp_project_dir = Path.join(tester_root(), unique_dir) if File.exists?(tmp_project_dir) do setup(opts) else do_setup(tmp_project_dir, opts) end end defp do_setup(tmp_project_dir, opts) do File.mkdir_p!(tmp_project_dir) name = opts[:name] || "test_project" new_opts = "#{name} #{opts[:new] || ""}" cmd!("mix new " <> new_opts, cd: tmp_project_dir) [project_dirname] = File.ls!(tmp_project_dir) project_root = Path.join(tmp_project_dir, project_dirname) project = %Project{root: project_root, name: name} prepare_mixexs(project, opts) prepare_config(project, opts[:application_env]) if Code.ensure_loaded?(ExUnit) do try do apply(ExUnit, :after_suite, [ fn _ -> cleanup(project) end ]) rescue _ -> :ok end end load_system_env(project, opts) end defp load_system_env(project, opts) do system_env = Keyword.get(opts, :system_env, %{}) Enum.each(system_env, fn {key, value} when is_binary(key) and is_binary(value) -> :ok {key, value} -> raise ArgumentError, "Expected strings in system env. Got #{inspect(key)} and #{inspect(value)}" end) %Project{project | env: system_env} end defp merge_env(opts, %Project{env: project_env}) do case Keyword.fetch(opts, :env) do :error -> Keyword.put(opts, :env, Enum.to_list(project_env)) {:ok, env} -> env = project_env |> Map.new() |> Map.merge(Map.new(env)) |> Map.to_list() Keyword.put(opts, :env, env) end end defp prepare_mixexs(project, opts) do at_ast(project, "mix.exs", fn ast -> {:defp, _, [{:deps, _, _}, [{_, body}]]} = AST.find(ast, fn {:defp, _, [{:deps, _, _}, _]} -> true _ -> false end) deps = body |> eval() |> Macro.escape() Macro.postwalk(ast, fn {:defp, _, [{:deps, _, _}, _]} -> nil {:deps, _, []} -> deps {:def, meta, [{key, _, _} = p, [{thedo, body}]]} when key in ~w[project application]a -> kv = body |> eval() |> Keyword.merge(opts[key] || []) |> Macro.escape() {:def, meta, [p, [{thedo, kv}]]} other -> other end) end) end defp prepare_config(_project, nil), do: :ok defp prepare_config(project, application_env) do {config, application_env} = Map.pop(application_env, "config", []) write_ast( project, "config/config.exs", quote do import Config unquote_splicing( for {{app, key}, value} <- config do quote do: config(unquote(app), unquote(key), unquote(Macro.escape(value))) end ) import_config("#{config_env()}.exs") end ) application_env = application_env |> Map.put_new("dev", %{}) |> Map.put_new("test", %{}) |> Map.put_new("prod", %{}) for {config_file, config} <- application_env do write_ast( project, "config/#{config_file}.exs", quote do import Config unquote_splicing( for {{app, key}, value} <- config do quote do: config(unquote(app), unquote(key), unquote(Macro.escape(value))) end ) end ) end end @doc """ Deletes project ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.cleanup(project) iex> MixTester.exists?(project, "mix.exs") false """ @spec cleanup(Project.t()) :: :ok def cleanup(%Project{root: root}) do root |> Path.join("..") |> Path.expand() |> File.rm_rf!() :ok end @doc """ Removes all existing projects ## Example: iex> project1 = MixTester.setup(name: :example) iex> project2 = MixTester.setup(name: :example) iex> MixTester.cleanup_all() iex> MixTester.exists?(project1, "mix.exs") false iex> MixTester.exists?(project2, "mix.exs") false """ @spec cleanup_all() :: :ok def cleanup_all do File.rm_rf!(tester_root()) :ok end @doc """ Executes shell command in the project ## Example: iex> project = MixTester.setup(name: :example) iex> {ls, 0} = MixTester.sh(project, "ls -la") iex> ls =~ "mix.exs" """ @spec sh(Project.t(), command :: String.t(), [command_option()]) :: command_result() def sh(%Project{root: root} = project, command_string, opts \\ []) do opts = opts |> Keyword.update(:cd, root, &Path.join(root, &1)) |> merge_env(project) System.cmd("sh", ["-c", command_string], opts) end @doc """ Executes command in project root ## Example: iex> project = MixTester.setup(name: :example) iex> {ls, 0} = MixTester.cmd(project, "ls", ["-l", "-a"]) iex> ls =~ "mix.exs" """ @spec cmd(Project.t(), String.t(), [String.t()], [command_option()]) :: command_result() def cmd(%Project{root: root} = project, command, args, opts \\ []) do opts = opts |> Keyword.update(:cd, root, &Path.join(root, &1)) |> merge_env(project) System.cmd(command, args, opts) end @doc """ Executes mix command in project root ## Example: iex> project = MixTester.setup(name: :example) iex> {_, 0} = MixTester.mix_cmd(project, "compile") """ @spec mix_cmd(Project.t(), String.t(), [String.t()], [command_option()]) :: command_result() def mix_cmd(%Project{root: root} = project, command, args \\ [], opts \\ []) do opts = opts |> Keyword.update(:cd, root, &Path.join(root, &1)) |> merge_env(project) System.cmd("mix", [command | args], opts) end @doc """ Executes mix test in the project. Returns `true` or `false` ## Example iex> project = MixTester.setup(name: :example) iex> true = MixTester.mix_test(project) """ defmacro mix_test(project, args \\ [], opts \\ []) do position = "#{Path.basename(__CALLER__.file)}:#{__CALLER__.line}" message = "MixTester.mix_test at #{position} failed with" quote do case unquote(__MODULE__).mix_cmd(unquote(project), "test", unquote(args), unquote(opts)) do {_, 0} -> true {output, _} -> IO.puts(unquote(message)) IO.puts(output) false end end end @doc """ Reads the existing file from project ## Example: iex> project = MixTester.setup(name: :example) iex> "defmodule Example" <> _ = MixTester.read(project, "lib/example.ex") """ @spec read(Project.t(), Path.t()) :: binary() def read(project, filename) do project |> expand(filename) |> File.read!() end @doc """ Writes a file in the project ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.write(project, "priv/something.txt", "Hello!") iex> MixTester.read(project, "priv/something.txt") "Hello!" """ @spec write(Project.t(), Path.t(), iodata(), list()) :: :ok def write(project, filename, content, modes \\ []) do expanded = expand(project, filename) do_mkdir_p(expanded) File.write!(expanded, content, [:sync | modes]) end @doc """ Writes an AST in the file ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.write_ast(project, "test/test_helper.exs", quote do: ExUnit.start(max_failures: 1)) iex> {_, 0} = MixTester.mix_cmd(project, "test") iex> MixTester.read(project, "test/test_helper.exs") "ExUnit.start(max_failures: 1)" """ @spec write_ast(Project.t(), Path.t(), Macro.t(), list()) :: :ok def write_ast(project, filename, ast, modes \\ []) do content = ast |> Macro.to_string() |> Code.format_string!() write(project, filename, content, modes) end @doc """ Changes Elixir AST of file ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.at_ast(project, "lib/example.ex", fn ast -> ...> Macro.prewalk(ast, fn ...> :world -> :not_the_world_mhahaha ...> other -> other ...> end) ...> end) iex> {_, 2} = MixTester.mix_cmd(project, "test") """ @spec at_ast(Project.t(), Path.t(), (Macro.t() -> Macro.t())) :: :ok def at_ast(project, filename, func) do content = read(project, filename) {ast, comments} = Code.string_to_quoted_with_comments!(content) new_ast = func.(ast) content = new_ast |> Code.quoted_to_algebra(comments: comments) |> Inspect.Algebra.format(:infinity) |> IO.iodata_to_binary() |> Code.format_string!() write(project, filename, content) end @doc """ Checks if path exists in the project ## Example: iex> project = MixTester.setup() iex> MixTester.exists?(project, "mix.exs") true iex> MixTester.exists?(project, "this_file_does_not_exist_actually.txt") false """ @spec exists?(Project.t(), Path.t()) :: boolean() def exists?(project, filename) do File.exists?(expand(project, filename)) end @doc """ Expands path from relative path of the project ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.expand(project, "mix.exs") "/tmp/mix_tester/123/example/mix.exs" """ @spec expand(Project.t(), Path.t()) :: Path.t() def expand(%Project{root: root}, filename) do Path.expand(filename, root) end @doc """ Create a directory for specified file. ## Example: iex> project = MixTester.setup(name: :example) iex> MixTester.mkdir_p(project, "priv/static/file") iex> MixTester.sh(project, "ls priv/") {"static\\n", 0} """ @spec mkdir_p(Project.t(), Path.t()) :: Path.t() def mkdir_p(%Project{root: root}, filename) do do_mkdir_p(Path.expand(filename, root)) filename end defp do_mkdir_p(expanded_filename) do File.mkdir_p!(Path.dirname(expanded_filename)) end # Helpers defp tester_root do Path.join(System.tmp_dir!(), "mix_tester") end defp cmd!(string, opts) do [command | args] = string |> String.split() |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) case System.cmd(command, args, opts) do {output, 0} -> output {output, code} -> raise "Command #{string} failed with (exit code #{code}) #{output}" end end defp eval(quoted) do {data, _} = Code.eval_quoted(quoted) data end end