Packages

Phoenix test harness with one API across static, LiveView, and browser drivers

Current section

Files

Jump to
cerberus lib mix tasks cerberus.migrate_phoenix_test.ex
Raw

lib/mix/tasks/cerberus.migrate_phoenix_test.ex

defmodule Mix.Tasks.Cerberus.MigratePhoenixTest do
@shortdoc "Migrates PhoenixTest test files to Cerberus"
@example "mix cerberus.migrate_phoenix_test --write test/my_app_web/features"
@moduledoc """
#{@shortdoc} in a preview-first workflow.
By default this task runs in dry-run mode.
Use `--write` to apply changes.
mix cerberus.migrate_phoenix_test
#{@example}
"""
use Igniter.Mix.Task
alias Rewrite.Source
@type run_option :: {:write, boolean()} | {:dry_run, boolean()}
@type run_opts :: [run_option()]
@type migration_summary :: %{
files_scanned: non_neg_integer(),
files_changed: non_neg_integer(),
warnings: non_neg_integer()
}
@type migration_result :: {term(), boolean(), non_neg_integer()}
@type warning_messages :: [String.t()]
@type canonicalize_result :: {:ok, [Macro.t()]} | :no_change
@type split_args_result :: {:ok, [Macro.t()], term(), keyword() | :none} | :error
@switches [
write: :boolean,
dry_run: :boolean
]
@run_opts_schema [
write: [type: :boolean],
dry_run: [type: :boolean]
]
@default_globs [
"test/**/*_test.exs",
"test/**/*_test.ex"
]
@default_setup_files ["config/test.exs", "test/test_helper.exs"]
@relative_setup_files @default_setup_files
@warning_test_helpers "PhoenixTest.TestHelpers import has no direct Cerberus equivalent and needs manual migration."
@warning_use_phoenix_test "use PhoenixTest has no direct Cerberus equivalent and needs manual migration."
@warning_submodule_alias "PhoenixTest submodule alias detected; verify Cerberus module equivalents manually."
@warning_direct_call "Direct PhoenixTest.<function> call detected; migrate to Cerberus session-first flow manually."
@warning_browser_helper "Browser helper call likely needs manual migration to Cerberus browser extensions."
@browser_helpers [:with_dialog, :with_popup, :screenshot, :press, :type, :drag, :cookie, :session_cookie]
@rewritable_direct_calls [
:assert_has,
:refute_has,
:click,
:click_link,
:click_button,
:fill_in,
:select,
:choose,
:check,
:uncheck,
:submit,
:upload,
:assert_path,
:refute_path,
:within,
:unwrap,
:visit,
:open_browser
]
@rewritable_assertions_calls [:assert_has, :refute_has, :assert_path, :refute_path]
@canonical_text_assertions [:assert_has, :refute_has]
@canonical_labeled_value_keys %{fill_in: :with}
@local_import_trigger_calls Enum.uniq([:session | @rewritable_direct_calls ++ @rewritable_assertions_calls])
defmodule RewriteState do
@moduledoc false
@type t :: %__MODULE__{changed?: boolean(), warnings: [String.t()], seen: MapSet.t(String.t())}
defstruct changed?: false, warnings: [], seen: MapSet.new()
end
@impl Igniter.Mix.Task
def info(_argv, _source) do
%Igniter.Mix.Task.Info{
group: :cerberus,
example: @example,
schema: @switches,
defaults: [write: false],
extra_args?: true
}
end
@impl Mix.Task
@spec run([String.t()]) :: :ok
def run(args) do
{opts, positional} = parse_run_opts!(args)
dry_run = resolve_dry_run(opts)
files = migration_targets(positional)
Mix.Task.run("compile")
Application.ensure_all_started(:rewrite)
igniter =
Igniter.new()
|> Map.put(:task, Mix.Task.task_name(__MODULE__))
|> Igniter.assign(:cerberus_migration_files, files)
|> Igniter.assign(:cerberus_migration_dry_run, dry_run)
|> Igniter.Mix.Task.configure_and_run(__MODULE__, args)
if !dry_run do
_ =
Igniter.do_or_dry_run(
igniter,
dry_run: false,
yes: true,
quiet_on_no_changes?: true,
title: "Cerberus migration"
)
end
summary = igniter.assigns[:cerberus_migration_summary]
print_summary(summary.files_scanned, summary.files_changed, summary.warnings, dry_run)
:ok
end
@spec parse_run_opts!([String.t()]) :: {run_opts(), [String.t()]}
defp parse_run_opts!(args) do
{opts, positional, invalid} = OptionParser.parse(args, strict: @switches)
if invalid != [] do
raise ArgumentError, "invalid options: #{inspect(invalid)}"
end
{NimbleOptions.validate!(opts, @run_opts_schema), positional}
end
@impl Igniter.Mix.Task
@spec igniter(term()) :: term()
def igniter(igniter) do
files = igniter.assigns[:cerberus_migration_files] || []
dry_run = Map.get(igniter.assigns, :cerberus_migration_dry_run, true)
if files == [] do
IO.puts("No candidate test files found.")
Igniter.assign(igniter, :cerberus_migration_summary, %{files_scanned: 0, files_changed: 0, warnings: 0})
else
{igniter, changed_count, warning_count} =
Enum.reduce(files, {igniter, 0, 0}, fn file, acc ->
reduce_migrated_file(file, acc, dry_run)
end)
Igniter.assign(igniter, :cerberus_migration_summary, %{
files_scanned: length(files),
files_changed: changed_count,
warnings: warning_count
})
end
end
@spec reduce_migrated_file(String.t(), {term(), non_neg_integer(), non_neg_integer()}, boolean()) ::
{term(), non_neg_integer(), non_neg_integer()}
defp reduce_migrated_file(file, {igniter, changed_acc, warning_acc}, dry_run) do
{next_igniter, changed?, file_warning_count} = migrate_file(igniter, file, dry_run)
{next_igniter, changed_acc + changed_to_count(changed?), warning_acc + file_warning_count}
end
@spec changed_to_count(boolean()) :: 0 | 1
defp changed_to_count(true), do: 1
defp changed_to_count(false), do: 0
@spec resolve_dry_run(run_opts()) :: boolean()
defp resolve_dry_run(opts) do
cond do
Keyword.get(opts, :write, false) -> false
Keyword.has_key?(opts, :dry_run) -> require_boolean!(Keyword.fetch!(opts, :dry_run), :dry_run)
true -> true
end
end
@spec require_boolean!(term(), atom()) :: boolean()
defp require_boolean!(value, _option_name) when is_boolean(value), do: value
defp require_boolean!(value, option_name) do
raise ArgumentError, "expected #{inspect(option_name)} option to be a boolean, got: #{inspect(value)}"
end
@spec migration_targets([String.t()]) :: [String.t()]
defp migration_targets([]) do
@default_globs
|> Enum.flat_map(&Path.wildcard/1)
|> append_default_setup_files()
|> finalize_targets()
end
@spec migration_targets([String.t()]) :: [String.t()]
defp migration_targets(paths) do
include_relative_setup? = Enum.any?(paths, &(Path.type(&1) != :absolute))
paths
|> Enum.flat_map(&expand_target/1)
|> maybe_append_relative_setup_files(include_relative_setup?)
|> finalize_targets()
end
@spec maybe_append_relative_setup_files([String.t()], boolean()) :: [String.t()]
defp maybe_append_relative_setup_files(paths, true), do: paths ++ @relative_setup_files
defp maybe_append_relative_setup_files(paths, false), do: paths
@spec append_default_setup_files([String.t()]) :: [String.t()]
defp append_default_setup_files(paths), do: paths ++ @default_setup_files
@spec finalize_targets([String.t()]) :: [String.t()]
defp finalize_targets(paths) do
paths
|> Enum.filter(&File.regular?/1)
|> Enum.uniq()
|> Enum.sort()
end
@spec expand_target(String.t()) :: [String.t()]
defp expand_target(path) do
cond do
File.regular?(path) ->
[path]
File.dir?(path) ->
Path.wildcard(Path.join(path, "**/*.{ex,exs}"))
true ->
Path.wildcard(path)
end
end
@spec migrate_file(term(), String.t(), boolean()) :: migration_result()
defp migrate_file(igniter, file, dry_run) do
original = File.read!(file)
{rewritten, warnings} = rewrite_content(file, original)
print_warnings(file, warnings)
warning_count = length(warnings)
cond do
rewritten == original ->
{igniter, false, warning_count}
dry_run ->
print_diff(file, original, rewritten)
{igniter, true, warning_count}
true ->
igniter = persist_migrated_file(igniter, file, rewritten)
IO.puts("updated #{file}")
{igniter, true, warning_count}
end
end
@spec persist_migrated_file(term(), String.t(), String.t()) :: term()
defp persist_migrated_file(igniter, file, rewritten) do
Igniter.update_file(
igniter,
file,
&update_source_content(&1, rewritten),
source_handler: Rewrite.Source.Ex
)
end
@spec update_source_content(Source.t(), String.t()) :: Source.t()
defp update_source_content(source, rewritten) do
Source.update(source, :content, rewritten, by: :cerberus_migrate_phoenix_test)
end
@spec rewrite_content(String.t(), String.t()) :: {String.t(), warning_messages()}
defp rewrite_content(file, content) do
content
|> rewrite_with_ast()
|> maybe_ensure_phoenix_test_endpoint_compat(file)
|> maybe_ensure_test_helper_endpoint(file)
end
@spec maybe_ensure_phoenix_test_endpoint_compat({String.t(), warning_messages()}, String.t()) ::
{String.t(), warning_messages()}
defp maybe_ensure_phoenix_test_endpoint_compat({content, warnings}, file) do
cond do
not config_test_file?(file) ->
{content, warnings}
has_phoenix_test_endpoint_config?(content) ->
{content, warnings}
true ->
case infer_endpoint_ast_from_content(content) do
{:ok, endpoint_ast} ->
updated =
content
|> append_phoenix_test_endpoint_config(endpoint_ast)
|> Code.format_string!()
|> IO.iodata_to_binary()
{updated, warnings}
:error ->
{content, warnings}
end
end
end
@spec maybe_ensure_test_helper_endpoint({String.t(), warning_messages()}, String.t()) ::
{String.t(), warning_messages()}
defp maybe_ensure_test_helper_endpoint({content, warnings}, file) do
cond do
not test_helper_file?(file) ->
{content, warnings}
has_cerberus_endpoint_put_env?(content) ->
{content, warnings}
true ->
ensure_test_helper_endpoint_put_env(file, content, warnings)
end
end
@spec ensure_test_helper_endpoint_put_env(String.t(), String.t(), warning_messages()) ::
{String.t(), warning_messages()}
defp ensure_test_helper_endpoint_put_env(file, content, warnings) do
case infer_endpoint_ast(file) do
{:ok, endpoint_ast} ->
updated =
content
|> append_test_helper_endpoint_put_env(endpoint_ast)
|> Code.format_string!()
|> IO.iodata_to_binary()
{updated, warnings}
:error ->
{content, warnings}
end
end
@spec config_test_file?(String.t()) :: boolean()
defp config_test_file?(file) when is_binary(file) do
case file |> Path.split() |> Enum.take(-2) do
["config", "test.exs"] -> true
_ -> false
end
end
@spec test_helper_file?(String.t()) :: boolean()
defp test_helper_file?(file) when is_binary(file) do
case file |> Path.split() |> Enum.take(-2) do
["test", "test_helper.exs"] -> true
_ -> false
end
end
@spec has_cerberus_endpoint_put_env?(String.t()) :: boolean()
defp has_cerberus_endpoint_put_env?(content) when is_binary(content) do
case Sourceror.parse_string(content) do
{:ok, ast} ->
{_ast, found?} =
Macro.prewalk(ast, false, fn
{{:., _dot_meta, [{:__aliases__, _alias_meta, [:Application]}, :put_env]}, _call_meta, args} =
node,
found? ->
{node, found? or cerberus_endpoint_put_env_call_args?(args)}
node, found? ->
{node, found?}
end)
found?
_ ->
String.contains?(content, "Application.put_env(:cerberus, :endpoint")
end
end
@spec cerberus_endpoint_put_env_call_args?(list()) :: boolean()
defp cerberus_endpoint_put_env_call_args?([app_ast, key_ast | _rest]) do
atom_literal(app_ast) == {:ok, :cerberus} and atom_literal(key_ast) == {:ok, :endpoint}
end
defp cerberus_endpoint_put_env_call_args?(_args), do: false
@spec has_phoenix_test_endpoint_config?(String.t()) :: boolean()
defp has_phoenix_test_endpoint_config?(content) when is_binary(content) do
case Sourceror.parse_string(content) do
{:ok, ast} ->
{_ast, found?} =
Macro.prewalk(ast, false, fn
{:config, _meta, args} = node, found? when is_list(args) ->
{node, found? or phoenix_test_endpoint_config_args?(args)}
node, found? ->
{node, found?}
end)
found?
_ ->
String.contains?(content, "config :phoenix_test")
end
end
@spec phoenix_test_endpoint_config_args?([Macro.t()]) :: boolean()
defp phoenix_test_endpoint_config_args?([app_ast | rest]) do
atom_literal(app_ast) == {:ok, :phoenix_test} and endpoint_ast_from_config_rest(rest) != :error
end
defp phoenix_test_endpoint_config_args?(_args), do: false
@spec infer_endpoint_ast(String.t()) :: {:ok, Macro.t()} | :error
defp infer_endpoint_ast(file) do
config_path = config_test_path_for(file)
with true <- File.regular?(config_path),
{:ok, content} <- File.read(config_path) do
infer_endpoint_ast_from_content(content)
else
_ -> :error
end
end
@spec config_test_path_for(String.t()) :: String.t()
defp config_test_path_for(file) do
file
|> Path.expand()
|> Path.dirname()
|> Path.dirname()
|> Path.join("config/test.exs")
end
@spec infer_endpoint_ast_from_content(String.t()) :: {:ok, Macro.t()} | :error
defp infer_endpoint_ast_from_content(content) when is_binary(content) do
with {:ok, ast} <- Sourceror.parse_string(content),
{:ok, endpoint_ast} <- find_endpoint_ast(ast) do
{:ok, endpoint_ast}
else
_ -> :error
end
end
@spec find_endpoint_ast(Macro.t()) :: {:ok, Macro.t()} | :error
defp find_endpoint_ast(ast) do
{_ast, endpoint_ast} =
Macro.prewalk(ast, nil, fn
{:config, _meta, args} = node, nil when is_list(args) ->
{node, endpoint_ast_from_config_args(args)}
node, endpoint_ast ->
{node, endpoint_ast}
end)
if is_nil(endpoint_ast), do: :error, else: {:ok, endpoint_ast}
end
@spec endpoint_ast_from_config_args([Macro.t()]) :: Macro.t() | nil
defp endpoint_ast_from_config_args([app_ast | rest]) do
with {:ok, app} <- atom_literal(app_ast),
true <- app in [:cerberus, :phoenix_test],
{:ok, endpoint_ast} <- endpoint_ast_from_config_rest(rest) do
endpoint_ast
else
_ -> nil
end
end
defp endpoint_ast_from_config_args(_args), do: nil
@spec endpoint_ast_from_config_rest([Macro.t()]) :: {:ok, Macro.t()} | :error
defp endpoint_ast_from_config_rest(rest) when is_list(rest) do
rest
|> Enum.find(&keyword_ast?/1)
|> case do
nil ->
:error
keyword_ast ->
keyword_ast
|> normalize_keyword_ast()
|> Keyword.fetch(:endpoint)
end
end
@spec append_test_helper_endpoint_put_env(String.t(), Macro.t()) :: String.t()
defp append_test_helper_endpoint_put_env(content, endpoint_ast) when is_binary(content) do
put_env_call = "Application.put_env(:cerberus, :endpoint, #{Macro.to_string(endpoint_ast)})"
if String.ends_with?(content, "\n") do
content <> put_env_call <> "\n"
else
content <> "\n" <> put_env_call <> "\n"
end
end
@spec append_phoenix_test_endpoint_config(String.t(), Macro.t()) :: String.t()
defp append_phoenix_test_endpoint_config(content, endpoint_ast) when is_binary(content) do
config_line = "config :phoenix_test, endpoint: #{Macro.to_string(endpoint_ast)}"
if String.ends_with?(content, "\n") do
content <> config_line <> "\n"
else
content <> "\n" <> config_line <> "\n"
end
end
@spec print_summary(non_neg_integer(), non_neg_integer(), non_neg_integer(), boolean()) :: :ok
defp print_summary(files_scanned, changed_count, warning_count, dry_run) do
IO.puts("\nMigration summary:")
IO.puts(" Files scanned: #{files_scanned}")
IO.puts(" Files changed: #{changed_count}")
IO.puts(" Warnings: #{warning_count}")
IO.puts(" Mode: #{if dry_run, do: "dry-run", else: "write"}")
:ok
end
@spec rewrite_with_ast(String.t()) :: {String.t(), warning_messages()}
defp rewrite_with_ast(content) do
case Sourceror.parse_string(content) do
{:ok, ast} ->
{rewritten_ast, state} = Macro.prewalk(ast, %RewriteState{}, &rewrite_node/2)
{canonical_ast, canonical_changed?} = canonicalize_calls(rewritten_ast)
{imported_ast, import_changed?} = ensure_cerberus_imports(canonical_ast)
changed? = state.changed? or canonical_changed? or import_changed?
rewritten = render_rewritten_content(content, imported_ast, changed?)
{rewritten, Enum.reverse(state.warnings)}
{:error, {_line, error, _token}} ->
{content, ["Could not parse file for AST migration: #{error}"]}
{:error, reason} ->
{content, ["Could not parse file for AST migration: #{inspect(reason)}"]}
end
end
@spec render_rewritten_content(String.t(), Macro.t(), boolean()) :: String.t()
defp render_rewritten_content(content, _canonical_ast, false), do: content
defp render_rewritten_content(_content, canonical_ast, true) do
canonical_ast
|> Sourceror.to_string()
|> Code.format_string!()
|> IO.iodata_to_binary()
end
@spec rewrite_node(Macro.t(), RewriteState.t()) :: {Macro.t(), RewriteState.t()}
defp rewrite_node({:import, meta, args} = node, state) do
case args do
[module_ast | rest] ->
case alias_parts(module_ast) do
[:PhoenixTest] ->
updated = {:import, meta, [alias_ast(module_ast, [:Cerberus]) | rest]}
{updated, mark_changed(state)}
[:PhoenixTest, :Assertions] ->
updated = {:import, meta, [alias_ast(module_ast, [:Cerberus]) | rest]}
{updated, mark_changed(state)}
[:PhoenixTest, :TestHelpers] ->
{node, add_warning(state, @warning_test_helpers)}
[:PhoenixTest | _] ->
{node, add_warning(state, @warning_submodule_alias)}
_ ->
{node, state}
end
_ ->
{node, state}
end
end
defp rewrite_node({:use, _meta, args} = node, state) do
case args do
[module_ast | _rest] ->
case alias_parts(module_ast) do
[:PhoenixTest] ->
{node, add_warning(state, @warning_use_phoenix_test)}
[:PhoenixTest | _] ->
{node, add_warning(state, @warning_submodule_alias)}
_ ->
{node, state}
end
_ ->
{node, state}
end
end
defp rewrite_node({:alias, meta, args} = node, state) do
case args do
[module_ast | rest] ->
rewrite_alias_node(node, meta, module_ast, rest, state)
_ ->
{node, state}
end
end
defp rewrite_node({:|>, meta, [lhs, rhs]} = node, state) do
if conn_arg?(lhs) and visit_call?(rhs) do
updated = {:|>, meta, [session_bootstrap_ast_from(lhs), rhs]}
{updated, mark_changed(state)}
else
{node, state}
end
end
defp rewrite_node({{:., _dot_meta, [module_ast, fun]}, _call_meta, args} = node, state)
when is_atom(fun) and is_list(args) do
state_with_warnings = maybe_warn_browser_helper(state, fun)
rewrite_remote_call(node, module_ast, fun, args, state_with_warnings)
end
defp rewrite_node({:visit, meta, args} = node, state) when is_list(args) do
if conn_first_arg?(args) do
updated = {:visit, meta, replace_first_arg_with_session_bootstrap(args)}
{updated, mark_changed(state)}
else
{node, state}
end
end
defp rewrite_node({:config, meta, args} = node, state) when is_list(args) do
case rewrite_config_app(args) do
{:ok, updated_args} ->
{{:config, meta, updated_args}, mark_changed(state)}
:no_change ->
{node, state}
end
end
defp rewrite_node({fun, _meta, args} = node, state) when is_atom(fun) and is_list(args) do
next_state = maybe_warn_browser_helper(state, fun)
{node, next_state}
end
defp rewrite_node(node, state), do: {node, state}
@spec canonicalize_calls(Macro.t()) :: {Macro.t(), boolean()}
defp canonicalize_calls(ast) do
Macro.prewalk(ast, false, &canonicalize_node/2)
end
@spec canonicalize_node(Macro.t(), boolean()) :: {Macro.t(), boolean()}
defp canonicalize_node({{:., dot_meta, [module_ast, fun]}, call_meta, args} = node, changed)
when is_atom(fun) and is_list(args) do
scope_builder = &build_remote_css_scope(module_ast, &1)
case canonicalize_call_args(fun, args, scope_builder) do
{:ok, updated_args} ->
{{{:., dot_meta, [module_ast, fun]}, call_meta, updated_args}, true}
:no_change ->
{node, changed}
end
end
defp canonicalize_node({fun, meta, args} = node, changed) when is_atom(fun) and is_list(args) do
case canonicalize_call_args(fun, args, &build_local_css_scope/1) do
{:ok, updated_args} ->
{{fun, meta, updated_args}, true}
:no_change ->
{node, changed}
end
end
defp canonicalize_node(node, changed), do: {node, changed}
@spec ensure_cerberus_imports(Macro.t()) :: {Macro.t(), boolean()}
defp ensure_cerberus_imports(ast) do
Macro.prewalk(ast, false, &ensure_cerberus_import_node/2)
end
@spec ensure_cerberus_import_node(Macro.t(), boolean()) :: {Macro.t(), boolean()}
defp ensure_cerberus_import_node({:defmodule, meta, [module_name, body_kw]} = node, changed) when is_list(body_kw) do
{updated_body_kw, inserted?} =
Enum.map_reduce(body_kw, false, fn
{key, body} = entry, inserted_acc ->
cond do
inserted_acc ->
{entry, true}
do_block_key?(key) and module_needs_cerberus_import?(body) and not module_imports_cerberus?(body) ->
{{key, prepend_cerberus_import(body)}, true}
true ->
{entry, inserted_acc}
end
entry, inserted_acc ->
{entry, inserted_acc}
end)
if inserted? do
{{:defmodule, meta, [module_name, updated_body_kw]}, true}
else
{node, changed}
end
end
defp ensure_cerberus_import_node(node, changed), do: {node, changed}
@spec do_block_key?(term()) :: boolean()
defp do_block_key?(:do), do: true
defp do_block_key?({:__block__, _meta, [:do]}), do: true
defp do_block_key?(_), do: false
@spec module_needs_cerberus_import?(Macro.t()) :: boolean()
defp module_needs_cerberus_import?(body) do
{_body, needed?} =
Macro.prewalk(body, false, fn
{fun, _meta, args} = node, needed when is_atom(fun) and is_list(args) ->
{node, needed or fun in @local_import_trigger_calls}
node, needed ->
{node, needed}
end)
needed?
end
@spec module_imports_cerberus?(Macro.t()) :: boolean()
defp module_imports_cerberus?(body) do
{_body, imported?} =
Macro.prewalk(body, false, fn
{:import, _meta, [module_ast | _rest]} = node, imported ->
{node, imported or alias_parts(module_ast) == [:Cerberus]}
node, imported ->
{node, imported}
end)
imported?
end
@spec prepend_cerberus_import(Macro.t()) :: Macro.t()
defp prepend_cerberus_import({:__block__, meta, nodes}) when is_list(nodes) do
{:__block__, meta, [cerberus_import_ast() | nodes]}
end
defp prepend_cerberus_import(node) do
{:__block__, [], [cerberus_import_ast(), node]}
end
@spec cerberus_import_ast() :: Macro.t()
defp cerberus_import_ast do
{:import, [], [{:__aliases__, [], [:Cerberus]}]}
end
@spec canonicalize_call_args(atom(), [Macro.t()], (Macro.t() -> Macro.t())) :: canonicalize_result()
defp canonicalize_call_args(fun, args, scope_builder) when is_function(scope_builder, 1) do
{args, changed?} =
Enum.reduce(
[
fn acc -> canonicalize_text_assertion_args(fun, acc) end,
fn acc -> canonicalize_labeled_value_call_args(fun, acc) end,
fn acc -> canonicalize_scope_locator_args(fun, acc, scope_builder) end
],
{args, false},
fn transform, {acc, changed?} ->
case transform.(acc) do
{:ok, updated} -> {updated, true}
:no_change -> {acc, changed?}
end
end
)
if changed?, do: {:ok, args}, else: :no_change
end
@spec canonicalize_text_assertion_args(atom(), [Macro.t()]) :: canonicalize_result()
defp canonicalize_text_assertion_args(fun, args) when fun in @canonical_text_assertions do
with {:ok, prefix, maybe_opts, trailing_opts} <- split_text_assertion_args(args),
{:ok, text_value, remaining_opts} <- pop_keyword_ast(maybe_opts, :text),
{:ok, merged_opts} <- merge_trailing_opts(remaining_opts, trailing_opts) do
{:ok, build_value_args(prefix, text_value, merged_opts)}
else
_ -> :no_change
end
end
defp canonicalize_text_assertion_args(_fun, _args), do: :no_change
@spec canonicalize_labeled_value_call_args(atom(), [Macro.t()]) :: canonicalize_result()
defp canonicalize_labeled_value_call_args(fun, args) do
case Map.fetch(@canonical_labeled_value_keys, fun) do
{:ok, key} ->
canonicalize_labeled_value_call_args_by_key(args, key)
:error ->
:no_change
end
end
@spec canonicalize_labeled_value_call_args_by_key([Macro.t()], atom()) :: canonicalize_result()
defp canonicalize_labeled_value_call_args_by_key(args, key) do
with {:ok, prefix, maybe_opts, trailing_opts} <- split_labeled_value_args(args),
{:ok, value, remaining_opts} <- pop_keyword_ast(maybe_opts, key),
{:ok, merged_opts} <- merge_trailing_opts(remaining_opts, trailing_opts) do
{:ok, build_value_args(prefix, value, merged_opts)}
else
_ -> :no_change
end
end
@spec canonicalize_scope_locator_args(atom(), [Macro.t()], (Macro.t() -> Macro.t())) :: canonicalize_result()
defp canonicalize_scope_locator_args(fun, args, scope_builder) when fun in @canonical_text_assertions do
canonicalize_assertion_scope_args(args, scope_builder)
end
defp canonicalize_scope_locator_args(:within, args, scope_builder) do
canonicalize_within_scope_args(args, scope_builder)
end
defp canonicalize_scope_locator_args(_fun, _args, _scope_builder), do: :no_change
@spec canonicalize_assertion_scope_args([Macro.t()], (Macro.t() -> Macro.t())) :: canonicalize_result()
defp canonicalize_assertion_scope_args(args, scope_builder) when is_function(scope_builder, 1) do
case args do
[scope, locator] ->
canonicalize_assertion_scope_pair(scope, locator, scope_builder)
[first, second, third] ->
canonicalize_assertion_scope_triple(first, second, third, scope_builder)
[session, scope, locator, opts] ->
canonicalize_assertion_scope_quad(session, scope, locator, opts, scope_builder)
_ ->
:no_change
end
end
@spec canonicalize_assertion_scope_pair(Macro.t(), Macro.t(), (Macro.t() -> Macro.t())) :: canonicalize_result()
defp canonicalize_assertion_scope_pair(scope, locator, scope_builder) when is_function(scope_builder, 1) do
if binary_literal_ast?(scope) and not keyword_ast?(locator) do
{:ok, [scope_builder.(scope), locator]}
else
:no_change
end
end
@spec canonicalize_assertion_scope_triple(Macro.t(), Macro.t(), Macro.t(), (Macro.t() -> Macro.t())) ::
canonicalize_result()
defp canonicalize_assertion_scope_triple(first, second, third, scope_builder) when is_function(scope_builder, 1) do
cond do
binary_literal_ast?(second) and not keyword_ast?(third) ->
{:ok, [first, scope_builder.(second), third]}
binary_literal_ast?(first) and keyword_ast?(third) ->
{:ok, [scope_builder.(first), second, third]}
true ->
:no_change
end
end
@spec canonicalize_assertion_scope_quad(Macro.t(), Macro.t(), Macro.t(), Macro.t(), (Macro.t() -> Macro.t())) ::
canonicalize_result()
defp canonicalize_assertion_scope_quad(session, scope, locator, opts, scope_builder)
when is_function(scope_builder, 1) do
if binary_literal_ast?(scope) and keyword_ast?(opts) do
{:ok, [session, scope_builder.(scope), locator, opts]}
else
:no_change
end
end
@spec canonicalize_within_scope_args([Macro.t()], (Macro.t() -> Macro.t())) :: canonicalize_result()
defp canonicalize_within_scope_args(args, scope_builder) when is_function(scope_builder, 1) do
case args do
[scope, callback] ->
if binary_literal_ast?(scope) and callback_ast?(callback) do
{:ok, [scope_builder.(scope), callback]}
else
:no_change
end
[session, scope, callback] ->
if binary_literal_ast?(scope) and callback_ast?(callback) do
{:ok, [session, scope_builder.(scope), callback]}
else
:no_change
end
_ ->
:no_change
end
end
@spec callback_ast?(Macro.t()) :: boolean()
defp callback_ast?({:fn, _, _}), do: true
defp callback_ast?({:&, _, _}), do: true
defp callback_ast?(_), do: false
@spec binary_literal_ast?(Macro.t()) :: boolean()
defp binary_literal_ast?(value) when is_binary(value), do: true
defp binary_literal_ast?({:__block__, _meta, [value]}), do: binary_literal_ast?(value)
defp binary_literal_ast?(_value), do: false
@spec build_local_css_scope(Macro.t()) :: Macro.t()
defp build_local_css_scope(scope_ast), do: {:css, [], [scope_ast]}
@spec build_remote_css_scope(Macro.t(), Macro.t()) :: Macro.t()
defp build_remote_css_scope(module_ast, scope_ast) do
{{:., [], [module_ast, :css]}, [], [scope_ast]}
end
@spec split_text_assertion_args([Macro.t()]) :: split_args_result()
defp split_text_assertion_args([session, maybe_opts]), do: {:ok, [session], maybe_opts, :none}
defp split_text_assertion_args([session, scope, maybe_opts]), do: {:ok, [session, scope], maybe_opts, :none}
defp split_text_assertion_args(_args), do: :error
@spec rewrite_config_app([Macro.t()]) :: {:ok, [Macro.t()]} | :no_change
defp rewrite_config_app([app_ast | rest]) do
case atom_literal(app_ast) do
{:ok, :phoenix_test} ->
{:ok, [replace_atom_literal(app_ast, :cerberus) | rest]}
_ ->
:no_change
end
end
defp rewrite_config_app(_args), do: :no_change
@spec atom_literal(Macro.t()) :: {:ok, atom()} | :error
defp atom_literal(value) when is_atom(value), do: {:ok, value}
defp atom_literal({:__block__, _meta, [value]}), do: atom_literal(value)
defp atom_literal(_value), do: :error
@spec replace_atom_literal(Macro.t(), atom()) :: Macro.t()
defp replace_atom_literal({:__block__, meta, [_old]}, replacement) when is_atom(replacement) do
{:__block__, meta, [replacement]}
end
defp replace_atom_literal(_old, replacement), do: replacement
@spec split_labeled_value_args([Macro.t()]) :: split_args_result()
defp split_labeled_value_args([locator, maybe_opts]), do: {:ok, [locator], maybe_opts, :none}
defp split_labeled_value_args([session, locator, maybe_opts]), do: {:ok, [session, locator], maybe_opts, :none}
defp split_labeled_value_args([session, locator, maybe_opts, trailing_opts]),
do: {:ok, [session, locator], maybe_opts, trailing_opts}
defp split_labeled_value_args(_args), do: :error
@spec merge_trailing_opts(keyword(), keyword() | :none) :: {:ok, keyword()} | :error
defp merge_trailing_opts(remaining_opts, :none), do: {:ok, remaining_opts}
defp merge_trailing_opts(remaining_opts, trailing_opts) do
if keyword_ast?(trailing_opts) do
{:ok, merge_keyword_opts(remaining_opts, normalize_keyword_ast(trailing_opts))}
else
:error
end
end
@spec merge_keyword_opts(keyword(), keyword()) :: keyword()
defp merge_keyword_opts([], trailing_opts), do: trailing_opts
defp merge_keyword_opts(remaining_opts, trailing_opts), do: Keyword.merge(remaining_opts, trailing_opts)
@spec build_value_args([Macro.t()], Macro.t(), keyword()) :: [Macro.t()]
defp build_value_args(prefix, value, []), do: prefix ++ [value]
defp build_value_args(prefix, value, merged_opts), do: prefix ++ [value, merged_opts]
@spec pop_keyword_ast(term(), atom()) :: {:ok, Macro.t(), keyword()} | :no_change
defp pop_keyword_ast(maybe_keyword, key) when is_atom(key) do
if keyword_ast?(maybe_keyword) do
case Keyword.pop(normalize_keyword_ast(maybe_keyword), key, :__not_found__) do
{:__not_found__, _rest} ->
:no_change
{value, rest} ->
{:ok, value, rest}
end
else
:no_change
end
end
@spec keyword_ast?(term()) :: boolean()
defp keyword_ast?(value) when is_list(value) do
Enum.all?(value, fn
{key, _val} -> keyword_key_atom(key) != :error
_other -> false
end)
end
defp keyword_ast?(_value), do: false
@spec normalize_keyword_ast(keyword()) :: keyword()
defp normalize_keyword_ast(keyword_list) when is_list(keyword_list) do
Enum.map(keyword_list, fn {key, value} ->
{unwrap_keyword_key(key), value}
end)
end
@spec keyword_key_atom(term()) :: {:ok, atom()} | :error
defp keyword_key_atom(key) when is_atom(key), do: {:ok, key}
defp keyword_key_atom({:__block__, _meta, [key]}) when is_atom(key), do: {:ok, key}
defp keyword_key_atom(_key), do: :error
@spec unwrap_keyword_key(term()) :: term()
defp unwrap_keyword_key(key) do
case keyword_key_atom(key) do
{:ok, atom_key} -> atom_key
:error -> key
end
end
@spec alias_parts(Macro.t()) :: [atom()] | :unknown
defp alias_parts({:__aliases__, _meta, parts}) when is_list(parts), do: parts
defp alias_parts(_module_ast), do: :unknown
@spec alias_ast(Macro.t(), [atom()]) :: Macro.t()
defp alias_ast({:__aliases__, meta, _parts}, parts), do: {:__aliases__, meta, parts}
defp alias_ast(_module_ast, parts), do: {:__aliases__, [], parts}
@spec rewrite_remote_call(Macro.t(), Macro.t(), atom(), [Macro.t()], RewriteState.t()) ::
{Macro.t(), RewriteState.t()}
defp rewrite_remote_call(node, module_ast, fun, args, state) do
case alias_parts(module_ast) do
[:PhoenixTest] ->
rewrite_phoenix_test_remote_call(node, module_ast, fun, args, state)
[:PhoenixTest, :Assertions] ->
rewrite_assertions_remote_call(node, module_ast, fun, state)
[:PhoenixTest | _] ->
{node, add_warning(state, @warning_submodule_alias)}
_ ->
{node, state}
end
end
@spec rewrite_remote_module(Macro.t(), Macro.t(), [atom()]) :: Macro.t()
defp rewrite_remote_module({{:., dot_meta, [module_ast, fun]}, call_meta, args}, module_ast, parts) do
{{:., dot_meta, [alias_ast(module_ast, parts), fun]}, call_meta, args}
end
@spec rewrite_remote_module(Macro.t(), Macro.t(), [atom()], [Macro.t()]) :: Macro.t()
defp rewrite_remote_module({{:., dot_meta, [module_ast, fun]}, call_meta, _args}, module_ast, parts, args) do
{{:., dot_meta, [alias_ast(module_ast, parts), fun]}, call_meta, args}
end
@spec rewrite_phoenix_test_remote_call(Macro.t(), Macro.t(), atom(), [Macro.t()], RewriteState.t()) ::
{Macro.t(), RewriteState.t()}
defp rewrite_phoenix_test_remote_call(node, module_ast, fun, args, state) do
cond do
fun in @browser_helpers ->
{node, state}
fun == :visit and conn_first_arg?(args) ->
updated_args = replace_first_arg_with_session_bootstrap(args)
{rewrite_remote_module(node, module_ast, [:Cerberus], updated_args), mark_changed(state)}
fun in @rewritable_direct_calls ->
{rewrite_remote_module(node, module_ast, [:Cerberus]), mark_changed(state)}
true ->
{node, add_warning(state, @warning_direct_call)}
end
end
@spec rewrite_assertions_remote_call(Macro.t(), Macro.t(), atom(), RewriteState.t()) ::
{Macro.t(), RewriteState.t()}
defp rewrite_assertions_remote_call(node, module_ast, fun, state) do
if fun in @rewritable_assertions_calls do
{rewrite_remote_module(node, module_ast, [:Cerberus]), mark_changed(state)}
else
{node, add_warning(state, @warning_submodule_alias)}
end
end
@spec rewrite_assertions_alias_opts([term()], Macro.t()) :: {:ok, [keyword()]} | :error
defp rewrite_assertions_alias_opts([], module_ast) do
{:ok, [[as: alias_ast(module_ast, [:Assertions])]]}
end
defp rewrite_assertions_alias_opts([opts], module_ast) when is_list(opts) do
if keyword_ast?(opts) do
normalized_opts = normalize_keyword_ast(opts)
{:ok, [Keyword.put_new(normalized_opts, :as, alias_ast(module_ast, [:Assertions]))]}
else
:error
end
end
defp rewrite_assertions_alias_opts(_rest, _module_ast), do: :error
@spec rewrite_alias_node(Macro.t(), keyword(), Macro.t(), [term()], RewriteState.t()) ::
{Macro.t(), RewriteState.t()}
defp rewrite_alias_node(node, meta, module_ast, rest, state) do
case alias_parts(module_ast) do
[:PhoenixTest] ->
updated = {:alias, meta, [alias_ast(module_ast, [:Cerberus]) | rest]}
{updated, mark_changed(state)}
[:PhoenixTest, :Assertions] ->
rewrite_assertions_alias_node(node, meta, module_ast, rest, state)
[:PhoenixTest | _] ->
{node, add_warning(state, @warning_submodule_alias)}
_ ->
{node, state}
end
end
@spec rewrite_assertions_alias_node(Macro.t(), keyword(), Macro.t(), [term()], RewriteState.t()) ::
{Macro.t(), RewriteState.t()}
defp rewrite_assertions_alias_node(node, meta, module_ast, rest, state) do
case rewrite_assertions_alias_opts(rest, module_ast) do
{:ok, alias_opts} ->
updated = {:alias, meta, [alias_ast(module_ast, [:Cerberus]) | alias_opts]}
{updated, mark_changed(state)}
:error ->
{node, add_warning(state, @warning_submodule_alias)}
end
end
@spec maybe_warn_browser_helper(RewriteState.t(), atom()) :: RewriteState.t()
defp maybe_warn_browser_helper(state, fun) when fun in @browser_helpers, do: add_warning(state, @warning_browser_helper)
defp maybe_warn_browser_helper(state, _fun), do: state
@spec conn_first_arg?([Macro.t()]) :: boolean()
defp conn_first_arg?([first | _rest]), do: conn_arg?(first)
defp conn_first_arg?(_args), do: false
@spec visit_call?(Macro.t()) :: boolean()
defp visit_call?({:visit, _meta, args}) when is_list(args), do: true
defp visit_call?({{:., _meta, [_module_ast, :visit]}, _call_meta, args}) when is_list(args), do: true
defp visit_call?(_other), do: false
@spec conn_arg?(Macro.t()) :: boolean()
defp conn_arg?({:conn, _meta, context}) when is_atom(context) or is_nil(context), do: true
defp conn_arg?(_other), do: false
@spec replace_first_arg_with_session_bootstrap([Macro.t()]) :: [Macro.t()]
defp replace_first_arg_with_session_bootstrap([first | rest]), do: [session_bootstrap_ast_from(first) | rest]
defp replace_first_arg_with_session_bootstrap(args), do: args
@spec session_bootstrap_ast(Macro.t()) :: Macro.t()
defp session_bootstrap_ast(conn_arg) do
quote do
session(unquote(conn_arg))
end
end
@spec session_bootstrap_ast_from(Macro.t()) :: Macro.t()
defp session_bootstrap_ast_from(original_node) do
transfer_comment_metadata(session_bootstrap_ast(original_node), original_node)
end
@spec transfer_comment_metadata(Macro.t(), Macro.t()) :: Macro.t()
defp transfer_comment_metadata({name, meta, args}, {_, source_meta, _}) when is_list(meta) and is_list(source_meta) do
source_leading = Keyword.get(source_meta, :leading_comments, [])
source_trailing = Keyword.get(source_meta, :trailing_comments, [])
merged_meta =
meta
|> put_comment_metadata(:leading_comments, source_leading)
|> put_comment_metadata(:trailing_comments, source_trailing)
{name, merged_meta, args}
end
defp transfer_comment_metadata(ast, _source), do: ast
@spec put_comment_metadata(keyword(), atom(), [term()]) :: keyword()
defp put_comment_metadata(meta, _key, []), do: meta
defp put_comment_metadata(meta, key, comments), do: Keyword.put(meta, key, comments)
@spec mark_changed(RewriteState.t()) :: RewriteState.t()
defp mark_changed(%RewriteState{} = state), do: %{state | changed?: true}
@spec add_warning(RewriteState.t(), String.t()) :: RewriteState.t()
defp add_warning(%RewriteState{} = state, warning) do
if MapSet.member?(state.seen, warning) do
state
else
%{state | warnings: [warning | state.warnings], seen: MapSet.put(state.seen, warning)}
end
end
@spec print_warnings(String.t(), warning_messages()) :: :ok
defp print_warnings(_file, []), do: :ok
defp print_warnings(file, warnings) do
Enum.each(warnings, fn warning ->
IO.puts("WARNING #{file}: #{warning}")
end)
end
@spec print_diff(String.t(), String.t(), String.t()) :: :ok
defp print_diff(file, _original, rewritten) do
tmp = Path.join(System.tmp_dir!(), "cerberus-migrate-#{System.unique_integer([:positive])}.tmp")
File.write!(tmp, rewritten)
{output, _status} =
System.cmd("git", ["diff", "--no-index", "--", file, tmp], stderr_to_stdout: true)
output |> String.replace(tmp, file <> ".migrated") |> IO.write()
File.rm(tmp)
:ok
end
end