Current section

Files

Jump to
igniter lib igniter test.ex
Raw

lib/igniter/test.ex

defmodule Igniter.Test do
@moduledoc "Tools for testing with igniter."
import ExUnit.Assertions
@doc """
Sets up a test igniter that has only the files passed to it.
## Starting point
All of the files of an empty mix project are added by default.
You can specify more or overwrite files with the `:files` option.
## Limitations
You cannot install new dependencies, or use dependencies your own project does not have.
If you need to do that kind of thing, you will have to do a test that uses tools like
`System.cmd` in a temporary directory.
## Options
* `files` - A map of file paths to file contents. The file paths should be relative to the project root.
* `app_name` - The name of the application. Defaults to `:test`.
## Examples
test_project(files: %{
"lib/foo.ex" => \"\"\"
defmodule MyApp.Foo do
use Ash.Resource
end
\"\"\"
})
"""
@spec test_project(opts :: Keyword.t()) :: Igniter.t()
def test_project(opts \\ []) do
Igniter.new()
|> Igniter.assign(:test_mode?, true)
|> Igniter.assign(
:test_files,
add_mix_new(opts)
)
# need them all back in the igniter
|> Igniter.include_glob("**/.formatter.exs")
|> Igniter.include_glob(".formatter.exs")
|> Igniter.include_glob("**/*.*")
|> Igniter.Project.IgniterConfig.setup()
|> apply_igniter!()
end
@doc """
Sets up a test igniter that mimics a new phoenix project
"""
@spec test_project(opts :: Keyword.t()) :: Igniter.t()
def phx_test_project(opts \\ []) do
app_name = opts[:app_name] || :test
Igniter.new()
|> Igniter.assign(:test_mode?, true)
|> Igniter.assign(
:test_files,
add_mix_new(opts)
)
# need them all back in the igniter
|> Igniter.include_glob("**/.formatter.exs")
|> Igniter.include_glob(".formatter.exs")
|> Igniter.include_glob("**/*.*")
|> Igniter.Project.IgniterConfig.setup()
|> Igniter.compose_task("igniter.phx.install", [
".",
"--module",
Macro.camelize(Atom.to_string(app_name)),
"--app",
Atom.to_string(app_name),
"--yes"
])
|> apply_igniter!()
end
@doc """
Print the current `igniter` diff, returning the `igniter`.
This is primarily used for debugging purposes.
## Options
* `:label` - A label to print before the diff
* `:only` - Only print the diff for this file or files
"""
@spec puts_diff(Igniter.t(), opts :: Keyword.t()) :: Igniter.t()
def puts_diff(igniter, opts \\ []) do
only = Keyword.get(opts, :only)
label = Keyword.get(opts, :label, "")
case igniter |> diff(only: only, color?: true) |> String.trim() do
"" ->
prefix = if label == "", do: "", else: "#{label}: "
IO.puts("#{prefix}No changes!")
diff ->
prefix = if label == "", do: "", else: "#{label}:\n\n"
IO.puts("#{prefix}#{diff}")
end
igniter
end
@doc """
Return the current `igniter` diff.
## Options
* `:only` - Only return the diff for this file or files
"""
@spec diff(Igniter.t(), opts :: Keyword.t()) :: String.t()
def diff(igniter, opts \\ []) do
only = Keyword.get(opts, :only)
color? = Keyword.get(opts, :color?, false)
igniter.rewrite.sources
|> Map.values()
|> Enum.sort_by(& &1.path)
|> Enum.filter(fn source ->
(!only || source.path in List.wrap(only)) && Igniter.changed?(source)
end)
|> Igniter.diff(color?: color?)
end
@doc """
Applies an igniter, raising an error if there are any issues.
See `apply_igniter/1` for more.
"""
@spec apply_igniter!(Igniter.t()) :: Igniter.t() | no_return
def apply_igniter!(igniter) do
case apply_igniter(igniter) do
{:ok, igniter, _} ->
igniter
{:error, error} ->
raise "Error applying igniter:\n\n#{Enum.map_join(error, "\n", &"* #{&1}")}"
end
end
@doc """
Fakes applying the changes of an igniter.
This function returns any tasks, errors, warnings.
"""
@spec apply_igniter(Igniter.t()) ::
{:ok, Igniter.t(),
%{
tasks: [{String.t(), list(String.t())}],
warnings: [String.t()],
notices: [String.t()]
}}
| {:error, [String.t()]}
def apply_igniter(igniter) do
case Igniter.prepare_for_write(igniter) do
%{issues: []} = igniter ->
new_igniter =
igniter
|> simulate_write()
|> move_files()
|> rm_files()
{:ok, new_igniter,
%{tasks: igniter.tasks, warnings: igniter.warnings, notices: igniter.notices}}
%{issues: issues} ->
{:error, issues}
end
end
def assert_content_equals(igniter, path, text) do
content =
igniter.rewrite
|> Rewrite.source!(path)
|> Rewrite.Source.get(:content)
if content != text do
flunk("""
Expected content of `#{path}` to equal:
#{text}
Actual Content
#{content}
Diff of actual content against expected content
#{TextDiff.format(text, content, color: false)}
""")
end
igniter
end
def assert_rms(igniter, expected_paths) do
actual_rms = Enum.sort(igniter.rms)
expected_rms = Enum.sort(List.wrap(expected_paths))
if actual_rms != expected_rms do
if Enum.empty?(actual_rms) do
flunk("""
Expected the following files to be marked for removal:
#{Enum.map_join(expected_rms, "\n", &"* #{&1}")}
but no files were marked for removal.
""")
else
flunk("""
Expected the following files to be marked for removal:
#{Enum.map_join(expected_rms, "\n", &"* #{&1}")}
But found these files marked for removal:
#{Enum.map_join(actual_rms, "\n", &"* #{&1}")}
""")
end
end
igniter
end
def assert_has_task(igniter, task, argv) do
task_found? =
Enum.any?(igniter.tasks, fn
{^task, ^argv} -> true
{^task, ^argv, :delayed} -> true
_ -> false
end)
if not task_found? do
if Enum.empty?(igniter.tasks) do
flunk("""
Expected to find `mix #{task} #{Enum.join(argv, " ")}` in igniter tasks,
but no tasks were found on the igniter.
""")
else
flunk("""
Expected to find `mix #{task} #{Enum.join(argv, " ")}` in igniter tasks.
Found tasks:
#{Enum.map_join(igniter.tasks, "\n", fn
{task, argv} -> "- mix #{task} #{Enum.join(argv, " ")}"
{task, argv, :delayed} -> "- mix #{task} #{Enum.join(argv, " ")} (delayed)"
end)}
""")
end
end
igniter
end
def assert_has_delayed_task(igniter, task, argv) do
if {task, argv, :delayed} not in igniter.tasks do
if Enum.empty?(igniter.tasks) do
flunk("""
Expected to find delayed task `mix #{task} #{Enum.join(argv, " ")}` in igniter tasks,
but no tasks were found on the igniter.
""")
else
flunk("""
Expected to find delayed task `mix #{task} #{Enum.join(argv, " ")}` in igniter tasks.
Found tasks:
#{Enum.map_join(igniter.tasks, "\n", fn
{task, argv} -> "- mix #{task} #{Enum.join(argv, " ")}"
{task, argv, :delayed} -> "- mix #{task} #{Enum.join(argv, " ")} (delayed)"
end)}
""")
end
end
igniter
end
def assert_has_notice(igniter, notice) do
condition =
if is_function(notice, 1) do
notice
else
&(&1 == notice)
end
if !Enum.any?(igniter.notices, fn found_notice ->
condition.(found_notice)
end) do
message =
if is_binary(notice) do
"""
Expected to find the following notice:
#{notice}
"""
else
"""
Expected to find a matching notice.
"""
end
if Enum.empty?(igniter.notices) do
flunk("""
#{message}
but no notices were found on the igniter.
""")
else
flunk("""
#{message}
Found notices:
#{Enum.join(igniter.notices, "\n\b")}
""")
end
end
igniter
end
def assert_has_warning(igniter, warning) do
condition =
if is_function(warning, 1) do
warning
else
&(&1 == warning)
end
if !Enum.any?(igniter.warnings, fn found_warning ->
condition.(found_warning)
end) do
message =
if is_binary(warning) do
"""
Expected to find the following warning:
#{warning}
"""
else
"""
Expected to find a matching warning.
"""
end
if Enum.empty?(igniter.warnings) do
flunk("""
#{message}
but no warnings were found on the igniter.
""")
else
flunk("""
#{message}
Found warnings:
#{Enum.join(igniter.warnings, "\n\b")}
""")
end
end
igniter
end
def assert_has_issue(igniter, path \\ nil, issue) do
condition =
if is_function(issue, 1) do
issue
else
&(&1 == issue)
end
if path do
source = Rewrite.source(igniter, path)
issues =
case source do
{:ok, source} -> source.issues
_ -> []
end
if !Enum.any?(igniter.issues, fn found_issue ->
condition.(found_issue)
end) do
message =
if is_binary(issue) do
"""
Expected to find the following issue at path: #{inspect(path)}
#{issue}
"""
else
"""
Expected to find a matching issue at path: #{inspect(path)}.
"""
end
if Enum.empty?(issues) do
flunk("""
#{message}
but no issues were found on the igniter.
""")
else
flunk("""
#{message}
Found issue:
#{Enum.join(issues, "\n\b")}
""")
end
end
else
if !Enum.any?(igniter.issues, fn found_issue ->
condition.(found_issue)
end) do
message =
if is_binary(issue) do
"""
Expected to find the following issue:
#{issue}
"""
else
"""
Expected to find a matching issue.
"""
end
if Enum.empty?(igniter.issues) do
flunk("""
#{message}
but no issues were found on the igniter.
""")
else
flunk("""
#{message}
Found issues:
#{Enum.join(igniter.issues, "\n\b")}
""")
end
end
end
igniter
end
def assert_has_patch(igniter, path, patch) do
diff =
igniter.rewrite.sources
|> Map.take([path])
|> Igniter.diff(color?: false)
compare_diff =
Igniter.Test.sanitize_diff(diff)
compare_patch =
Igniter.Test.sanitize_diff(patch, diff)
compare_diff =
if Igniter.Test.has_line_numbers?(compare_patch) do
compare_diff
else
Igniter.Test.remove_line_numbers(compare_diff)
end
assert String.contains?(compare_diff, compare_patch),
"""
Expected `#{path}` to contain the following patch:
#{patch}
Actual diff:
#{diff}
"""
igniter
end
def assert_unchanged(igniter, path_or_paths) do
for path <- List.wrap(path_or_paths) do
refute Igniter.changed?(igniter, path), """
Expected #{inspect(path)} to be unchanged, but it was changed.
Diff:
#{igniter.rewrite.sources |> Map.take([path]) |> Igniter.diff()}
"""
end
igniter
end
def assert_unchanged(igniter) do
refute Igniter.changed?(igniter), """
Expected there to be no changes, but there were changes.
Diff:
#{Rewrite.sources(igniter.rewrite) |> Igniter.diff()}
"""
igniter
end
@doc false
def has_line_numbers?(patch) do
patch
|> String.split("\n")
|> Enum.any?(fn line ->
case Integer.parse(String.trim(line)) do
:error ->
false
_ ->
true
end
end)
end
@doc false
def remove_line_numbers(diff) do
diff
|> String.split("\n")
|> Enum.map_join("\n", fn line ->
String.replace(String.trim(line), ~r/^\d+/, "")
end)
end
@doc false
def sanitize_diff(diff, actual \\ nil) do
diff
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == "...|" || &1 == ""))
|> Enum.flat_map(fn line ->
if String.contains?(line, "|") do
[line]
else
if actual do
raise """
Invalid patch provided.
#{diff}
Each line of the patch should contain at least one | character.
Actual diff:
#{actual}
"""
else
[]
end
end
end)
|> Enum.map_join("\n", fn line ->
[l, r] = String.split(line, "|", parts: 2)
l =
l
|> String.split()
|> Enum.join()
String.trim(String.trim(l) <> String.trim(r))
end)
end
@doc """
Asserts that a file was created during the igniter run.
Optionally validates the content of the created file if `content` is provided.
## Examples
test_project()
|> Igniter.create_new_file("lib/example.ex", "defmodule Example, do: nil")
|> assert_creates("lib/example.ex")
test_project()
|> Igniter.create_new_file("lib/example.ex", "defmodule Example, do: nil")
|> assert_creates("lib/example.ex", "defmodule Example, do: nil")
"""
def assert_creates(igniter, path, content \\ nil) do
assert source = igniter.rewrite.sources[path],
"""
Expected #{inspect(path)} to have been created, but it was not.
#{Igniter.Test.created_files(igniter)}
"""
assert source.from == :string,
"""
Expected #{inspect(path)} to have been created, but it already existed.
#{Igniter.Test.created_files(igniter)}
"""
if content do
actual_content = Rewrite.Source.get(source, :content)
if actual_content != content do
flunk("""
Expected created file #{inspect(path)} to have the following contents:
#{content}
But it actually had the following contents:
#{actual_content}
Diff, showing your assertion against the actual contents:
#{TextDiff.format(actual_content, content)}
""")
end
end
igniter
end
@doc """
Asserts that a file was NOT created during the igniter run.
This will pass if the file doesn't exist at all, or if it already existed
before the igniter run started.
## Examples
test_project()
|> refute_creates("lib/non_existent.ex")
test_project()
|> refute_creates("mix.exs") # mix.exs already exists
"""
def refute_creates(igniter, path) do
source = igniter.rewrite.sources[path]
if source && source.from == :string do
flunk("""
Expected #{inspect(path)} to not have been created, but it was.
#{Igniter.Test.created_files(igniter)}
""")
end
igniter
end
@doc false
def created_files(igniter) do
igniter.rewrite
|> Rewrite.sources()
|> Enum.filter(&(&1.from == :string))
|> Enum.map(& &1.path)
|> case do
[] ->
"\nNo files were created."
modules ->
"\nThe following files were created:\n\n#{Enum.map_join(modules, "\n", &"* #{&1}")}"
end
end
defp simulate_write(igniter) do
igniter.rewrite
|> Rewrite.sources()
|> Enum.reduce(igniter.assigns[:test_files], fn source, test_files ->
content = Rewrite.Source.get(source, :content)
Map.put(test_files, source.path, content)
end)
|> then(fn test_files ->
igniter
|> Map.put(
:rewrite,
Rewrite.new(
hooks: [Igniter.Rewrite.DotFormatterUpdater],
dot_formatter: igniter.rewrite.dot_formatter
)
)
|> Map.put(:tasks, [])
|> Map.put(:warnings, [])
|> Map.put(:notices, [])
|> Map.put(:issues, [])
|> Map.put(:assigns, %{
test_mode?: true,
test_files: test_files,
igniter_exs: igniter.assigns[:igniter_exs]
})
# need them all back in the igniter
|> Igniter.include_glob("**/.formatter.exs")
|> Igniter.include_glob(".formatter.exs")
|> Igniter.include_glob("**/*.*")
end)
end
defp move_files(igniter) do
igniter.moves
|> Enum.reduce(igniter.assigns[:test_files], fn {from, to}, files ->
case Map.pop(files, from) do
{nil, files} -> files
{contents, files} -> Map.put(files, to, contents)
end
end)
|> then(fn test_files ->
igniter
|> Igniter.assign(:test_files, test_files)
|> Map.put(:moves, %{})
end)
end
defp rm_files(igniter) do
test_files =
Map.drop(igniter.assigns[:test_files], igniter.rms)
igniter
|> Igniter.assign(:test_files, test_files)
|> Map.put(:rms, [])
end
defp add_mix_new(opts) do
app_name = opts[:app_name] || :test
module_name = Module.concat([Macro.camelize(to_string(app_name))])
opts[:files]
|> Kernel.||(%{})
|> Map.put_new("test/test_helper.exs", "ExUnit.start()")
|> Map.put_new("test/#{app_name}_test.exs", """
defmodule #{module_name}Test do
use ExUnit.Case
doctest #{module_name}
test "greets the world" do
assert #{module_name}.hello() == :world
end
end
""")
|> Map.put_new("lib/#{app_name}.ex", """
defmodule #{module_name} do
@moduledoc \"\"\"
Documentation for `#{module_name}`.
\"\"\"
@doc \"\"\"
Hello world.
## Examples
iex> #{module_name}.hello()
:world
\"\"\"
def hello do
:world
end
end
""")
|> Map.put_new("README.md", """
# #{module_name}
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `thing` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:#{app_name}, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/#{app_name}>.
""")
|> Map.put_new(".formatter.exs", """
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
""")
|> Map.put_new(".gitignore", """
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
#{app_name}-*.tar
# Temporary files, for example, from tests.
/tmp/
""")
|> Map.put_new("mix.exs", """
defmodule #{module_name}.MixProject do
use Mix.Project
def project do
[
app: :#{app_name},
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end
""")
end
end