From 1244598266ae889eedb9f7ee75d95a2723b02f36 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Mon, 26 Aug 2024 22:32:31 -0700 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Process=20command?= =?UTF-8?q?-line=20args=20before=20starting=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to prepare for config-less operation, we need to process the command-line args before starting the main parts of the application. In order to make that work well, we only start a `DynamicSupervisor` intiially and then start the real supervisor later once we've processed the command-line arguments. --- lib/mix/tasks/test/interactive.ex | 1 + lib/mix_test_interactive.ex | 14 +++++++---- lib/mix_test_interactive/application.ex | 9 +------ lib/mix_test_interactive/interactive_mode.ex | 23 +++-------------- lib/mix_test_interactive/supervisor.ex | 25 +++++++++++++++++++ test/mix_test_interactive/end_to_end_test.exs | 9 ++++--- 6 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 lib/mix_test_interactive/supervisor.ex diff --git a/lib/mix/tasks/test/interactive.ex b/lib/mix/tasks/test/interactive.ex index 4a8700f..25dd990 100644 --- a/lib/mix/tasks/test/interactive.ex +++ b/lib/mix/tasks/test/interactive.ex @@ -78,6 +78,7 @@ defmodule Mix.Tasks.Test.Interactive do use Mix.Task @preferred_cli_env :test + @requirements ["app.config"] defdelegate run(args), to: MixTestInteractive end diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index 59268b3..cc189f2 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -2,18 +2,22 @@ defmodule MixTestInteractive do @moduledoc """ Interactively run your Elixir project's tests. """ - + alias MixTestInteractive.InitialSupervisor alias MixTestInteractive.InteractiveMode + alias MixTestInteractive.MainSupervisor + alias MixTestInteractive.Settings + + @application :mix_test_interactive @doc """ Start the interactive test runner. """ - @spec run([String.t()]) :: no_return() def run(args \\ []) when is_list(args) do - Mix.env(:test) - {:ok, _} = Application.ensure_all_started(:mix_test_interactive) + settings = Settings.new(args) + + {:ok, _} = Application.ensure_all_started(@application) + {:ok, _supervisor} = DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, settings: settings}) - InteractiveMode.command_line_arguments(args) loop() end diff --git a/lib/mix_test_interactive/application.ex b/lib/mix_test_interactive/application.ex index a6a2f19..2b213dc 100644 --- a/lib/mix_test_interactive/application.ex +++ b/lib/mix_test_interactive/application.ex @@ -3,17 +3,10 @@ defmodule MixTestInteractive.Application do use Application - alias MixTestInteractive.Config - alias MixTestInteractive.InteractiveMode - alias MixTestInteractive.Watcher - @impl Application def start(_type, _args) do - config = Config.new() - children = [ - {InteractiveMode, config: config}, - {Watcher, config: config} + {DynamicSupervisor, strategy: :one_for_one, name: MixTestInteractive.InitialSupervisor} ] opts = [strategy: :one_for_one, name: MixTestInteractive.Supervisor] diff --git a/lib/mix_test_interactive/interactive_mode.ex b/lib/mix_test_interactive/interactive_mode.ex index 5539e15..cede069 100644 --- a/lib/mix_test_interactive/interactive_mode.ex +++ b/lib/mix_test_interactive/interactive_mode.ex @@ -25,19 +25,11 @@ defmodule MixTestInteractive.InteractiveMode do def start_link(options) do name = Keyword.get(options, :name, __MODULE__) config = Keyword.fetch!(options, :config) - initial_state = %{config: config, settings: Settings.new()} + settings = Keyword.fetch!(options, :settings) + initial_state = %{config: config, settings: settings} GenServer.start_link(__MODULE__, initial_state, name: name) end - @doc """ - Process command-line arguments. - """ - @spec command_line_arguments([String.t()]) :: :ok - @spec command_line_arguments(GenServer.server(), [String.t()]) :: :ok - def command_line_arguments(server \\ __MODULE__, cli_args) do - GenServer.call(server, {:command_line_arguments, cli_args}) - end - @doc """ Process a command from the user. """ @@ -58,13 +50,7 @@ defmodule MixTestInteractive.InteractiveMode do @impl GenServer def init(initial_state) do - {:ok, initial_state} - end - - @impl GenServer - def handle_call({:command_line_arguments, cli_args}, _from, state) do - settings = Settings.new(cli_args) - {:reply, :ok, %{state | settings: settings}, {:continue, :run_tests}} + {:ok, initial_state, {:continue, :run_tests}} end @impl GenServer @@ -123,9 +109,6 @@ defmodule MixTestInteractive.InteractiveMode do |> IO.puts() :ok - - error -> - error end end diff --git a/lib/mix_test_interactive/supervisor.ex b/lib/mix_test_interactive/supervisor.ex new file mode 100644 index 0000000..81389ee --- /dev/null +++ b/lib/mix_test_interactive/supervisor.ex @@ -0,0 +1,25 @@ +defmodule MixTestInteractive.MainSupervisor do + @moduledoc false + use Supervisor + + alias MixTestInteractive.Config + alias MixTestInteractive.InteractiveMode + alias MixTestInteractive.Watcher + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl Supervisor + def init(opts) do + settings = Keyword.fetch!(opts, :settings) + config = Config.new() + + children = [ + {InteractiveMode, config: config, settings: settings}, + {Watcher, config: config} + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/test/mix_test_interactive/end_to_end_test.exs b/test/mix_test_interactive/end_to_end_test.exs index 6e7c8dd..33688e1 100644 --- a/test/mix_test_interactive/end_to_end_test.exs +++ b/test/mix_test_interactive/end_to_end_test.exs @@ -3,6 +3,7 @@ defmodule MixTestInteractive.EndToEndTest do alias MixTestInteractive.Config alias MixTestInteractive.InteractiveMode + alias MixTestInteractive.Settings defmodule DummyRunner do @moduledoc false @@ -15,20 +16,20 @@ defmodule MixTestInteractive.EndToEndTest do end @config %Config{runner: DummyRunner} + @settings Settings.new([]) setup do test_pid = self() {:ok, _} = Agent.start_link(fn -> test_pid end, name: DummyRunner) - {:ok, io} = StringIO.open("") - {:ok, pid} = start_supervised({InteractiveMode, config: @config, name: :end_to_end}) - Process.group_leader(pid, io) + {:ok, io} = StringIO.open("") + Process.group_leader(self(), io) + {:ok, pid} = start_supervised({InteractiveMode, config: @config, name: :end_to_end, settings: @settings}) %{pid: pid} end test "end to end workflow test", %{pid: pid} do - InteractiveMode.command_line_arguments(pid, []) assert_ran_tests() assert :ok = InteractiveMode.process_command(pid, "") From 24ab2ae8ac5d34bff6b4853553e8e1e70ffc2b0c Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 16:41:25 -0700 Subject: [PATCH 02/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20argument?= =?UTF-8?q?=20parsing=20from=20Settings=20->=20CommandLineParser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This sets the stage for specifying config options on the command line in addition to settings. --- lib/mix_test_interactive.ex | 3 +- .../command_line_parser.ex | 60 ++++++++ lib/mix_test_interactive/settings.ex | 59 -------- .../command_line_parser_test.exs | 78 +++++++++++ .../command_processor_test.exs | 28 ++-- test/mix_test_interactive/end_to_end_test.exs | 2 +- test/mix_test_interactive/settings_test.exs | 130 +++--------------- 7 files changed, 172 insertions(+), 188 deletions(-) create mode 100644 lib/mix_test_interactive/command_line_parser.ex create mode 100644 test/mix_test_interactive/command_line_parser_test.exs diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index cc189f2..4e1655d 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -2,6 +2,7 @@ defmodule MixTestInteractive do @moduledoc """ Interactively run your Elixir project's tests. """ + alias MixTestInteractive.CommandLineParser alias MixTestInteractive.InitialSupervisor alias MixTestInteractive.InteractiveMode alias MixTestInteractive.MainSupervisor @@ -13,7 +14,7 @@ defmodule MixTestInteractive do Start the interactive test runner. """ def run(args \\ []) when is_list(args) do - settings = Settings.new(args) + %Settings{} = settings = CommandLineParser.parse(args) {:ok, _} = Application.ensure_all_started(@application) {:ok, _supervisor} = DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, settings: settings}) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex new file mode 100644 index 0000000..90b95b2 --- /dev/null +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -0,0 +1,60 @@ +defmodule MixTestInteractive.CommandLineParser do + @moduledoc false + + use TypedStruct + + alias MixTestInteractive.Settings + + @options [ + watch: :boolean + ] + + @mix_test_options [ + all_warnings: :boolean, + archives_check: :boolean, + color: :boolean, + compile: :boolean, + cover: :boolean, + deps_check: :boolean, + elixir_version_check: :boolean, + exclude: :keep, + exit_status: :integer, + export_coverage: :string, + failed: :boolean, + force: :boolean, + formatter: :keep, + include: :keep, + listen_on_stdin: :boolean, + max_cases: :integer, + max_failures: :integer, + only: :keep, + partitions: :integer, + preload_modules: :boolean, + profile_require: :string, + raise: :boolean, + seed: :integer, + slowest: :integer, + stale: :boolean, + start: :boolean, + timeout: :integer, + trace: :boolean, + warnings_as_errors: :boolean + ] + + @spec parse([String.t()]) :: Settings.t() + def parse(cli_args \\ []) do + {opts, patterns} = OptionParser.parse!(cli_args, switches: @options ++ @mix_test_options) + no_patterns? = Enum.empty?(patterns) + {failed?, opts} = Keyword.pop(opts, :failed, false) + {stale?, opts} = Keyword.pop(opts, :stale, false) + {watching?, opts} = Keyword.pop(opts, :watch, true) + + %Settings{ + failed?: no_patterns? && failed?, + initial_cli_args: OptionParser.to_argv(opts), + patterns: patterns, + stale?: no_patterns? && !failed? && stale?, + watching?: watching? + } + end +end diff --git a/lib/mix_test_interactive/settings.ex b/lib/mix_test_interactive/settings.ex index 4e39a82..c8e1885 100644 --- a/lib/mix_test_interactive/settings.ex +++ b/lib/mix_test_interactive/settings.ex @@ -22,65 +22,6 @@ defmodule MixTestInteractive.Settings do field :watching?, boolean(), default: true end - @options [ - watch: :boolean - ] - - @mix_test_options [ - all_warnings: :boolean, - archives_check: :boolean, - color: :boolean, - compile: :boolean, - cover: :boolean, - deps_check: :boolean, - elixir_version_check: :boolean, - exclude: :keep, - exit_status: :integer, - export_coverage: :string, - failed: :boolean, - force: :boolean, - formatter: :keep, - include: :keep, - listen_on_stdin: :boolean, - max_cases: :integer, - max_failures: :integer, - only: :keep, - partitions: :integer, - preload_modules: :boolean, - profile_require: :string, - raise: :boolean, - seed: :integer, - slowest: :integer, - stale: :boolean, - start: :boolean, - timeout: :integer, - trace: :boolean, - warnings_as_errors: :boolean - ] - - @doc """ - Create a new state struct, taking values from the command line. - - In addition to its own options, new/1 initializes its interactive mode settings from some of - `mix test`'s options (`--failed`, `--stale`, and any filename arguments). - """ - @spec new([String.t()]) :: t() - def new(cli_args \\ []) do - {opts, patterns} = OptionParser.parse!(cli_args, switches: @options ++ @mix_test_options) - no_patterns? = Enum.empty?(patterns) - {failed?, opts} = Keyword.pop(opts, :failed, false) - {stale?, opts} = Keyword.pop(opts, :stale, false) - {watching?, opts} = Keyword.pop(opts, :watch, true) - - %__MODULE__{ - failed?: no_patterns? && failed?, - initial_cli_args: OptionParser.to_argv(opts), - patterns: patterns, - stale?: no_patterns? && !failed? && stale?, - watching?: watching? - } - end - @doc """ Assemble command-line arguments to pass to `mix test`. diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs new file mode 100644 index 0000000..1b0cb85 --- /dev/null +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -0,0 +1,78 @@ +defmodule MixTestInteractive.CommandLineParserTest do + use ExUnit.Case, async: true + + alias MixTestInteractive.CommandLineParser + alias MixTestInteractive.Settings + + describe "watch mode" do + test "enables with --watch flag" do + %Settings{} = settings = CommandLineParser.parse(["--watch"]) + assert settings.watching? + end + + test "disables with --no-watch flag" do + %Settings{} = settings = CommandLineParser.parse(["--no-watch"]) + refute settings.watching? + end + + test "consumes --watch flag" do + %Settings{} = settings = CommandLineParser.parse(["--watch"]) + assert {:ok, []} = Settings.cli_args(settings) + end + + test "consumes --no-watch flag" do + %Settings{} = settings = CommandLineParser.parse(["--no-watch"]) + assert {:ok, []} = Settings.cli_args(settings) + end + end + + describe "mix test arguments" do + test "records initial `mix test` arguments" do + %Settings{} = settings = CommandLineParser.parse(["--trace", "--raise"]) + assert settings.initial_cli_args == ["--trace", "--raise"] + end + + test "records no `mix test` arguments by default" do + %Settings{} = settings = CommandLineParser.parse() + assert settings.initial_cli_args == [] + end + + test "omits unknown arguments" do + %Settings{} = settings = CommandLineParser.parse(["--unknown-arg"]) + + assert settings.initial_cli_args == [] + end + + test "extracts stale setting from arguments" do + %Settings{} = settings = CommandLineParser.parse(["--trace", "--stale", "--raise"]) + assert settings.stale? + assert settings.initial_cli_args == ["--trace", "--raise"] + end + + test "extracts failed flag from arguments" do + %Settings{} = settings = CommandLineParser.parse(["--trace", "--failed", "--raise"]) + assert settings.failed? + assert settings.initial_cli_args == ["--trace", "--raise"] + end + + test "extracts patterns from arguments" do + %Settings{} = settings = CommandLineParser.parse(["pattern1", "--trace", "pattern2"]) + assert settings.patterns == ["pattern1", "pattern2"] + assert settings.initial_cli_args == ["--trace"] + end + + test "failed takes precedence over stale" do + %Settings{} = settings = CommandLineParser.parse(["--failed", "--stale"]) + refute settings.stale? + assert settings.failed? + end + + test "patterns take precedence over stale/failed flags" do + %Settings{} = settings = CommandLineParser.parse(["--failed", "--stale", "pattern"]) + assert settings.patterns == ["pattern"] + refute settings.failed? + refute settings.stale? + assert settings.initial_cli_args == [] + end + end +end diff --git a/test/mix_test_interactive/command_processor_test.exs b/test/mix_test_interactive/command_processor_test.exs index 6256c63..666c79c 100644 --- a/test/mix_test_interactive/command_processor_test.exs +++ b/test/mix_test_interactive/command_processor_test.exs @@ -4,7 +4,7 @@ defmodule MixTestInteractive.CommandProcessorTest do alias MixTestInteractive.CommandProcessor alias MixTestInteractive.Settings - defp process_command(command, settings \\ Settings.new([])) do + defp process_command(command, settings \\ %Settings{}) do CommandProcessor.call(command, settings) end @@ -18,55 +18,55 @@ defmodule MixTestInteractive.CommandProcessorTest do end test "Enter returns ok tuple" do - settings = Settings.new() + settings = %Settings{} assert {:ok, ^settings} = process_command("", settings) end test "p filters test files to those matching provided pattern" do - settings = Settings.new() + settings = %Settings{} expected = Settings.only_patterns(settings, ["pattern"]) assert {:ok, ^expected} = process_command("p pattern", settings) end test "p a second time replaces patterns with new ones" do - settings = Settings.new() - {:ok, first_config} = process_command("p first", Settings.new()) + settings = %Settings{} + {:ok, first_config} = process_command("p first", %Settings{}) expected = Settings.only_patterns(settings, ["second"]) assert {:ok, ^expected} = process_command("p second", first_config) end test "s runs only stale tests" do - settings = Settings.new() + settings = %Settings{} expected = Settings.only_stale(settings) assert {:ok, ^expected} = process_command("s", settings) end test "f runs only failed tests" do - settings = Settings.new() + settings = %Settings{} expected = Settings.only_failed(settings) assert {:ok, ^expected} = process_command("f", settings) end test "a runs all tests" do - {:ok, settings} = process_command("s", Settings.new()) + {:ok, settings} = process_command("s", %Settings{}) expected = Settings.all_tests(settings) assert {:ok, ^expected} = process_command("a", settings) end test "w toggles watch mode" do - settings = Settings.new() + settings = %Settings{} expected = Settings.toggle_watch_mode(settings) assert {:no_run, ^expected} = process_command("w", settings) end test "? returns :help" do - settings = Settings.new() + settings = %Settings{} assert :help = process_command("?", settings) end @@ -78,28 +78,28 @@ defmodule MixTestInteractive.CommandProcessorTest do describe "usage information" do test "shows relevant commands when running all tests" do - settings = Settings.new() + settings = %Settings{} assert_commands(settings, ["p ", "s", "f"], ~w(a)) end test "shows relevant commands when filtering by pattern" do settings = - Settings.only_patterns(Settings.new(), ["pattern"]) + Settings.only_patterns(%Settings{}, ["pattern"]) assert_commands(settings, ["p ", "s", "f", "a"], ~w(p)) end test "shows relevant commands when running failed tests" do settings = - Settings.only_failed(Settings.new()) + Settings.only_failed(%Settings{}) assert_commands(settings, ["p ", "s", "a"], ~w(f)) end test "shows relevant commands when running stale tests" do settings = - Settings.only_stale(Settings.new()) + Settings.only_stale(%Settings{}) assert_commands(settings, ["p ", "f", "a"], ~w(s)) end diff --git a/test/mix_test_interactive/end_to_end_test.exs b/test/mix_test_interactive/end_to_end_test.exs index 33688e1..8d2cdd0 100644 --- a/test/mix_test_interactive/end_to_end_test.exs +++ b/test/mix_test_interactive/end_to_end_test.exs @@ -16,7 +16,7 @@ defmodule MixTestInteractive.EndToEndTest do end @config %Config{runner: DummyRunner} - @settings Settings.new([]) + @settings %Settings{} setup do test_pid = self() diff --git a/test/mix_test_interactive/settings_test.exs b/test/mix_test_interactive/settings_test.exs index 6abeb36..df7a9b6 100644 --- a/test/mix_test_interactive/settings_test.exs +++ b/test/mix_test_interactive/settings_test.exs @@ -3,104 +3,12 @@ defmodule MixTestInteractive.SettingsTest do alias MixTestInteractive.Settings - describe "watch mode" do - test "enabled by default" do - settings = Settings.new() - - assert settings.watching? - end - - test "disables with --no-watch flag" do - settings = Settings.new(["--no-watch"]) - refute settings.watching? - end - - test "consumes --watch flag" do - settings = Settings.new(["--watch"]) - assert {:ok, []} = Settings.cli_args(settings) - end - - test "consumes --no-watch flag" do - settings = Settings.new(["--no-watch"]) - assert {:ok, []} = Settings.cli_args(settings) - end - - test "toggles off" do - settings = - Settings.toggle_watch_mode(Settings.new()) - - refute settings.watching? - end - - test "toggles back on" do - settings = - Settings.new() - |> Settings.toggle_watch_mode() - |> Settings.toggle_watch_mode() - - assert settings.watching? - end - end - - describe "command line arguments" do - test "passes on provided arguments" do - settings = Settings.new(["--trace", "--raise"]) - {:ok, args} = Settings.cli_args(settings) - assert args == ["--trace", "--raise"] - end - - test "passes no arguments by default" do - settings = Settings.new() - {:ok, args} = Settings.cli_args(settings) - assert args == [] - end - - test "omits unknown arguments" do - settings = Settings.new(["--unknown-arg"]) - - assert settings.initial_cli_args == [] - end - - test "initializes stale flag from arguments" do - settings = Settings.new(["--trace", "--stale", "--raise"]) - assert settings.stale? - assert settings.initial_cli_args == ["--trace", "--raise"] - end - - test "initializes failed flag from arguments" do - settings = Settings.new(["--trace", "--failed", "--raise"]) - assert settings.failed? - assert settings.initial_cli_args == ["--trace", "--raise"] - end - - test "initializes patterns from arguments" do - settings = Settings.new(["pattern1", "--trace", "pattern2"]) - assert settings.patterns == ["pattern1", "pattern2"] - assert settings.initial_cli_args == ["--trace"] - end - - test "failed takes precedence to stale" do - settings = Settings.new(["--failed", "--stale"]) - refute settings.stale? - assert settings.failed? - end - - test "patterns take precedence to stale/failed flags" do - settings = Settings.new(["--failed", "--stale", "pattern"]) - assert settings.patterns == ["pattern"] - refute settings.failed? - refute settings.stale? - assert settings.initial_cli_args == [] - end - end - describe "filtering tests" do test "filters to files matching patterns" do all_files = ~w(file1 file2 no_match other) settings = - ["--trace"] - |> Settings.new() + %Settings{initial_cli_args: ["--trace"]} |> with_fake_file_list(all_files) |> Settings.only_patterns(["file", "other"]) @@ -110,7 +18,7 @@ defmodule MixTestInteractive.SettingsTest do test "returns error if no files match pattern" do settings = - Settings.new() + %Settings{} |> with_fake_file_list([]) |> Settings.only_patterns(["file"]) @@ -119,9 +27,7 @@ defmodule MixTestInteractive.SettingsTest do test "restricts to failed tests" do settings = - ["--trace"] - |> Settings.new() - |> Settings.only_failed() + Settings.only_failed(%Settings{initial_cli_args: ["--trace"]}) {:ok, args} = Settings.cli_args(settings) assert args == ["--trace", "--failed"] @@ -129,9 +35,7 @@ defmodule MixTestInteractive.SettingsTest do test "restricts to stale tests" do settings = - ["--trace"] - |> Settings.new() - |> Settings.only_stale() + Settings.only_stale(%Settings{initial_cli_args: ["--trace"]}) {:ok, args} = Settings.cli_args(settings) assert args == ["--trace", "--stale"] @@ -139,7 +43,7 @@ defmodule MixTestInteractive.SettingsTest do test "pattern filter clears failed flag" do settings = - Settings.new() + %Settings{} |> with_fake_file_list(["file"]) |> Settings.only_failed() |> Settings.only_patterns(["f"]) @@ -150,7 +54,7 @@ defmodule MixTestInteractive.SettingsTest do test "pattern filter clears stale flag" do settings = - Settings.new() + %Settings{} |> with_fake_file_list(["file"]) |> Settings.only_stale() |> Settings.only_patterns(["f"]) @@ -161,7 +65,7 @@ defmodule MixTestInteractive.SettingsTest do test "failed flag clears pattern filters" do settings = - Settings.new() + %Settings{} |> Settings.only_patterns(["file"]) |> Settings.only_failed() @@ -171,7 +75,7 @@ defmodule MixTestInteractive.SettingsTest do test "failed flag clears stale flag" do settings = - Settings.new() + %Settings{} |> Settings.only_stale() |> Settings.only_failed() @@ -181,7 +85,7 @@ defmodule MixTestInteractive.SettingsTest do test "stale flag clears pattern filters" do settings = - Settings.new() + %Settings{} |> Settings.only_patterns(["file"]) |> Settings.only_stale() @@ -191,7 +95,7 @@ defmodule MixTestInteractive.SettingsTest do test "stale flag clears failed flag" do settings = - Settings.new() + %Settings{} |> Settings.only_failed() |> Settings.only_stale() @@ -201,7 +105,7 @@ defmodule MixTestInteractive.SettingsTest do test "all tests clears pattern filters" do settings = - Settings.new() + %Settings{} |> Settings.only_patterns(["pattern"]) |> Settings.all_tests() @@ -211,7 +115,7 @@ defmodule MixTestInteractive.SettingsTest do test "all tests removes stale flag" do settings = - Settings.new() + %Settings{} |> Settings.only_stale() |> Settings.all_tests() @@ -221,7 +125,7 @@ defmodule MixTestInteractive.SettingsTest do test "all tests removes failed flag" do settings = - Settings.new() + %Settings{} |> Settings.only_failed() |> Settings.all_tests() @@ -236,25 +140,25 @@ defmodule MixTestInteractive.SettingsTest do describe "summary" do test "ran all tests" do - settings = Settings.new() + settings = %Settings{} assert Settings.summary(settings) == "Ran all tests" end test "ran failed tests" do - settings = Settings.only_failed(Settings.new()) + settings = Settings.only_failed(%Settings{}) assert Settings.summary(settings) == "Ran only failed tests" end test "ran stale tests" do - settings = Settings.only_stale(Settings.new()) + settings = Settings.only_stale(%Settings{}) assert Settings.summary(settings) == "Ran only stale tests" end test "ran specific patterns" do - settings = Settings.only_patterns(Settings.new(), ["p1", "p2"]) + settings = Settings.only_patterns(%Settings{}, ["p1", "p2"]) assert Settings.summary(settings) == "Ran all test files matching p1, p2" end From 5979011669a6bd927c84750bf4c63d841ea0d7de Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 17:08:30 -0700 Subject: [PATCH 03/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Return=20config=20fr?= =?UTF-8?q?om=20CommandLineParser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets the stage for allowing config settings to be passed on the command line. --- lib/mix_test_interactive.ex | 7 +++-- .../command_line_parser.ex | 7 +++-- .../{supervisor.ex => main_supervisor.ex} | 3 +- .../command_line_parser_test.exs | 29 +++++++++---------- 4 files changed, 24 insertions(+), 22 deletions(-) rename lib/mix_test_interactive/{supervisor.ex => main_supervisor.ex} (89%) diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index 4e1655d..22ebd6c 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -6,7 +6,6 @@ defmodule MixTestInteractive do alias MixTestInteractive.InitialSupervisor alias MixTestInteractive.InteractiveMode alias MixTestInteractive.MainSupervisor - alias MixTestInteractive.Settings @application :mix_test_interactive @@ -14,10 +13,12 @@ defmodule MixTestInteractive do Start the interactive test runner. """ def run(args \\ []) when is_list(args) do - %Settings{} = settings = CommandLineParser.parse(args) + {config, settings} = CommandLineParser.parse(args) {:ok, _} = Application.ensure_all_started(@application) - {:ok, _supervisor} = DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, settings: settings}) + + {:ok, _supervisor} = + DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, config: config, settings: settings}) loop() end diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 90b95b2..b24e254 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -3,6 +3,7 @@ defmodule MixTestInteractive.CommandLineParser do use TypedStruct + alias MixTestInteractive.Config alias MixTestInteractive.Settings @options [ @@ -41,7 +42,7 @@ defmodule MixTestInteractive.CommandLineParser do warnings_as_errors: :boolean ] - @spec parse([String.t()]) :: Settings.t() + @spec parse([String.t()]) :: {Config.t(), Settings.t()} def parse(cli_args \\ []) do {opts, patterns} = OptionParser.parse!(cli_args, switches: @options ++ @mix_test_options) no_patterns? = Enum.empty?(patterns) @@ -49,12 +50,14 @@ defmodule MixTestInteractive.CommandLineParser do {stale?, opts} = Keyword.pop(opts, :stale, false) {watching?, opts} = Keyword.pop(opts, :watch, true) - %Settings{ + settings = %Settings{ failed?: no_patterns? && failed?, initial_cli_args: OptionParser.to_argv(opts), patterns: patterns, stale?: no_patterns? && !failed? && stale?, watching?: watching? } + + {Config.new(), settings} end end diff --git a/lib/mix_test_interactive/supervisor.ex b/lib/mix_test_interactive/main_supervisor.ex similarity index 89% rename from lib/mix_test_interactive/supervisor.ex rename to lib/mix_test_interactive/main_supervisor.ex index 81389ee..547593d 100644 --- a/lib/mix_test_interactive/supervisor.ex +++ b/lib/mix_test_interactive/main_supervisor.ex @@ -2,7 +2,6 @@ defmodule MixTestInteractive.MainSupervisor do @moduledoc false use Supervisor - alias MixTestInteractive.Config alias MixTestInteractive.InteractiveMode alias MixTestInteractive.Watcher @@ -12,8 +11,8 @@ defmodule MixTestInteractive.MainSupervisor do @impl Supervisor def init(opts) do + config = Keyword.fetch!(opts, :config) settings = Keyword.fetch!(opts, :settings) - config = Config.new() children = [ {InteractiveMode, config: config, settings: settings}, diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 1b0cb85..9e6619f 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -2,73 +2,72 @@ defmodule MixTestInteractive.CommandLineParserTest do use ExUnit.Case, async: true alias MixTestInteractive.CommandLineParser - alias MixTestInteractive.Settings describe "watch mode" do test "enables with --watch flag" do - %Settings{} = settings = CommandLineParser.parse(["--watch"]) + {_config, settings} = CommandLineParser.parse(["--watch"]) assert settings.watching? end test "disables with --no-watch flag" do - %Settings{} = settings = CommandLineParser.parse(["--no-watch"]) + {_config, settings} = CommandLineParser.parse(["--no-watch"]) refute settings.watching? end test "consumes --watch flag" do - %Settings{} = settings = CommandLineParser.parse(["--watch"]) - assert {:ok, []} = Settings.cli_args(settings) + {_config, settings} = CommandLineParser.parse(["--watch"]) + assert settings.initial_cli_args == [] end test "consumes --no-watch flag" do - %Settings{} = settings = CommandLineParser.parse(["--no-watch"]) - assert {:ok, []} = Settings.cli_args(settings) + {_config, settings} = CommandLineParser.parse(["--no-watch"]) + assert settings.initial_cli_args == [] end end describe "mix test arguments" do test "records initial `mix test` arguments" do - %Settings{} = settings = CommandLineParser.parse(["--trace", "--raise"]) + {_config, settings} = CommandLineParser.parse(["--trace", "--raise"]) assert settings.initial_cli_args == ["--trace", "--raise"] end test "records no `mix test` arguments by default" do - %Settings{} = settings = CommandLineParser.parse() + {_config, settings} = CommandLineParser.parse() assert settings.initial_cli_args == [] end test "omits unknown arguments" do - %Settings{} = settings = CommandLineParser.parse(["--unknown-arg"]) + {_config, settings} = CommandLineParser.parse(["--unknown-arg"]) assert settings.initial_cli_args == [] end test "extracts stale setting from arguments" do - %Settings{} = settings = CommandLineParser.parse(["--trace", "--stale", "--raise"]) + {_config, settings} = CommandLineParser.parse(["--trace", "--stale", "--raise"]) assert settings.stale? assert settings.initial_cli_args == ["--trace", "--raise"] end test "extracts failed flag from arguments" do - %Settings{} = settings = CommandLineParser.parse(["--trace", "--failed", "--raise"]) + {_config, settings} = CommandLineParser.parse(["--trace", "--failed", "--raise"]) assert settings.failed? assert settings.initial_cli_args == ["--trace", "--raise"] end test "extracts patterns from arguments" do - %Settings{} = settings = CommandLineParser.parse(["pattern1", "--trace", "pattern2"]) + {_config, settings} = CommandLineParser.parse(["pattern1", "--trace", "pattern2"]) assert settings.patterns == ["pattern1", "pattern2"] assert settings.initial_cli_args == ["--trace"] end test "failed takes precedence over stale" do - %Settings{} = settings = CommandLineParser.parse(["--failed", "--stale"]) + {_config, settings} = CommandLineParser.parse(["--failed", "--stale"]) refute settings.stale? assert settings.failed? end test "patterns take precedence over stale/failed flags" do - %Settings{} = settings = CommandLineParser.parse(["--failed", "--stale", "pattern"]) + {_config, settings} = CommandLineParser.parse(["--failed", "--stale", "pattern"]) assert settings.patterns == ["pattern"] refute settings.failed? refute settings.stale? From e571cb04586d73bf6b20e56e66aa271f3870876e Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 17:42:30 -0700 Subject: [PATCH 04/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Clean=20up=20Config?= =?UTF-8?q?=20initialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename new -> load_from_environment to make what's happening more explicit - use a raw `%Config{}` struct where env loading isn't needed - Eliminate duplicate specification of config defaults - Inline AppConfig.fetch - separate module for this is overkill right now --- lib/mix_test_interactive/app_config.ex | 15 ---- .../command_line_parser.ex | 2 +- lib/mix_test_interactive/config.ex | 90 ++++++++----------- lib/mix_test_interactive/paths.ex | 2 +- test/mix_test_interactive/config_test.exs | 26 +++--- test/mix_test_interactive/paths_test.exs | 9 +- .../mix_test_interactive/port_runner_test.exs | 2 +- 7 files changed, 58 insertions(+), 88 deletions(-) delete mode 100644 lib/mix_test_interactive/app_config.ex diff --git a/lib/mix_test_interactive/app_config.ex b/lib/mix_test_interactive/app_config.ex deleted file mode 100644 index ecfdb09..0000000 --- a/lib/mix_test_interactive/app_config.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule MixTestInteractive.AppConfig do - @moduledoc false - - @type key :: Application.key() - @type value :: Application.value() - - @application :mix_test_interactive - - @spec get(key()) :: value() - @spec get(key(), value()) :: value() - def get(key, default \\ nil) do - from_app_env = Application.get_env(@application, key, default) - ProcessTree.get(key, default: from_app_env) - end -end diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index b24e254..397f294 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -58,6 +58,6 @@ defmodule MixTestInteractive.CommandLineParser do watching?: watching? } - {Config.new(), settings} + {Config.load_from_environment(), settings} end end diff --git a/lib/mix_test_interactive/config.ex b/lib/mix_test_interactive/config.ex index b8a6866..c9a9ee7 100644 --- a/lib/mix_test_interactive/config.ex +++ b/lib/mix_test_interactive/config.ex @@ -4,71 +4,53 @@ defmodule MixTestInteractive.Config do """ use TypedStruct - alias MixTestInteractive.AppConfig - - @default_clear false - @default_command {"mix", []} - @default_exclude [~r/\.#/, ~r{priv/repo/migrations}] - @default_extra_extensions [] - @default_runner MixTestInteractive.PortRunner - @default_show_timestamp false - @default_task "test" + @application :mix_test_interactive typedstruct do - field :clear?, boolean, default: @default_clear - field :command, {String.t(), [String.t()]}, default: @default_command - field :exclude, [Regex.t()], default: @default_exclude - field :extra_extensions, [String.t()], default: @default_extra_extensions - field :runner, module(), default: @default_runner - field :show_timestamp?, boolean(), default: @default_show_timestamp - field :task, String.t(), default: @default_task + field :clear?, boolean, default: false + field :command, {String.t(), [String.t()]}, default: {"mix", []} + field :exclude, [Regex.t()], default: [~r/\.#/, ~r{priv/repo/migrations}] + field :extra_extensions, [String.t()], default: [] + field :runner, module(), default: MixTestInteractive.PortRunner + field :show_timestamp?, boolean(), default: false + field :task, String.t(), default: "test" end @doc """ Create a new config struct, taking values from the application environment. """ - @spec new() :: t() - def new do - %__MODULE__{ - clear?: get_clear(), - command: get_command(), - exclude: get_excluded(), - extra_extensions: get_extra_extensions(), - runner: get_runner(), - show_timestamp?: get_show_timestamp(), - task: get_task() - } - end - - defp get_clear do - AppConfig.get(:clear, @default_clear) - end - - defp get_command do - case AppConfig.get(:command, @default_command) do - {cmd, args} = command when is_binary(cmd) and is_list(args) -> command - command when is_binary(command) -> {command, []} - _invalid_command -> raise ArgumentError, "command must be a binary or a {command, [arg, ...]} tuple" + @spec load_from_environment() :: t() + def load_from_environment do + %__MODULE__{} + |> load(:clear, rename: :clear?) + |> load(:command, transform: &parse_command/1) + |> load(:exclude) + |> load(:extra_extensions) + |> load(:runner) + |> load(:timestamp, rename: :show_timestamp?) + |> load(:task) + end + + defp load(%__MODULE__{} = config, app_key, opts \\ []) do + config_key = Keyword.get(opts, :rename, app_key) + transform = Keyword.get(opts, :transform, & &1) + + case config(app_key) do + {:ok, value} -> Map.put(config, config_key, transform.(value)) + :error -> config end end - defp get_excluded do - AppConfig.get(:exclude, @default_exclude) - end - - defp get_extra_extensions do - AppConfig.get(:extra_extensions, @default_extra_extensions) - end - - defp get_runner do - AppConfig.get(:runner, @default_runner) + defp config(key) do + case ProcessTree.get(key) do + nil -> Application.fetch_env(@application, key) + value -> {:ok, value} + end end - defp get_show_timestamp do - AppConfig.get(:timestamp, @default_show_timestamp) - end + defp parse_command({cmd, args} = command) when is_binary(cmd) and is_list(args), do: command + defp parse_command(command) when is_binary(command), do: {command, []} - defp get_task do - AppConfig.get(:task, @default_task) - end + defp parse_command(_invalid_command), + do: raise(ArgumentError, "command must be a binary or a {command, [arg, ...]} tuple") end diff --git a/lib/mix_test_interactive/paths.ex b/lib/mix_test_interactive/paths.ex index af3f7b8..7795155 100644 --- a/lib/mix_test_interactive/paths.ex +++ b/lib/mix_test_interactive/paths.ex @@ -10,7 +10,7 @@ defmodule MixTestInteractive.Paths do Determines if we should respond to changes in a file. """ @spec watching?(String.t(), Config.t()) :: boolean - def watching?(path, config \\ %Config{}) do + def watching?(path, config) do watched_directory?(path) and elixir_extension?(path, config.extra_extensions) and not excluded?(config, path) end diff --git a/test/mix_test_interactive/config_test.exs b/test/mix_test_interactive/config_test.exs index beda916..58c28ef 100644 --- a/test/mix_test_interactive/config_test.exs +++ b/test/mix_test_interactive/config_test.exs @@ -3,10 +3,10 @@ defmodule MixTestInteractive.ConfigTest do alias MixTestInteractive.Config - describe "creation" do + describe "loading from the environment" do test "takes :clear? from the env" do Process.put(:clear, true) - config = Config.new() + config = Config.load_from_environment() assert config.clear? end @@ -14,7 +14,7 @@ defmodule MixTestInteractive.ConfigTest do command = "/path/to/command" Process.put(:command, command) - config = Config.new() + config = Config.load_from_environment() assert config.command == {command, []} end @@ -22,7 +22,7 @@ defmodule MixTestInteractive.ConfigTest do command = {"command", ["arg1", "arg2"]} Process.put(:command, command) - config = Config.new() + config = Config.load_from_environment() assert config.command == command end @@ -30,52 +30,52 @@ defmodule MixTestInteractive.ConfigTest do Process.put(:command, ["invalid_command", "arg1", "arg2"]) assert_raise ArgumentError, fn -> - Config.new() + Config.load_from_environment() end end test "defaults :command to `{\"mix\", []}`" do - config = Config.new() + config = Config.load_from_environment() assert config.command == {"mix", []} end test "takes :exclude from the env" do Process.put(:exclude, [~r/migration_.*/]) - config = Config.new() + config = Config.load_from_environment() assert config.exclude == [~r/migration_.*/] end test ":exclude contains common editor temp/swap files by default" do - config = Config.new() + config = Config.load_from_environment() # Emacs lock symlink assert ~r/\.#/ in config.exclude end test "excludes default Phoenix migrations directory by default" do - config = Config.new() + config = Config.load_from_environment() assert ~r{priv/repo/migrations} in config.exclude end test "takes :extra_extensions from the env" do Process.put(:extra_extensions, [".haml"]) - config = Config.new() + config = Config.load_from_environment() assert config.extra_extensions == [".haml"] end test "takes :show_timestamps? from the env" do Process.put(:timestamp, true) - config = Config.new() + config = Config.load_from_environment() assert config.show_timestamp? end test "takes :task from the env" do Process.put(:task, :env_task) - config = Config.new() + config = Config.load_from_environment() assert config.task == :env_task end test ~s(defaults :task to "test") do - config = Config.new() + config = Config.load_from_environment() assert config.task == "test" end end diff --git a/test/mix_test_interactive/paths_test.exs b/test/mix_test_interactive/paths_test.exs index c6ab7d9..ebfdb97 100644 --- a/test/mix_test_interactive/paths_test.exs +++ b/test/mix_test_interactive/paths_test.exs @@ -1,9 +1,8 @@ -defmodule MixTestInteractive.PathTest do +defmodule MixTestInteractive.PathsTest do use ExUnit.Case - import MixTestInteractive.Paths, only: [watching?: 1, watching?: 2] - alias MixTestInteractive.Config + alias MixTestInteractive.Paths test ".ex files are watched" do assert watching?("foo.ex") @@ -78,4 +77,8 @@ defmodule MixTestInteractive.PathTest do test "app.ex is not excluded by migrations_.* pattern" do assert watching?("app.ex", %Config{exclude: [~r/migrations_.*/]}) end + + defp watching?(path, config \\ %Config{}) do + Paths.watching?(path, config) + end end diff --git a/test/mix_test_interactive/port_runner_test.exs b/test/mix_test_interactive/port_runner_test.exs index bc395f9..46ce6ae 100644 --- a/test/mix_test_interactive/port_runner_test.exs +++ b/test/mix_test_interactive/port_runner_test.exs @@ -5,7 +5,7 @@ defmodule MixTestInteractive.PortRunnerTest do alias MixTestInteractive.PortRunner defp run(os_type, options) do - config = Keyword.get(options, :config, Config.new()) + config = Keyword.get(options, :config, %Config{}) args = Keyword.get(options, :args, []) runner = fn command, args, options -> From 7b5bb83ca85eba4a70572e8baf2e7de5ccf1db0b Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 22:20:09 -0700 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20Allow=20passing=20mti=20confi?= =?UTF-8?q?g=20as=20cli=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So far, only the `--clear`/`--no-clear` option is supported; the rest will come in a later commit. Config options must come first and be separated from `mix test` options by a `--` separator. The pre-existing `--watch`/`--no-watch` flag can appear in either group for backwards-compatibility, but if it is specified after the `--` separator, a deprecation notice will be printed. OptionParser has built-in support for recognizing the `--` separator, but I was unable to take full advantage of that so that I could both preserve existing behavior and allow omitting the `--` separator when all arguments are intended for `mix test`. --- .../command_line_parser.ex | 63 +++++++++++++-- .../command_line_parser_test.exs | 78 ++++++++++++++++--- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 397f294..f92e45d 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -7,6 +7,11 @@ defmodule MixTestInteractive.CommandLineParser do alias MixTestInteractive.Settings @options [ + clear: :boolean, + watch: :boolean + ] + + @deprecated_combined_options [ watch: :boolean ] @@ -44,20 +49,64 @@ defmodule MixTestInteractive.CommandLineParser do @spec parse([String.t()]) :: {Config.t(), Settings.t()} def parse(cli_args \\ []) do - {opts, patterns} = OptionParser.parse!(cli_args, switches: @options ++ @mix_test_options) + {mti_args, rest_args} = Enum.split_while(cli_args, &(&1 != "--")) + {mti_opts, _args, _invalid} = OptionParser.parse(mti_args, strict: @options) + + mix_test_args = + if rest_args == [] and mti_opts == [] do + # There was no separator, and none of the arguments were recognized by + # mix_test_interactive, so assume that they are all intended for mix + # test for convenience and backwards-compatibility. + mti_args + else + remove_leading_separator(rest_args) + end + + {mix_test_opts, patterns} = + OptionParser.parse!(mix_test_args, switches: @deprecated_combined_options ++ @mix_test_options) + + {mti_opts, mix_test_opts} = check_for_deprecated_watch_option(mti_opts, mix_test_opts) + config = build_config(mti_opts) + settings = build_settings(mti_opts, mix_test_opts, patterns) + + {config, settings} + end + + defp build_config(opts) do + Map.put(Config.load_from_environment(), :clear?, Keyword.get(opts, :clear, false)) + end + + defp build_settings(mti_opts, mix_test_opts, patterns) do no_patterns? = Enum.empty?(patterns) - {failed?, opts} = Keyword.pop(opts, :failed, false) - {stale?, opts} = Keyword.pop(opts, :stale, false) - {watching?, opts} = Keyword.pop(opts, :watch, true) + {failed?, mix_test_opts} = Keyword.pop(mix_test_opts, :failed, false) + {stale?, mix_test_opts} = Keyword.pop(mix_test_opts, :stale, false) + watching? = Keyword.get(mti_opts, :watch, true) - settings = %Settings{ + %Settings{ failed?: no_patterns? && failed?, - initial_cli_args: OptionParser.to_argv(opts), + initial_cli_args: OptionParser.to_argv(mix_test_opts), patterns: patterns, stale?: no_patterns? && !failed? && stale?, watching?: watching? } + end - {Config.load_from_environment(), settings} + defp check_for_deprecated_watch_option(mti_opts, mix_test_opts) do + case Keyword.pop(mix_test_opts, :watch, :not_found) do + {:not_found, opts} -> + {mti_opts, opts} + + {value, opts} -> + IO.puts(:stderr, """ + DEPRECATION WARNING: The `--watch` and `--no-watch` options must + now be separated from other `mix test` options using the `--` separator + e.g.: `mix test.interactive --no-watch -- --stale` + """) + + {Keyword.put_new(mti_opts, :watch, value), opts} + end end + + defp remove_leading_separator([]), do: [] + defp remove_leading_separator(["--" | args]), do: args end diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 9e6619f..b597c74 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -1,26 +1,33 @@ defmodule MixTestInteractive.CommandLineParserTest do use ExUnit.Case, async: true + import ExUnit.CaptureIO + alias MixTestInteractive.CommandLineParser - describe "watch mode" do - test "enables with --watch flag" do - {_config, settings} = CommandLineParser.parse(["--watch"]) - assert settings.watching? + describe "mix test.interactive options" do + test "sets clear? flag with --clear" do + {config, _settings} = CommandLineParser.parse(["--clear"]) + assert config.clear? end - test "disables with --no-watch flag" do - {_config, settings} = CommandLineParser.parse(["--no-watch"]) - refute settings.watching? + test "clears clear? flag with --no-clear" do + {config, _settings} = CommandLineParser.parse(["--no-clear"]) + refute config.clear? end - test "consumes --watch flag" do + test "initially enables watch mode with --watch flag" do {_config, settings} = CommandLineParser.parse(["--watch"]) - assert settings.initial_cli_args == [] + assert settings.watching? end - test "consumes --no-watch flag" do + test "initially disables watch mode with --no-watch flag" do {_config, settings} = CommandLineParser.parse(["--no-watch"]) + refute settings.watching? + end + + test "does not pass mti options to mix test" do + {_config, settings} = CommandLineParser.parse(["--clear", "--no-clear", "--watch", "--no-watch"]) assert settings.initial_cli_args == [] end end @@ -74,4 +81,55 @@ defmodule MixTestInteractive.CommandLineParserTest do assert settings.initial_cli_args == [] end end + + describe "passing both mix test.interactive (mti) and mix test arguments" do + test "process arguments for mti and mix test separately" do + {config, settings} = CommandLineParser.parse(["--clear", "--", "--stale"]) + assert config.clear? + assert settings.stale? + end + + test "requires -- separator to distinguish the sets of arguments" do + {config, settings} = CommandLineParser.parse(["--clear", "--stale"]) + assert config.clear? + refute settings.stale? + end + + test "handles mix test options with leading `--` separator" do + {_config, settings} = CommandLineParser.parse(["--", "--stale"]) + assert settings.stale? + end + + test "displays deprecation warning if --{no-}watch specified in mix test options" do + {{_config, settings}, output} = + with_io(:stderr, fn -> + CommandLineParser.parse(["--", "--no-watch"]) + end) + + assert output =~ "DEPRECATION WARNING" + refute settings.watching? + end + + test "watch flag from mti options takes precedence over the flag from mix test options, but still displays deprecation warning" do + {{_config, settings}, output} = + with_io(:stderr, fn -> + CommandLineParser.parse(["--no-watch", "--", "--watch"]) + end) + + assert output =~ "DEPRECATION WARNING" + refute settings.watching? + end + + test "omits unknown options before --" do + {_config, settings} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) + + assert settings.stale? + end + + test "omits unknown options after --" do + {config, _settings} = CommandLineParser.parse(["--clear", "--", "--unknown-arg"]) + + assert config.clear? + end + end end From e0de4b0dd0f0e3ef7af0aeb7f3753fad880abe22 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 23:44:15 -0700 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=92=A5=20Add=20remaining=20config?= =?UTF-8?q?=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This includes adding an `--exclude` option that now conflicts with mix test's option of the same name. I also realized that I've broken backwards-compatibility with the `watch`/`no-watch` option. I'll decide whether this feature warrants a major version bump and then either fix backwards-compatibility or remove the (not quite correct) handling of the legacy watch/no-watch option. --- .../command_line_parser.ex | 52 +++++++- .../command_line_parser_test.exs | 122 +++++++++++++++++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index f92e45d..44a5cd1 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -7,7 +7,14 @@ defmodule MixTestInteractive.CommandLineParser do alias MixTestInteractive.Settings @options [ + arg: :keep, clear: :boolean, + command: :string, + exclude: :keep, + extra_extensions: :keep, + runner: :string, + task: :string, + timestamp: :boolean, watch: :boolean ] @@ -72,8 +79,39 @@ defmodule MixTestInteractive.CommandLineParser do {config, settings} end - defp build_config(opts) do - Map.put(Config.load_from_environment(), :clear?, Keyword.get(opts, :clear, false)) + defp build_config(mti_opts) do + mti_opts + |> Enum.reduce(Config.load_from_environment(), fn + {:clear, clear?}, config -> %{config | clear?: clear?} + {:runner, runner}, config -> %{config | runner: ensure_valid_runner(runner)} + {:timestamp, show_timestamp?}, config -> %{config | show_timestamp?: show_timestamp?} + {:task, task}, config -> %{config | task: task} + _pair, config -> config + end) + |> add_custom_command(mti_opts) + |> add_excludes(mti_opts) + |> add_extra_extensions(mti_opts) + end + + defp add_custom_command(%Config{} = config, mti_opts) do + case Keyword.fetch(mti_opts, :command) do + {:ok, command} -> %{config | command: {command, Keyword.get_values(mti_opts, :arg)}} + :error -> config + end + end + + defp add_excludes(%Config{} = config, mti_opts) do + case Keyword.get_values(mti_opts, :exclude) do + [] -> config + excludes -> %{config | exclude: Enum.map(excludes, &Regex.compile!/1)} + end + end + + defp add_extra_extensions(%Config{} = config, mti_opts) do + case Keyword.get_values(mti_opts, :extra_extensions) do + [] -> config + extensions -> %{config | extra_extensions: extensions} + end end defp build_settings(mti_opts, mix_test_opts, patterns) do @@ -107,6 +145,16 @@ defmodule MixTestInteractive.CommandLineParser do end end + defp ensure_valid_runner(runner) do + module = runner |> String.split(".") |> Module.concat() + + if function_exported?(module, :run, 2) do + module + else + raise ArgumentError, message: "--runner must name a module that implements a `run/2` function" + end + end + defp remove_leading_separator([]), do: [] defp remove_leading_separator(["--" | args]), do: args end diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index b597c74..e08b742 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -4,8 +4,23 @@ defmodule MixTestInteractive.CommandLineParserTest do import ExUnit.CaptureIO alias MixTestInteractive.CommandLineParser + alias MixTestInteractive.Config + + defmodule CustomRunner do + @moduledoc false + def run(_config, _args), do: :noop + end + + defmodule NotARunner do + @moduledoc false + end describe "mix test.interactive options" do + test "retains original defaults when no options" do + {config, _settings} = CommandLineParser.parse([]) + assert config == %Config{} + end + test "sets clear? flag with --clear" do {config, _settings} = CommandLineParser.parse(["--clear"]) assert config.clear? @@ -16,6 +31,86 @@ defmodule MixTestInteractive.CommandLineParserTest do refute config.clear? end + test "configures custom command with --command" do + {config, _settings} = CommandLineParser.parse(["--command", "custom_command"]) + assert config.command == {"custom_command", []} + end + + test "configures custom command with single argument with --command and --arg" do + {config, _settings} = CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg"]) + assert config.command == {"custom_command", ["custom_arg"]} + end + + test "configures custom command with multiple arguments with --command and repeated --arg options" do + {config, _settings} = + CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg1", "--arg", "custom_arg2"]) + + assert config.command == {"custom_command", ["custom_arg1", "custom_arg2"]} + end + + test "ignores custom command arguments if command is not specified" do + {config, _settings} = CommandLineParser.parse(["--arg", "arg_with_missing_command"]) + assert config.command == %Config{}.command + end + + test "configures watch exclusions with --exclude" do + {config, _settings} = CommandLineParser.parse(["--exclude", "~$"]) + assert config.exclude == [~r/~$/] + end + + test "configures multiple watch exclusions with repeated --exclude options" do + {config, _settings} = CommandLineParser.parse(["--exclude", "~$", "--exclude", "\.secret\.exs"]) + assert config.exclude == [~r/~$/, ~r/.secret.exs/] + end + + test "fails if watch exclusion is an invalid Regex" do + assert_raise Regex.CompileError, fn -> + CommandLineParser.parse(["--exclude", "[A-Za-z"]) + end + end + + test "configures additional extensions to watch with --extra-extensions" do + {config, _settings} = CommandLineParser.parse(["--extra-extensions", "md"]) + assert config.extra_extensions == ["md"] + end + + test "configures multiple additional extensions to watch with repeated --extra-extensions options" do + {config, _settings} = CommandLineParser.parse(["--extra-extensions", "md", "--extra-extensions", "json"]) + assert config.extra_extensions == ["md", "json"] + end + + test "configures custom runner module with --runner" do + {config, _setting} = CommandLineParser.parse(["--runner", inspect(CustomRunner)]) + assert config.runner == CustomRunner + end + + test "fails if custom runner doesn't have a run function" do + assert_raise ArgumentError, fn -> + CommandLineParser.parse(["--runner", inspect(NotARunner)]) + end + end + + test "fails if custom runner module doesn't exist" do + assert_raise ArgumentError, fn -> + CommandLineParser.parse(["--runner", "NotAModule"]) + end + end + + test "sets show_timestamp? flag with --timestamp" do + {config, _settings} = CommandLineParser.parse(["--timestamp"]) + assert config.show_timestamp? + end + + test "clears show_timestamp? flag with --no-timestamp" do + {config, _settings} = CommandLineParser.parse(["--no-timestamp"]) + refute config.show_timestamp? + end + + test "configures custom mix task with --task" do + {config, _settings} = CommandLineParser.parse(["--task", "custom_task"]) + assert config.task == "custom_task" + end + test "initially enables watch mode with --watch flag" do {_config, settings} = CommandLineParser.parse(["--watch"]) assert settings.watching? @@ -27,7 +122,26 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "does not pass mti options to mix test" do - {_config, settings} = CommandLineParser.parse(["--clear", "--no-clear", "--watch", "--no-watch"]) + {_config, settings} = + CommandLineParser.parse([ + "--clear", + "--no-clear", + "--command", + "custom_command", + "--arg", + "--custom_arg", + "--exclude", + "~$", + "--extra-extensions", + "md", + "--runner", + inspect(CustomRunner), + "--timestamp", + "--no-timestamp", + "--watch", + "--no-watch" + ]) + assert settings.initial_cli_args == [] end end @@ -89,6 +203,12 @@ defmodule MixTestInteractive.CommandLineParserTest do assert settings.stale? end + test "handles mti and mix test options with the same name" do + {config, settings} = CommandLineParser.parse(["--exclude", "~$", "--", "--exclude", "integration"]) + assert config.exclude == [~r/~$/] + assert settings.initial_cli_args == ["--exclude", "integration"] + end + test "requires -- separator to distinguish the sets of arguments" do {config, settings} = CommandLineParser.parse(["--clear", "--stale"]) assert config.clear? From c482c720b0be5c8c790e5563bb59fd7ad79ee4f1 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Sat, 7 Sep 2024 23:52:05 -0700 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=91=BD=20Add=20new=20mix=20test=20o?= =?UTF-8?q?ptions=20from=20latest=20Elixir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mix_test_interactive/command_line_parser.ex | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 44a5cd1..3b8d194 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -25,6 +25,7 @@ defmodule MixTestInteractive.CommandLineParser do @mix_test_options [ all_warnings: :boolean, archives_check: :boolean, + breakpoints: :boolean, color: :boolean, compile: :boolean, cover: :boolean, @@ -45,8 +46,10 @@ defmodule MixTestInteractive.CommandLineParser do preload_modules: :boolean, profile_require: :string, raise: :boolean, + repeat_until_failure: :integer, seed: :integer, slowest: :integer, + slowest_modules: :integer, stale: :boolean, start: :boolean, timeout: :integer, @@ -54,6 +57,10 @@ defmodule MixTestInteractive.CommandLineParser do warnings_as_errors: :boolean ] + @mix_test_aliases [ + b: :breakpoints + ] + @spec parse([String.t()]) :: {Config.t(), Settings.t()} def parse(cli_args \\ []) do {mti_args, rest_args} = Enum.split_while(cli_args, &(&1 != "--")) @@ -70,7 +77,10 @@ defmodule MixTestInteractive.CommandLineParser do end {mix_test_opts, patterns} = - OptionParser.parse!(mix_test_args, switches: @deprecated_combined_options ++ @mix_test_options) + OptionParser.parse!(mix_test_args, + aliases: @mix_test_aliases, + switches: @deprecated_combined_options ++ @mix_test_options + ) {mti_opts, mix_test_opts} = check_for_deprecated_watch_option(mti_opts, mix_test_opts) config = build_config(mti_opts) From fb4827650f58c7bf027ac5200efdce0dd3ab7a4f Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Mon, 9 Sep 2024 17:43:46 -0700 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=92=A5=20Remove=20backwards-compati?= =?UTF-8?q?bility=20for=20`--watch`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We've decided to make config-less operation a breaking change. Adjusting to it isn't terribly difficult and simplifies the code and maintenance burden. --- .../command_line_parser.ex | 26 +------------------ .../command_line_parser_test.exs | 22 +++------------- 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 3b8d194..56c552f 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -18,10 +18,6 @@ defmodule MixTestInteractive.CommandLineParser do watch: :boolean ] - @deprecated_combined_options [ - watch: :boolean - ] - @mix_test_options [ all_warnings: :boolean, archives_check: :boolean, @@ -77,12 +73,8 @@ defmodule MixTestInteractive.CommandLineParser do end {mix_test_opts, patterns} = - OptionParser.parse!(mix_test_args, - aliases: @mix_test_aliases, - switches: @deprecated_combined_options ++ @mix_test_options - ) + OptionParser.parse!(mix_test_args, aliases: @mix_test_aliases, switches: @mix_test_options) - {mti_opts, mix_test_opts} = check_for_deprecated_watch_option(mti_opts, mix_test_opts) config = build_config(mti_opts) settings = build_settings(mti_opts, mix_test_opts, patterns) @@ -139,22 +131,6 @@ defmodule MixTestInteractive.CommandLineParser do } end - defp check_for_deprecated_watch_option(mti_opts, mix_test_opts) do - case Keyword.pop(mix_test_opts, :watch, :not_found) do - {:not_found, opts} -> - {mti_opts, opts} - - {value, opts} -> - IO.puts(:stderr, """ - DEPRECATION WARNING: The `--watch` and `--no-watch` options must - now be separated from other `mix test` options using the `--` separator - e.g.: `mix test.interactive --no-watch -- --stale` - """) - - {Keyword.put_new(mti_opts, :watch, value), opts} - end - end - defp ensure_valid_runner(runner) do module = runner |> String.split(".") |> Module.concat() diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index e08b742..370ebca 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -1,8 +1,6 @@ defmodule MixTestInteractive.CommandLineParserTest do use ExUnit.Case, async: true - import ExUnit.CaptureIO - alias MixTestInteractive.CommandLineParser alias MixTestInteractive.Config @@ -220,24 +218,10 @@ defmodule MixTestInteractive.CommandLineParserTest do assert settings.stale? end - test "displays deprecation warning if --{no-}watch specified in mix test options" do - {{_config, settings}, output} = - with_io(:stderr, fn -> - CommandLineParser.parse(["--", "--no-watch"]) - end) - - assert output =~ "DEPRECATION WARNING" - refute settings.watching? - end + test "ignores --{no-}watch if specified in mix test options" do + {_config, settings} = CommandLineParser.parse(["--", "--no-watch"]) - test "watch flag from mti options takes precedence over the flag from mix test options, but still displays deprecation warning" do - {{_config, settings}, output} = - with_io(:stderr, fn -> - CommandLineParser.parse(["--no-watch", "--", "--watch"]) - end) - - assert output =~ "DEPRECATION WARNING" - refute settings.watching? + assert settings.watching? end test "omits unknown options before --" do From b074314c7a605559e69f4a1d2f5cb5efef8abb50 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Mon, 9 Sep 2024 18:51:25 -0700 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A5=85=20Return=20errors=20on=20inv?= =?UTF-8?q?alid=20mti=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors CommandLineParser to return `:ok`/`:error` tuples. --- lib/mix_test_interactive.ex | 16 ++- .../command_line_parser.ex | 101 ++++++++++++++---- .../command_line_parser_test.exs | 77 +++++++------ 3 files changed, 128 insertions(+), 66 deletions(-) diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index 22ebd6c..e6f236a 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -13,14 +13,20 @@ defmodule MixTestInteractive do Start the interactive test runner. """ def run(args \\ []) when is_list(args) do - {config, settings} = CommandLineParser.parse(args) + case CommandLineParser.parse(args) do + {:ok, %{config: config, settings: settings}} -> + {:ok, _} = Application.ensure_all_started(@application) - {:ok, _} = Application.ensure_all_started(@application) + {:ok, _supervisor} = + DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, config: config, settings: settings}) - {:ok, _supervisor} = - DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, config: config, settings: settings}) + loop() - loop() + {:error, error} -> + IO.puts(:standard_error, Exception.message(error)) + IO.puts("") + IO.puts(CommandLineParser.usage_message()) + end end defp loop do diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 56c552f..f6b4fb8 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -5,6 +5,7 @@ defmodule MixTestInteractive.CommandLineParser do alias MixTestInteractive.Config alias MixTestInteractive.Settings + alias OptionParser.ParseError @options [ arg: :keep, @@ -18,6 +19,29 @@ defmodule MixTestInteractive.CommandLineParser do watch: :boolean ] + @usage """ + Usage: mix_test_interactive [-- ] + or: mix_test_interactive + + where: + : + --(no-)clear: Clear the console before each run (default `false`) + --command /--arg : Custom command and arguments for running tests + (default: `"mix"` with no args) + NOTE: Use `--arg` multiple times to specify more than one argument + --exclude : Exclude files/directories from triggering test runs + (default: `--exclude "~r/\.#/" --exclude "~r{priv/repo/migrations}"`) + NOTE: Use `--exclude` multiple times to specify more than one regex + --extra-extensions : Watch files with additional extensions (default: []) + NOTE: Use `--extra-extensions` multiple times to specify more than one extension. + --runner : Use a custom runner module (default: `MixTestInteractive.PortRunner`) + --task : Run a different mix task (default: `"test"`) + --(no-)timestamp: Display the current time before running the tests (default: `false`) + --(no-)watch: Run tests when a watched file changes (default: `true`) + + : any arguments accepted by `mix test` + """ + @mix_test_options [ all_warnings: :boolean, archives_check: :boolean, @@ -57,30 +81,20 @@ defmodule MixTestInteractive.CommandLineParser do b: :breakpoints ] - @spec parse([String.t()]) :: {Config.t(), Settings.t()} + @spec parse([String.t()]) :: {:ok, Config.t(), Settings.t()} | {:error, Exception.t()} def parse(cli_args \\ []) do - {mti_args, rest_args} = Enum.split_while(cli_args, &(&1 != "--")) - {mti_opts, _args, _invalid} = OptionParser.parse(mti_args, strict: @options) - - mix_test_args = - if rest_args == [] and mti_opts == [] do - # There was no separator, and none of the arguments were recognized by - # mix_test_interactive, so assume that they are all intended for mix - # test for convenience and backwards-compatibility. - mti_args - else - remove_leading_separator(rest_args) - end - - {mix_test_opts, patterns} = - OptionParser.parse!(mix_test_args, aliases: @mix_test_aliases, switches: @mix_test_options) - - config = build_config(mti_opts) - settings = build_settings(mti_opts, mix_test_opts, patterns) + with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), + {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args) do + config = build_config(mti_opts) + settings = build_settings(mti_opts, mix_test_opts, patterns) - {config, settings} + {:ok, %{config: config, settings: settings}} + end end + @spec usage_message :: String.t() + def usage_message, do: @usage + defp build_config(mti_opts) do mti_opts |> Enum.reduce(Config.load_from_environment(), fn @@ -141,6 +155,49 @@ defmodule MixTestInteractive.CommandLineParser do end end - defp remove_leading_separator([]), do: [] - defp remove_leading_separator(["--" | args]), do: args + defp parse_mix_test_args(mix_test_args) do + {mix_test_opts, patterns} = + OptionParser.parse!(mix_test_args, aliases: @mix_test_aliases, switches: @mix_test_options) + + {:ok, mix_test_opts, patterns} + rescue + error in ParseError -> + {:error, error} + end + + defp parse_mti_args(cli_args) do + case Enum.find_index(cli_args, &(&1 == "--")) do + nil -> + case try_parse_as_mti_args(cli_args) do + {:ok, mti_opts} -> {:ok, mti_opts, []} + {:error, :try_as_mix_test_args} -> {:ok, [], cli_args} + {:error, error} -> {:error, error} + end + + index -> + mti_args = Enum.take(cli_args, index) + + with {:ok, mti_opts} <- parse_as_mti_args(mti_args) do + mix_test_args = Enum.drop(cli_args, index + 1) + {:ok, mti_opts, mix_test_args} + end + end + end + + defp parse_as_mti_args(args) do + {mti_opts, _args} = OptionParser.parse!(args, strict: @options) + {:ok, mti_opts} + rescue + error in ParseError -> {:error, error} + end + + defp try_parse_as_mti_args(args) do + {mti_opts, _args, invalid} = OptionParser.parse(args, strict: @options) + + cond do + invalid == [] -> {:ok, mti_opts} + mti_opts == [] -> {:error, :try_as_mix_test_args} + true -> parse_as_mti_args(args) + end + end end diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 370ebca..7825372 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -3,6 +3,7 @@ defmodule MixTestInteractive.CommandLineParserTest do alias MixTestInteractive.CommandLineParser alias MixTestInteractive.Config + alias OptionParser.ParseError defmodule CustomRunner do @moduledoc false @@ -15,49 +16,49 @@ defmodule MixTestInteractive.CommandLineParserTest do describe "mix test.interactive options" do test "retains original defaults when no options" do - {config, _settings} = CommandLineParser.parse([]) + {:ok, %{config: config}} = CommandLineParser.parse([]) assert config == %Config{} end test "sets clear? flag with --clear" do - {config, _settings} = CommandLineParser.parse(["--clear"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--clear"]) assert config.clear? end test "clears clear? flag with --no-clear" do - {config, _settings} = CommandLineParser.parse(["--no-clear"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--no-clear"]) refute config.clear? end test "configures custom command with --command" do - {config, _settings} = CommandLineParser.parse(["--command", "custom_command"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--command", "custom_command"]) assert config.command == {"custom_command", []} end test "configures custom command with single argument with --command and --arg" do - {config, _settings} = CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg"]) assert config.command == {"custom_command", ["custom_arg"]} end test "configures custom command with multiple arguments with --command and repeated --arg options" do - {config, _settings} = + {:ok, %{config: config}} = CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg1", "--arg", "custom_arg2"]) assert config.command == {"custom_command", ["custom_arg1", "custom_arg2"]} end test "ignores custom command arguments if command is not specified" do - {config, _settings} = CommandLineParser.parse(["--arg", "arg_with_missing_command"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--arg", "arg_with_missing_command"]) assert config.command == %Config{}.command end test "configures watch exclusions with --exclude" do - {config, _settings} = CommandLineParser.parse(["--exclude", "~$"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--exclude", "~$"]) assert config.exclude == [~r/~$/] end test "configures multiple watch exclusions with repeated --exclude options" do - {config, _settings} = CommandLineParser.parse(["--exclude", "~$", "--exclude", "\.secret\.exs"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--exclude", "~$", "--exclude", "\.secret\.exs"]) assert config.exclude == [~r/~$/, ~r/.secret.exs/] end @@ -68,17 +69,17 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "configures additional extensions to watch with --extra-extensions" do - {config, _settings} = CommandLineParser.parse(["--extra-extensions", "md"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--extra-extensions", "md"]) assert config.extra_extensions == ["md"] end test "configures multiple additional extensions to watch with repeated --extra-extensions options" do - {config, _settings} = CommandLineParser.parse(["--extra-extensions", "md", "--extra-extensions", "json"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--extra-extensions", "md", "--extra-extensions", "json"]) assert config.extra_extensions == ["md", "json"] end test "configures custom runner module with --runner" do - {config, _setting} = CommandLineParser.parse(["--runner", inspect(CustomRunner)]) + {:ok, %{config: config}} = CommandLineParser.parse(["--runner", inspect(CustomRunner)]) assert config.runner == CustomRunner end @@ -95,39 +96,39 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "sets show_timestamp? flag with --timestamp" do - {config, _settings} = CommandLineParser.parse(["--timestamp"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--timestamp"]) assert config.show_timestamp? end test "clears show_timestamp? flag with --no-timestamp" do - {config, _settings} = CommandLineParser.parse(["--no-timestamp"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--no-timestamp"]) refute config.show_timestamp? end test "configures custom mix task with --task" do - {config, _settings} = CommandLineParser.parse(["--task", "custom_task"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--task", "custom_task"]) assert config.task == "custom_task" end test "initially enables watch mode with --watch flag" do - {_config, settings} = CommandLineParser.parse(["--watch"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--watch"]) assert settings.watching? end test "initially disables watch mode with --no-watch flag" do - {_config, settings} = CommandLineParser.parse(["--no-watch"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--no-watch"]) refute settings.watching? end test "does not pass mti options to mix test" do - {_config, settings} = + {:ok, %{settings: settings}} = CommandLineParser.parse([ "--clear", "--no-clear", "--command", "custom_command", "--arg", - "--custom_arg", + "custom_arg", "--exclude", "~$", "--extra-extensions", @@ -146,47 +147,47 @@ defmodule MixTestInteractive.CommandLineParserTest do describe "mix test arguments" do test "records initial `mix test` arguments" do - {_config, settings} = CommandLineParser.parse(["--trace", "--raise"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--trace", "--raise"]) assert settings.initial_cli_args == ["--trace", "--raise"] end test "records no `mix test` arguments by default" do - {_config, settings} = CommandLineParser.parse() + {:ok, %{settings: settings}} = CommandLineParser.parse() assert settings.initial_cli_args == [] end test "omits unknown arguments" do - {_config, settings} = CommandLineParser.parse(["--unknown-arg"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--unknown-arg"]) assert settings.initial_cli_args == [] end test "extracts stale setting from arguments" do - {_config, settings} = CommandLineParser.parse(["--trace", "--stale", "--raise"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--trace", "--stale", "--raise"]) assert settings.stale? assert settings.initial_cli_args == ["--trace", "--raise"] end test "extracts failed flag from arguments" do - {_config, settings} = CommandLineParser.parse(["--trace", "--failed", "--raise"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--trace", "--failed", "--raise"]) assert settings.failed? assert settings.initial_cli_args == ["--trace", "--raise"] end test "extracts patterns from arguments" do - {_config, settings} = CommandLineParser.parse(["pattern1", "--trace", "pattern2"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["pattern1", "--trace", "pattern2"]) assert settings.patterns == ["pattern1", "pattern2"] assert settings.initial_cli_args == ["--trace"] end test "failed takes precedence over stale" do - {_config, settings} = CommandLineParser.parse(["--failed", "--stale"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--failed", "--stale"]) refute settings.stale? assert settings.failed? end test "patterns take precedence over stale/failed flags" do - {_config, settings} = CommandLineParser.parse(["--failed", "--stale", "pattern"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--failed", "--stale", "pattern"]) assert settings.patterns == ["pattern"] refute settings.failed? refute settings.stale? @@ -196,42 +197,40 @@ defmodule MixTestInteractive.CommandLineParserTest do describe "passing both mix test.interactive (mti) and mix test arguments" do test "process arguments for mti and mix test separately" do - {config, settings} = CommandLineParser.parse(["--clear", "--", "--stale"]) + {:ok, %{config: config, settings: settings}} = CommandLineParser.parse(["--clear", "--", "--stale"]) assert config.clear? assert settings.stale? end test "handles mti and mix test options with the same name" do - {config, settings} = CommandLineParser.parse(["--exclude", "~$", "--", "--exclude", "integration"]) + {:ok, %{config: config, settings: settings}} = + CommandLineParser.parse(["--exclude", "~$", "--", "--exclude", "integration"]) + assert config.exclude == [~r/~$/] assert settings.initial_cli_args == ["--exclude", "integration"] end test "requires -- separator to distinguish the sets of arguments" do - {config, settings} = CommandLineParser.parse(["--clear", "--stale"]) - assert config.clear? - refute settings.stale? + assert {:error, %ParseError{}} = CommandLineParser.parse(["--clear", "--stale"]) end test "handles mix test options with leading `--` separator" do - {_config, settings} = CommandLineParser.parse(["--", "--stale"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--", "--stale"]) assert settings.stale? end test "ignores --{no-}watch if specified in mix test options" do - {_config, settings} = CommandLineParser.parse(["--", "--no-watch"]) + {:ok, %{settings: settings}} = CommandLineParser.parse(["--", "--no-watch"]) assert settings.watching? end - test "omits unknown options before --" do - {_config, settings} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) - - assert settings.stale? + test "fails with unknown options before --" do + assert {:error, %ParseError{}} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) end test "omits unknown options after --" do - {config, _settings} = CommandLineParser.parse(["--clear", "--", "--unknown-arg"]) + {:ok, %{config: config}} = CommandLineParser.parse(["--clear", "--", "--unknown-arg"]) assert config.clear? end From ed2c013b30b5de7b6a498fd7ef1acc15ee0777e5 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Mon, 9 Sep 2024 18:56:16 -0700 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=A5=85=20Convert=20raised=20excepti?= =?UTF-8?q?ons=20into=20error=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command_line_parser.ex | 32 +++++++++++-------- .../command_line_parser_test.exs | 12 ++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index f6b4fb8..f7f21c9 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -84,8 +84,8 @@ defmodule MixTestInteractive.CommandLineParser do @spec parse([String.t()]) :: {:ok, Config.t(), Settings.t()} | {:error, Exception.t()} def parse(cli_args \\ []) do with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), - {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args) do - config = build_config(mti_opts) + {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args), + {:ok, config} <- build_config(mti_opts) do settings = build_settings(mti_opts, mix_test_opts, patterns) {:ok, %{config: config, settings: settings}} @@ -96,17 +96,23 @@ defmodule MixTestInteractive.CommandLineParser do def usage_message, do: @usage defp build_config(mti_opts) do - mti_opts - |> Enum.reduce(Config.load_from_environment(), fn - {:clear, clear?}, config -> %{config | clear?: clear?} - {:runner, runner}, config -> %{config | runner: ensure_valid_runner(runner)} - {:timestamp, show_timestamp?}, config -> %{config | show_timestamp?: show_timestamp?} - {:task, task}, config -> %{config | task: task} - _pair, config -> config - end) - |> add_custom_command(mti_opts) - |> add_excludes(mti_opts) - |> add_extra_extensions(mti_opts) + config = + mti_opts + |> Enum.reduce(Config.load_from_environment(), fn + {:clear, clear?}, config -> %{config | clear?: clear?} + {:runner, runner}, config -> %{config | runner: ensure_valid_runner(runner)} + {:timestamp, show_timestamp?}, config -> %{config | show_timestamp?: show_timestamp?} + {:task, task}, config -> %{config | task: task} + _pair, config -> config + end) + |> add_custom_command(mti_opts) + |> add_excludes(mti_opts) + |> add_extra_extensions(mti_opts) + + {:ok, config} + rescue + error -> + {:error, error} end defp add_custom_command(%Config{} = config, mti_opts) do diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 7825372..9f7affb 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -63,9 +63,7 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "fails if watch exclusion is an invalid Regex" do - assert_raise Regex.CompileError, fn -> - CommandLineParser.parse(["--exclude", "[A-Za-z"]) - end + assert {:error, %Regex.CompileError{}} = CommandLineParser.parse(["--exclude", "[A-Za-z"]) end test "configures additional extensions to watch with --extra-extensions" do @@ -84,15 +82,11 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "fails if custom runner doesn't have a run function" do - assert_raise ArgumentError, fn -> - CommandLineParser.parse(["--runner", inspect(NotARunner)]) - end + assert {:error, %ArgumentError{}} = CommandLineParser.parse(["--runner", inspect(NotARunner)]) end test "fails if custom runner module doesn't exist" do - assert_raise ArgumentError, fn -> - CommandLineParser.parse(["--runner", "NotAModule"]) - end + assert {:error, %ArgumentError{}} = CommandLineParser.parse(["--runner", "NotAModule"]) end test "sets show_timestamp? flag with --timestamp" do From dfbe104ec77953437ff7d5bcfbe410f9cbe0b2bd Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 20:31:23 -0700 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Fix=20typespec=20?= =?UTF-8?q?on=20parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mix_test_interactive.ex | 2 +- lib/mix_test_interactive/command_line_parser.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index e6f236a..34eeba3 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -15,7 +15,7 @@ defmodule MixTestInteractive do def run(args \\ []) when is_list(args) do case CommandLineParser.parse(args) do {:ok, %{config: config, settings: settings}} -> - {:ok, _} = Application.ensure_all_started(@application) + {:ok, _apps} = Application.ensure_all_started(@application) {:ok, _supervisor} = DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, config: config, settings: settings}) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index f7f21c9..41c7a85 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -81,7 +81,7 @@ defmodule MixTestInteractive.CommandLineParser do b: :breakpoints ] - @spec parse([String.t()]) :: {:ok, Config.t(), Settings.t()} | {:error, Exception.t()} + @spec parse([String.t()]) :: {:ok, %{config: Config.t(), settings: Settings.t()}} | {:error, Exception.t()} def parse(cli_args \\ []) do with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args), From 2808ed03c37cb220d293cb4bcad2bd74b8378206 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 20:46:22 -0700 Subject: [PATCH 12/19] =?UTF-8?q?=E2=9E=96=20Remove=20locked=20temporary?= =?UTF-8?q?=5Fenv=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mix.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/mix.lock b/mix.lock index 59d9efd..b160bb3 100644 --- a/mix.lock +++ b/mix.lock @@ -8,6 +8,5 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "process_tree": {:hex, :process_tree, "0.1.3", "12ca2724d74b211bcb106659cb64d11a88fe6e8e3256071c601233d469235665", [:mix], [], "hexpm", "156e8b4f8ed51f80dd134fc0de89af1ad3ec84a255a9c036c92a2e02e3b24302"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, - "temporary_env": {:hex, :temporary_env, "2.0.1", "d4b5e031837e5619485e1f23af7cba7e897b8fd546eaaa8b10c812d642ec4546", [:mix], [], "hexpm", "f9420044742b5f0479a7f8243e86b048b6a2d4878bce026a3615065b11199c27"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, } From 06ebca424b1d84f5902a7880698a26ce5dcf89e1 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 20:53:34 -0700 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=9A=B8=20Improve=20formatting=20of?= =?UTF-8?q?=20usage=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command_line_parser.ex | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 41c7a85..3bae993 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -20,26 +20,38 @@ defmodule MixTestInteractive.CommandLineParser do ] @usage """ - Usage: mix_test_interactive [-- ] - or: mix_test_interactive + Usage: + mix_test_interactive [-- ] + mix_test_interactive where: : - --(no-)clear: Clear the console before each run (default `false`) - --command /--arg : Custom command and arguments for running tests - (default: `"mix"` with no args) - NOTE: Use `--arg` multiple times to specify more than one argument - --exclude : Exclude files/directories from triggering test runs - (default: `--exclude "~r/\.#/" --exclude "~r{priv/repo/migrations}"`) - NOTE: Use `--exclude` multiple times to specify more than one regex - --extra-extensions : Watch files with additional extensions (default: []) - NOTE: Use `--extra-extensions` multiple times to specify more than one extension. - --runner : Use a custom runner module (default: `MixTestInteractive.PortRunner`) - --task : Run a different mix task (default: `"test"`) - --(no-)timestamp: Display the current time before running the tests (default: `false`) - --(no-)watch: Run tests when a watched file changes (default: `true`) - - : any arguments accepted by `mix test` + --(no-)clear Clear the console before each run + (default `false`) + --command / + --arg Custom command and arguments for running + tests (default: `"mix"` with no args) + NOTE: Use `--arg` multiple times to specify + more than one argument + --exclude Exclude files/directories from triggering + test runs + (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) + NOTE: Use `--exclude` multiple times to + specify more than one regex + --extra-extensions Watch files with additional extensions + (default: []) + NOTE: Use `--extra-extensions` multiple times + to specify more than one extension. + --runner Use a custom runner module + (default: `MixTestInteractive.PortRunner`) + --task Run a different mix task (default: `"test"`) + --(no-)timestamp Display the current time before running the + tests (default: `false`) + --(no-)watch Run tests when a watched file changes + (default: `true`) + + : + any arguments accepted by `mix test` """ @mix_test_options [ From 0787b2377449abd1c632770b8c058a3e61c53395 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 21:14:48 -0700 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=A5=85=20Wrap=20all=20errors=20in?= =?UTF-8?q?=20a=20UsageError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command_line_parser.ex | 23 +++++++++++++++---- .../command_line_parser_test.exs | 12 +++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 3bae993..ce2a23a 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -7,6 +7,21 @@ defmodule MixTestInteractive.CommandLineParser do alias MixTestInteractive.Settings alias OptionParser.ParseError + defmodule UsageError do + @moduledoc false + defexception [:message] + + @type t :: %__MODULE__{ + message: String.t() + } + + def exception(other) when is_exception(other) do + exception(Exception.message(other)) + end + + def exception(opts), do: super(opts) + end + @options [ arg: :keep, clear: :boolean, @@ -93,7 +108,7 @@ defmodule MixTestInteractive.CommandLineParser do b: :breakpoints ] - @spec parse([String.t()]) :: {:ok, %{config: Config.t(), settings: Settings.t()}} | {:error, Exception.t()} + @spec parse([String.t()]) :: {:ok, %{config: Config.t(), settings: Settings.t()}} | {:error, UsageError.t()} def parse(cli_args \\ []) do with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args), @@ -124,7 +139,7 @@ defmodule MixTestInteractive.CommandLineParser do {:ok, config} rescue error -> - {:error, error} + {:error, UsageError.exception(error)} end defp add_custom_command(%Config{} = config, mti_opts) do @@ -180,7 +195,7 @@ defmodule MixTestInteractive.CommandLineParser do {:ok, mix_test_opts, patterns} rescue error in ParseError -> - {:error, error} + {:error, UsageError.exception(error)} end defp parse_mti_args(cli_args) do @@ -206,7 +221,7 @@ defmodule MixTestInteractive.CommandLineParser do {mti_opts, _args} = OptionParser.parse!(args, strict: @options) {:ok, mti_opts} rescue - error in ParseError -> {:error, error} + error in ParseError -> {:error, UsageError.exception(error)} end defp try_parse_as_mti_args(args) do diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 9f7affb..78bbfa0 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -2,8 +2,8 @@ defmodule MixTestInteractive.CommandLineParserTest do use ExUnit.Case, async: true alias MixTestInteractive.CommandLineParser + alias MixTestInteractive.CommandLineParser.UsageError alias MixTestInteractive.Config - alias OptionParser.ParseError defmodule CustomRunner do @moduledoc false @@ -63,7 +63,7 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "fails if watch exclusion is an invalid Regex" do - assert {:error, %Regex.CompileError{}} = CommandLineParser.parse(["--exclude", "[A-Za-z"]) + assert {:error, %UsageError{}} = CommandLineParser.parse(["--exclude", "[A-Za-z"]) end test "configures additional extensions to watch with --extra-extensions" do @@ -82,11 +82,11 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "fails if custom runner doesn't have a run function" do - assert {:error, %ArgumentError{}} = CommandLineParser.parse(["--runner", inspect(NotARunner)]) + assert {:error, %UsageError{}} = CommandLineParser.parse(["--runner", inspect(NotARunner)]) end test "fails if custom runner module doesn't exist" do - assert {:error, %ArgumentError{}} = CommandLineParser.parse(["--runner", "NotAModule"]) + assert {:error, %UsageError{}} = CommandLineParser.parse(["--runner", "NotAModule"]) end test "sets show_timestamp? flag with --timestamp" do @@ -205,7 +205,7 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "requires -- separator to distinguish the sets of arguments" do - assert {:error, %ParseError{}} = CommandLineParser.parse(["--clear", "--stale"]) + assert {:error, %UsageError{}} = CommandLineParser.parse(["--clear", "--stale"]) end test "handles mix test options with leading `--` separator" do @@ -220,7 +220,7 @@ defmodule MixTestInteractive.CommandLineParserTest do end test "fails with unknown options before --" do - assert {:error, %ParseError{}} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) + assert {:error, %UsageError{}} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) end test "omits unknown options after --" do From d990b364b26330eb85ab54945031cc168d5bf9f3 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 21:56:58 -0700 Subject: [PATCH 15/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20mti=20arg?= =?UTF-8?q?ument=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After calling OptionParser, do a pass to parse special values from strings (regexes and runner modules). This allows more helpful error messages - Then do a pass to combine `:keep`-style arguments into a single list to simplify config building --- .../command_line_parser.ex | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index ce2a23a..90785a6 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -127,42 +127,25 @@ defmodule MixTestInteractive.CommandLineParser do mti_opts |> Enum.reduce(Config.load_from_environment(), fn {:clear, clear?}, config -> %{config | clear?: clear?} - {:runner, runner}, config -> %{config | runner: ensure_valid_runner(runner)} + {:exclude, excludes}, config -> %{config | exclude: excludes} + {:extra_extensions, extra_extensions}, config -> %{config | extra_extensions: extra_extensions} + {:runner, runner}, config -> %{config | runner: runner} {:timestamp, show_timestamp?}, config -> %{config | show_timestamp?: show_timestamp?} {:task, task}, config -> %{config | task: task} _pair, config -> config end) - |> add_custom_command(mti_opts) - |> add_excludes(mti_opts) - |> add_extra_extensions(mti_opts) + |> handle_custom_command(mti_opts) {:ok, config} - rescue - error -> - {:error, UsageError.exception(error)} end - defp add_custom_command(%Config{} = config, mti_opts) do + defp handle_custom_command(%Config{} = config, mti_opts) do case Keyword.fetch(mti_opts, :command) do - {:ok, command} -> %{config | command: {command, Keyword.get_values(mti_opts, :arg)}} + {:ok, command} -> %{config | command: {command, Keyword.get(mti_opts, :arg, [])}} :error -> config end end - defp add_excludes(%Config{} = config, mti_opts) do - case Keyword.get_values(mti_opts, :exclude) do - [] -> config - excludes -> %{config | exclude: Enum.map(excludes, &Regex.compile!/1)} - end - end - - defp add_extra_extensions(%Config{} = config, mti_opts) do - case Keyword.get_values(mti_opts, :extra_extensions) do - [] -> config - extensions -> %{config | extra_extensions: extensions} - end - end - defp build_settings(mti_opts, mix_test_opts, patterns) do no_patterns? = Enum.empty?(patterns) {failed?, mix_test_opts} = Keyword.pop(mix_test_opts, :failed, false) @@ -178,46 +161,40 @@ defmodule MixTestInteractive.CommandLineParser do } end - defp ensure_valid_runner(runner) do - module = runner |> String.split(".") |> Module.concat() - - if function_exported?(module, :run, 2) do - module - else - raise ArgumentError, message: "--runner must name a module that implements a `run/2` function" - end - end - defp parse_mix_test_args(mix_test_args) do {mix_test_opts, patterns} = OptionParser.parse!(mix_test_args, aliases: @mix_test_aliases, switches: @mix_test_options) {:ok, mix_test_opts, patterns} - rescue - error in ParseError -> - {:error, UsageError.exception(error)} end defp parse_mti_args(cli_args) do + with {:ok, mti_opts, mix_test_args} <- parse_mti_args_raw(cli_args), + {:ok, parsed} <- parse_mti_option_values(mti_opts) do + {:ok, combine_multiples(parsed), mix_test_args} + end + end + + defp parse_mti_args_raw(cli_args) do case Enum.find_index(cli_args, &(&1 == "--")) do nil -> case try_parse_as_mti_args(cli_args) do {:ok, mti_opts} -> {:ok, mti_opts, []} - {:error, :try_as_mix_test_args} -> {:ok, [], cli_args} + {:error, :maybe_mix_test_args} -> {:ok, [], cli_args} {:error, error} -> {:error, error} end index -> mti_args = Enum.take(cli_args, index) - with {:ok, mti_opts} <- parse_as_mti_args(mti_args) do + with {:ok, mti_opts} <- force_parse_as_mti_args(mti_args) do mix_test_args = Enum.drop(cli_args, index + 1) {:ok, mti_opts, mix_test_args} end end end - defp parse_as_mti_args(args) do + defp force_parse_as_mti_args(args) do {mti_opts, _args} = OptionParser.parse!(args, strict: @options) {:ok, mti_opts} rescue @@ -229,8 +206,44 @@ defmodule MixTestInteractive.CommandLineParser do cond do invalid == [] -> {:ok, mti_opts} - mti_opts == [] -> {:error, :try_as_mix_test_args} - true -> parse_as_mti_args(args) + mti_opts == [] -> {:error, :maybe_mix_test_args} + true -> force_parse_as_mti_args(args) end end + + defp parse_mti_option_values(mti_opts) do + {:ok, Enum.map(mti_opts, &parse_one_option_value!/1)} + rescue + error in UsageError -> {:error, error} + end + + defp parse_one_option_value!({:exclude, exclude}) do + {:exclude, Regex.compile!(exclude)} + rescue + error -> + raise UsageError, "--exclude '#{exclude}': #{Exception.message(error)}" + end + + defp parse_one_option_value!({:runner, runner}) do + module = runner |> String.split(".") |> Module.concat() + + if function_exported?(module, :run, 2) do + {:runner, module} + else + raise UsageError, message: "--runner: '#{runner}' must name a module that implements a `run/2` function" + end + end + + defp parse_one_option_value!(option), do: option + + defp combine_multiples(mti_opts) do + @options + |> Enum.filter(fn {_name, type} -> type == :keep end) + |> Enum.reduce(mti_opts, fn {name, _type}, acc -> + case Keyword.pop_values(acc, name) do + {[], _new_opts} -> acc + {values, new_opts} -> Keyword.put(new_opts, name, values) + end + end) + end end From 8876419d9108017d9d1085b68fbb1d707ecb77e8 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Tue, 10 Sep 2024 22:08:26 -0700 Subject: [PATCH 16/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Don't=20rely=20on=20?= =?UTF-8?q?exceptions=20for=20control=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify the option value parsing logic to return :ok/:error tuples and use reduce_while to collect the results. There are still two `rescue` clauses, but they're used in order to nicely format error messages since formatting functions aren't exposed by `OptionParser` and `Regex`. --- .../command_line_parser.ex | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 90785a6..2df46ba 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -212,29 +212,37 @@ defmodule MixTestInteractive.CommandLineParser do end defp parse_mti_option_values(mti_opts) do - {:ok, Enum.map(mti_opts, &parse_one_option_value!/1)} - rescue - error in UsageError -> {:error, error} + mti_opts + |> Enum.reduce_while([], fn {name, value}, acc -> + case parse_one_option_value(name, value) do + {:ok, parsed} -> {:cont, [{name, parsed} | acc]} + error -> {:halt, error} + end + end) + |> case do + {:error, _error} = error -> error + new_opts -> {:ok, Enum.reverse(new_opts)} + end end - defp parse_one_option_value!({:exclude, exclude}) do - {:exclude, Regex.compile!(exclude)} + defp parse_one_option_value(:exclude, exclude) do + {:ok, Regex.compile!(exclude)} rescue error -> - raise UsageError, "--exclude '#{exclude}': #{Exception.message(error)}" + {:error, UsageError.exception("--exclude '#{exclude}': #{Exception.message(error)}")} end - defp parse_one_option_value!({:runner, runner}) do + defp parse_one_option_value(:runner, runner) do module = runner |> String.split(".") |> Module.concat() if function_exported?(module, :run, 2) do - {:runner, module} + {:ok, module} else - raise UsageError, message: "--runner: '#{runner}' must name a module that implements a `run/2` function" + {:error, UsageError.exception("--runner: '#{runner}' must name a module that implements a `run/2` function")} end end - defp parse_one_option_value!(option), do: option + defp parse_one_option_value(_name, value), do: {:ok, value} defp combine_multiples(mti_opts) do @options From e4bf787b04eee74f3785d17602d0b84e8fa7328d Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Thu, 12 Sep 2024 21:02:56 -0700 Subject: [PATCH 17/19] =?UTF-8?q?=E2=9C=A8=20Add=20--help=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only show usage information when requested. When there is an error, display a note about using --help instead. --- lib/mix_test_interactive.ex | 17 +++++-- .../command_line_parser.ex | 47 +++++++++++-------- .../command_line_parser_test.exs | 18 +++++++ 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index 34eeba3..87bf1e7 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -14,6 +14,9 @@ defmodule MixTestInteractive do """ def run(args \\ []) when is_list(args) do case CommandLineParser.parse(args) do + {:ok, :help} -> + IO.puts(CommandLineParser.usage_message()) + {:ok, %{config: config, settings: settings}} -> {:ok, _apps} = Application.ensure_all_started(@application) @@ -23,9 +26,17 @@ defmodule MixTestInteractive do loop() {:error, error} -> - IO.puts(:standard_error, Exception.message(error)) - IO.puts("") - IO.puts(CommandLineParser.usage_message()) + message = [ + :bright, + :red, + Exception.message(error), + :reset, + "\n\nTry `mix test.interactive --help` for more information" + ] + + formatted = IO.ANSI.format(message) + IO.puts(:standard_error, formatted) + exit({:shutdown, 1}) end end diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 2df46ba..68aecd6 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -28,6 +28,7 @@ defmodule MixTestInteractive.CommandLineParser do command: :string, exclude: :keep, extra_extensions: :keep, + help: :boolean, runner: :string, task: :string, timestamp: :boolean, @@ -36,32 +37,33 @@ defmodule MixTestInteractive.CommandLineParser do @usage """ Usage: - mix_test_interactive [-- ] - mix_test_interactive + mix test.interactive [-- ] + mix test.interactive --help + mix test.interactive where: : --(no-)clear Clear the console before each run (default `false`) - --command / - --arg Custom command and arguments for running + --command /--arg Custom command and arguments for running tests (default: `"mix"` with no args) - NOTE: Use `--arg` multiple times to specify - more than one argument + NOTE: Use `--arg` multiple times to + specify more than one argument --exclude Exclude files/directories from triggering - test runs - (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) + test runs (default: + `["~r/\.#/", "~r{priv/repo/migrations}"`]) NOTE: Use `--exclude` multiple times to specify more than one regex --extra-extensions Watch files with additional extensions (default: []) - NOTE: Use `--extra-extensions` multiple times - to specify more than one extension. + NOTE: Use `--extra-extensions` multiple + times to specify more than one extension. --runner Use a custom runner module (default: `MixTestInteractive.PortRunner`) - --task Run a different mix task (default: `"test"`) - --(no-)timestamp Display the current time before running the - tests (default: `false`) + --task Run a different mix task + (default: `"test"`) + --(no-)timestamp Display the current time before running + the tests (default: `false`) --(no-)watch Run tests when a watched file changes (default: `true`) @@ -108,14 +110,20 @@ defmodule MixTestInteractive.CommandLineParser do b: :breakpoints ] - @spec parse([String.t()]) :: {:ok, %{config: Config.t(), settings: Settings.t()}} | {:error, UsageError.t()} + @type parse_result :: {:ok, %{config: Config.t(), settings: Settings.t()} | :help} | {:error, UsageError.t()} + + @spec parse([String.t()]) :: parse_result() def parse(cli_args \\ []) do with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), - {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args), - {:ok, config} <- build_config(mti_opts) do - settings = build_settings(mti_opts, mix_test_opts, patterns) - - {:ok, %{config: config, settings: settings}} + {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args) do + if Keyword.get(mti_opts, :help, false) do + {:ok, :help} + else + with {:ok, config} <- build_config(mti_opts) do + settings = build_settings(mti_opts, mix_test_opts, patterns) + {:ok, %{config: config, settings: settings}} + end + end end end @@ -206,6 +214,7 @@ defmodule MixTestInteractive.CommandLineParser do cond do invalid == [] -> {:ok, mti_opts} + mti_opts[:help] -> {:ok, mti_opts} mti_opts == [] -> {:error, :maybe_mix_test_args} true -> force_parse_as_mti_args(args) end diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 78bbfa0..00b8d07 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -14,6 +14,24 @@ defmodule MixTestInteractive.CommandLineParserTest do @moduledoc false end + describe "help option" do + test "returns help with --help" do + assert {:ok, :help} == CommandLineParser.parse(["--help"]) + end + + test "returns help even with other options" do + assert {:ok, :help} == CommandLineParser.parse(["--clear", "--help", "--no-watch"]) + end + + test "returns help even with unknown options" do + assert {:ok, :help} == CommandLineParser.parse(["--unknown", "--help"]) + end + + test "returns help even with mix test options only" do + assert {:ok, :help} == CommandLineParser.parse(["--stale", "--help"]) + end + end + describe "mix test.interactive options" do test "retains original defaults when no options" do {:ok, %{config: config}} = CommandLineParser.parse([]) From 08c8111b30c4df6680c8ea3a9198dcaefc242068 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Thu, 12 Sep 2024 21:11:12 -0700 Subject: [PATCH 18/19] =?UTF-8?q?=E2=9C=A8=20Add=20a=20--version=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mix_test_interactive.ex | 3 ++ .../command_line_parser.ex | 27 ++++++++++------- .../command_line_parser_test.exs | 30 ++++++++++++++++--- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/lib/mix_test_interactive.ex b/lib/mix_test_interactive.ex index 87bf1e7..520fcfc 100644 --- a/lib/mix_test_interactive.ex +++ b/lib/mix_test_interactive.ex @@ -17,6 +17,9 @@ defmodule MixTestInteractive do {:ok, :help} -> IO.puts(CommandLineParser.usage_message()) + {:ok, :version} -> + IO.puts("mix test.interactive v#{Application.spec(@application, :vsn)}") + {:ok, %{config: config, settings: settings}} -> {:ok, _apps} = Application.ensure_all_started(@application) diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index 68aecd6..a9e4b73 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -32,14 +32,16 @@ defmodule MixTestInteractive.CommandLineParser do runner: :string, task: :string, timestamp: :boolean, + version: :boolean, watch: :boolean ] @usage """ Usage: mix test.interactive [-- ] - mix test.interactive --help mix test.interactive + mix test.interactive --help + mix test.interactive --version where: : @@ -110,19 +112,24 @@ defmodule MixTestInteractive.CommandLineParser do b: :breakpoints ] - @type parse_result :: {:ok, %{config: Config.t(), settings: Settings.t()} | :help} | {:error, UsageError.t()} + @type parse_result :: {:ok, %{config: Config.t(), settings: Settings.t()} | :help | :version} | {:error, UsageError.t()} @spec parse([String.t()]) :: parse_result() def parse(cli_args \\ []) do with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args) do - if Keyword.get(mti_opts, :help, false) do - {:ok, :help} - else - with {:ok, config} <- build_config(mti_opts) do - settings = build_settings(mti_opts, mix_test_opts, patterns) - {:ok, %{config: config, settings: settings}} - end + cond do + Keyword.get(mti_opts, :help, false) -> + {:ok, :help} + + Keyword.get(mti_opts, :version, false) -> + {:ok, :version} + + true -> + with {:ok, config} <- build_config(mti_opts) do + settings = build_settings(mti_opts, mix_test_opts, patterns) + {:ok, %{config: config, settings: settings}} + end end end end @@ -214,7 +221,7 @@ defmodule MixTestInteractive.CommandLineParser do cond do invalid == [] -> {:ok, mti_opts} - mti_opts[:help] -> {:ok, mti_opts} + mti_opts[:help] || mti_opts[:version] -> {:ok, mti_opts} mti_opts == [] -> {:error, :maybe_mix_test_args} true -> force_parse_as_mti_args(args) end diff --git a/test/mix_test_interactive/command_line_parser_test.exs b/test/mix_test_interactive/command_line_parser_test.exs index 00b8d07..5a1aaa0 100644 --- a/test/mix_test_interactive/command_line_parser_test.exs +++ b/test/mix_test_interactive/command_line_parser_test.exs @@ -15,23 +15,45 @@ defmodule MixTestInteractive.CommandLineParserTest do end describe "help option" do - test "returns help with --help" do + test "returns :help with --help" do assert {:ok, :help} == CommandLineParser.parse(["--help"]) end - test "returns help even with other options" do + test "returns :help even with other options" do assert {:ok, :help} == CommandLineParser.parse(["--clear", "--help", "--no-watch"]) end - test "returns help even with unknown options" do + test "returns :help even with unknown options" do assert {:ok, :help} == CommandLineParser.parse(["--unknown", "--help"]) end - test "returns help even with mix test options only" do + test "returns :help even with mix test options only" do assert {:ok, :help} == CommandLineParser.parse(["--stale", "--help"]) end end + describe "version option" do + test "returns :version with --version" do + assert {:ok, :version} == CommandLineParser.parse(["--version"]) + end + + test "returns :help with both --help and --version" do + assert {:ok, :help} == CommandLineParser.parse(["--version", "--help"]) + end + + test "returns :version even with other options" do + assert {:ok, :version} == CommandLineParser.parse(["--clear", "--version", "--no-watch"]) + end + + test "returns :version even with unknown options" do + assert {:ok, :version} == CommandLineParser.parse(["--unknown", "--version"]) + end + + test "returns :version even with mix test options only" do + assert {:ok, :version} == CommandLineParser.parse(["--stale", "--version"]) + end + end + describe "mix test.interactive options" do test "retains original defaults when no options" do {:ok, %{config: config}} = CommandLineParser.parse([]) From 1dedc3ce7738cb030ba19c824fb46cfda04f87a9 Mon Sep 17 00:00:00 2001 From: Randy Coulman Date: Thu, 12 Sep 2024 21:33:25 -0700 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=93=9D=20Update=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 64 +++++++++++++------ lib/mix/tasks/test/interactive.ex | 38 +++++++++-- .../command_line_parser.ex | 2 +- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 48cf158..894cdab 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,55 @@ end ## Usage -Run the mix task: - ```shell -mix test.interactive +mix test.interactive [-- ] +mix test.interactive +mix test.interactive --help +mix test.interactive --version ``` Your tests will run immediately (and every time a file changes). -If you don't want tests to run automatically when files change, you can start `mix test.interactive` with the `--no-watch` flag: +### Options -```shell -mix test.interactive --no-watch -``` +`mix test.interactive` understands the following options, most of which +correspond to configuration settings below. + +Note that, if you want to pass both mix test.interactive options and mix test +arguments, you must separate them with `--`. + +If an option is provided on the command line, it will override the same option +specified in the configuration. + +- `--(no-)clear`: Clear the console before each run (default `false`). +- `--command [--arg ]`: Custom command and arguments for + running tests (default: "mix" with no arguments). NOTE: Use `--arg` multiple + times to specify more than one argument. +- `--exclude `: Exclude files/directories from triggering test runs + (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) NOTE: Use `--exclude` + multiple times to specify more than one regex. +- `--extra-extensions `: Watch files with additional extensions + (default: []). +- `--runner `: Use a custom runner module (default: + `MixTestInteractive.PortRunner`). +- `--task `: Run a different mix task (default: `"test"`). +- `--(no-)timestamp`: Display the current time before running the tests + (default: `false`). +- `--(no-)watch`: Don't run tests when a file changes (default: `true`). + +All of the `` are passed through to `mix test` on every +test run. + +`mix test.interactive` will detect the `--stale` and `--failed` flags and use those as initial settings in interactive mode. You can then toggle those flags on and off as needed. It will also detect any filename or pattern arguments and use those as initial settings. However, it does not detect any filenames passed with `--include` or `--only`. Note that if you specify a pattern on the command-line, `mix test.interactive` will find all test files matching that pattern and pass those to `mix test` as if you had used the `p` command. + +### Patterns and filenames + +`mix test.interactive` can take the same filename or filename:line_number +patterns that `mix test` understands. It also allows you to specify one or +more "patterns" - strings that match one or more test files. When you provide +one or more patterns on the command-line, `mix test.interactive` will find all +test files matching those patterns and pass them to `mix test` as if you had +used the `p` command (described below). After the tests run, you can use the interactive mode to change which tests will run. @@ -62,21 +98,11 @@ Use the `Enter` key to re-run the current set of tests without requiring a file Use the `q` command, or press `Ctrl-D` to exit the program. -## Passing Arguments To Tasks - -Any command line arguments passed to the `mix test.interactive` task will be passed -through to the task being run, along with any arguments added by interactive mode. If I want to see detailed trace information for my tests, I can run: - -``` -mix test.interactive --trace -``` - -`mix test.interactive` will detect the `--stale` and `--failed` flags and use those as initial settings in interactive mode. You can then toggle those flags on and off as needed. It will also detect any filename or pattern arguments and use those as initial settings. However, it does not detect any filenames passed with `--include` or `--only`. Note that if you specify a pattern on the command-line, `mix test.interactive` will find all test files matching that pattern and pass those to `mix test` as if you had used the `p` command. - ## Configuration `mix test.interactive` can be configured with various options using application -configuration. +configuration. You can also use command line arguments to specify these +configuration options, or to override configured options. ### `clear`: Clear the console before each run diff --git a/lib/mix/tasks/test/interactive.ex b/lib/mix/tasks/test/interactive.ex index 25dd990..d1a5967 100644 --- a/lib/mix/tasks/test/interactive.ex +++ b/lib/mix/tasks/test/interactive.ex @@ -11,17 +11,43 @@ defmodule Mix.Tasks.Test.Interactive do ## Usage ```shell - mix test.interactive [options] pattern... + mix test.interactive [-- ] + mix test.interactive + mix test.interactive --help + mix test.interactive --version ``` Your tests will run immediately (and every time a file changes). ### Options - `mix test.interactive` understands the following options: - - `--no-watch`: Don't run tests when a file changes + `mix test.interactive` understands the following options, most of which + correspond to configuration settings below. + + Note that, if you want to pass both mix test.interactive options and mix test + arguments, you must separate them with `--`. + + If an option is provided on the command line, it will override the same option + specified in the configuration. + + - `--(no-)clear`: Clear the console before each run (default `false`). + - `--command [--arg ]`: Custom command and arguments for + running tests (default: "mix" with no arguments). NOTE: Use `--arg` multiple + times to specify more than one argument. + - `--exclude `: Exclude files/directories from triggering test runs + (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) NOTE: Use `--exclude` + multiple times to specify more than one regex. + - `--extra-extensions `: Watch files with additional extensions + (default: []). + - `--runner `: Use a custom runner module (default: + `MixTestInteractive.PortRunner`). + - `--task `: Run a different mix task (default: `"test"`). + - `--(no-)timestamp`: Display the current time before running the tests + (default: `false`). + - `--(no-)watch`: Don't run tests when a file changes (default: `true`). - All other options are passed through to `mix test` on every test run. + All of the `` are passed through to `mix test` on every + test run. `mix test.interactive` will detect the `--stale` and `--failed` flags and use those as initial settings in interactive mode. You can then toggle those flags @@ -61,8 +87,8 @@ defmodule Mix.Tasks.Test.Interactive do operation of `mix test.interactive` with the following settings: - `clear: true`: Clear the console before each run (default: `false`). - - `command: ` or `command: {, [, ...]}`: - Use the provided command and arguments to run the test task (default: `mix`). + - `command: ` or `command: {, [, ...]}`: Use the + provided command and arguments to run the test task (default: `mix`). - `exclude: [patterns...]`: A list of `Regex`es to ignore when watching for changes (default: `[~r/\.#/, ~r{priv/repo/migrations}]`). - `extra_extensions: [...]`: Additional filename extensions to include diff --git a/lib/mix_test_interactive/command_line_parser.ex b/lib/mix_test_interactive/command_line_parser.ex index a9e4b73..f325647 100644 --- a/lib/mix_test_interactive/command_line_parser.ex +++ b/lib/mix_test_interactive/command_line_parser.ex @@ -46,7 +46,7 @@ defmodule MixTestInteractive.CommandLineParser do where: : --(no-)clear Clear the console before each run - (default `false`) + (default: `false`) --command /--arg Custom command and arguments for running tests (default: `"mix"` with no args) NOTE: Use `--arg` multiple times to