├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib ├── mix │ └── tasks │ │ └── test │ │ └── interactive.ex ├── mix_test_interactive.ex └── mix_test_interactive │ ├── application.ex │ ├── command.ex │ ├── command │ ├── all_tests.ex │ ├── exclude.ex │ ├── failed.ex │ ├── help.ex │ ├── include.ex │ ├── max_failures.ex │ ├── only.ex │ ├── pattern.ex │ ├── quit.ex │ ├── repeat_until_failure.ex │ ├── run_tests.ex │ ├── seed.ex │ ├── stale.ex │ ├── toggle_tracing.ex │ └── toggle_watch_mode.ex │ ├── command_line_formatter.ex │ ├── command_line_parser.ex │ ├── command_processor.ex │ ├── config.ex │ ├── interactive_mode.ex │ ├── main_supervisor.ex │ ├── message_inbox.ex │ ├── paths.ex │ ├── pattern_filter.ex │ ├── port_runner.ex │ ├── run_summary.ex │ ├── runner.ex │ ├── settings.ex │ ├── test_files.ex │ ├── test_runner.ex │ └── watcher.ex ├── mix.exs ├── mix.lock ├── priv └── zombie_killer └── test ├── mix_test_interactive ├── command_line_formatter_test.exs ├── command_line_parser_test.exs ├── command_processor_test.exs ├── config_test.exs ├── end_to_end_test.exs ├── message_inbox_test.exs ├── paths_test.exs ├── pattern_filter_test.exs ├── port_runner_test.exs ├── run_summary_test.exs ├── runner_test.exs ├── settings_test.exs └── test_files_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:typed_struct], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 5 | plugins: [Styler] 6 | ] 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | test: 17 | name: Checks/Tests on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | include: 22 | - elixir: '1.14' 23 | otp: '25' 24 | - elixir: '1.15' 25 | otp: '26' 26 | - elixir: '1.16' 27 | otp: '26' 28 | - elixir: '1.17' 29 | otp: '27' 30 | - elixir: '1.18' 31 | otp: '27' 32 | steps: 33 | - name: Set up Elixir 34 | id: setup 35 | uses: erlef/setup-beam@v1 36 | with: 37 | otp-version: ${{matrix.otp}} 38 | elixir-version: ${{matrix.elixir}} 39 | version-type: strict 40 | 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | 44 | - name: Cache dependencies 45 | id: cache-deps 46 | env: 47 | cache-name: cache-elixir-deps 48 | uses: actions/cache@v4 49 | with: 50 | key: ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}-${{steps.setup.outputs.elixir-version}}-${{hashFiles('**/mix.lock')}} 51 | path: deps 52 | restore-keys: | 53 | ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}-${{steps.setup.outputs.elixir-version}}- 54 | ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}- 55 | ${{runner.os}}-mix-${{env.cache-name}}- 56 | 57 | - name: Cache compiled build 58 | id: cache-build 59 | env: 60 | cache-name: cache-compiled-build 61 | uses: actions/cache@v4 62 | with: 63 | key: ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}-${{steps.setup.outputs.elixir-version}}-${{hashFiles('**/mix.lock')}} 64 | path: _build 65 | restore-keys: | 66 | ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}-${{steps.setup.outputs.elixir-version}}- 67 | ${{runner.os}}-mix-${{env.cache-name}}-${{steps.setup.outputs.otp-version}}- 68 | ${{runner.os}}-mix-${{env.cache-name}}- 69 | 70 | - name: Clean if incremental build fails 71 | if: github.run_attempt != '1' 72 | run: | 73 | mix deps.clean --all 74 | mix clean 75 | 76 | - name: Install dependencies 77 | run: mix deps.get 78 | 79 | - name: Compile 80 | run: mix compile --warnings-as-errors 81 | 82 | - name: Check formatting 83 | if: ${{matrix.elixir == '1.18'}} 84 | run: mix format --check-formatted 85 | 86 | - name: Run tests 87 | run: mix test 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | mix_test_interactive-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | 29 | # Ignore secrets 30 | /config/*.secret.exs 31 | 32 | # Ignore editor integration files 33 | .elixir_ls/ 34 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2 2 | elixir 1.18.1-otp-27 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased](https://github.com/randycoulman/mix_test_interactive/compare/v4.3.0...HEAD) 9 | 10 | ## [v4.3.0](https://github.com/randycoulman/mix_test_interactive/compare/v4.2.0...v4.3.0) - 2024-03-21 11 | 12 | ### Added 13 | 14 | - Add a new `verbose` configuration setting and command-line option, disabled by default. When enabled, `mix test.interactive` will print the command it is about to run just before running the tests. ([#135](https://github.com/randycoulman/mix_test_interactive/pull/135)) 15 | 16 | ## [v4.2.0](https://github.com/randycoulman/mix_test_interactive/compare/v4.1.2...v4.2.0) - 2024-03-19 17 | 18 | ### Fixed 19 | 20 | - On Unix-like system we no longer start the client application prematurely. Previously, we'd run (essentially) `mix do run -e 'Application.put_env(:elixir, :ansi_enabled, true)', test` in order to enable ANSI control codes/colors when running tests. However, `mix run` by default starts the application. Normally this would be fine, but in some cases it can cause problems. We now use `mix do eval 'Application.put_env(:elixir, :ansi_enabled, true)', test` instead, which delays starting the application until the `mix test` task runs. ([#132](https://github.com/randycoulman/mix_test_interactive/pull/132)) 21 | 22 | - Properly handle the `--no-start` option to `mix test` on Unix-like systems. Previously, we were using that option for the `mix run -e` command we were using to enable ANSI output, but not passing it through to `mix test` itself. ([#132](https://github.com/randycoulman/mix_test_interactive/pull/132)) 23 | 24 | ### Added 25 | 26 | - We make the use of ANSI control code output configurable by adding the `--(no-)ansi-enabled` command-line option and `ansi_enabled` configuration setting. Previously, we'd enable ANSI output automatically on Unix-like systems and not on Windows. This is still the default, but now Windows users can opt into ANSI output. Since Windows 10, ANSI support has been available if the [appropriate registry key is set](https://hexdocs.pm/elixir/IO.ANSI.html). Additional, users on Unix-like systems can opt out of ANSI output if desired. ([#133](https://github.com/randycoulman/mix_test_interactive/pull/133)) 27 | 28 | ## [v4.1.2](https://github.com/randycoulman/mix_test_interactive/compare/v4.1.1...v4.1.2) - 2024-12-14 29 | 30 | ### Updated 31 | 32 | - Update README with instructions for running `mix test.interactive` as an independent script that doesn't require installing as a dependency in your application. ([#127](https://github.com/randycoulman/mix_test_interactive/pull/127) - Thanks [@andyl](https://github.com/andyl)!) 33 | 34 | - Allow process_tree versions v0.1.3 and v0.2.0 to provide more flexibility for upstream projects ([#128](https://github.com/randycoulman/mix_test_interactive/pull/128)) 35 | 36 | ## [v4.1.1](https://github.com/randycoulman/mix_test_interactive/compare/v4.1.0...v4.1.1) - 2024-09-28 37 | 38 | ### Fixed 39 | 40 | - Properly handle `mix test.interactive ` case. The new command-line parsing added in v4.0 was not properly capturing the filenames/patterns and passing them on to `mix test`. ([#123](https://github.com/randycoulman/mix_test_interactive/pull/123) - Thanks [@jfpedroza](https://github.com/jfpedroza) for finding and reporting the bug!) 41 | 42 | ## [v4.1.0](https://github.com/randycoulman/mix_test_interactive/compare/v4.0.0...v4.1.0) - 2024-09-21 43 | 44 | ### Added 45 | 46 | - This version adds a number of new commands for controlling additional `mix test` options interactively: 47 | 48 | - `d `/`d`: Set or clear the seed to use when running tests (`mix test --seed `). ([#112](https://github.com/randycoulman/mix_test_interactive/pull/112)) 49 | - `i `/`i`: Set or clear tags to include (`mix test --include --include ...`). ([#113](https://github.com/randycoulman/mix_test_interactive/pull/113)) 50 | - `o `/`o`: Set or clear "only" tags (`mix test --only --only ...`). ([#113](https://github.com/randycoulman/mix_test_interactive/pull/113)) 51 | - `x `/`x`: Set or clear tags to exclude (`mix test --exclude --exclude ...`). ([#113](https://github.com/randycoulman/mix_test_interactive/pull/113)) 52 | - `m `/`m`: Set or clear the maximum number of failures to allow (`mix test --max-failures `). ([#116](https://github.com/randycoulman/mix_test_interactive/pull/116)) 53 | - `r /`/`r`: Set or clear the maximum number of repeated runs until a test failure (`mix test --repeat-until-failure `). **NOTE:** `mix test` only supports this option in v1.17.0 and later. ([#118](https://github.com/randycoulman/mix_test_interactive/pull/118)) 54 | - `t`: Toggle test tracing on/off (`mix test --trace`). ([#117](https://github.com/randycoulman/mix_test_interactive/pull/112)) 55 | 56 | - There is now a `MixTestInteractive.TestRunner` behaviour for use in custom test runners. Up until now, custom test runners needed to implement a single `run/2` function. This release adds a behaviour that custom test runners can implement to ensure that they've correctly conformed to the interface. Custom test runners don't have to explicitly implement the behaviour, but must implicitly do so as before. ([#115](https://github.com/randycoulman/mix_test_interactive/pull/115)) 57 | 58 | ## [v4.0.0](https://github.com/randycoulman/mix_test_interactive/compare/v3.2.1...v4.0.0) - 2024-09-13 59 | 60 | ### 💥 BREAKING CHANGE 💥 61 | 62 | This version introduces the option of "config-less" operation. All configuration settings can now be supplied on the command-line instead. To avoid confusion and clashes with `mix test`'s command-line options, it is now necessary to separate `mix test.interactive`'s options from `mix test`'s options with `--` separator. 63 | 64 | For example, to use the new `--clear` option as well as `mix test`'s `--stale` option, it is necessary to use: 65 | 66 | ```shell 67 | mix test.interactive --clear -- --stale 68 | ``` 69 | 70 | This affects two of the command-line options that were available in previous versions: 71 | 72 | - `mix test.interactive`'s `--no-watch` flag. Previously, you could run (for example) `mix test.interactive --no-watch --stale`. This will no longer work. You must now use `mix test.interactive --no-watch -- --stale` instead. 73 | - `mix test`'s `--exclude` option. `mix test.interactive` now has its own `--exclude` option. Previously, you could run (for example) `mix test.interactive --exclude some_test_tag` and that argument would be forwarded on to `mix test`. Now you must use `mix test.interactive -- --exclude some_test_tag` instead. 74 | 75 | If you don't use either of these two options, everything should work as before. 76 | 77 | To upgrade to this version, you'll need to update any `mix` aliases or other scripts you may have defined for `mix test.interactive`. In addition, you and everyone who works in your codebase will need to update any shell aliases they have defined. 78 | 79 | ### Added 80 | 81 | - This version introduces the option of "config-less" operation. All configuration settings can now be supplied on the command-line instead. See the [README](https://github.com/randycoulman/mix_test_interactive/blob/main/README.md) or run `mix help test.interactive` for more information. Also, see the `💥 BREAKING CHANGE 💥` section above. ([#108](https://github.com/randycoulman/mix_test_interactive/pull/108)) 82 | 83 | ### Changed 84 | 85 | - The `Running tests...` message that `mix test.interactive` displays before each test run is displayed in color. This makes it easier to find the most recent test run when scrolling back in your shell. ([#109](https://github.com/randycoulman/mix_test_interactive/pull/109)) 86 | 87 | ## [v3.2.1](https://github.com/randycoulman/mix_test_interactive/compare/v3.2.0...v3.2.1) - 2024-09-07 88 | 89 | ### Fixed 90 | 91 | - Fixed handling of custom `command`/`args` on Unix-like systems. `mix_test_interactive` was not correctly ordering the various arguments. ([#105](https://github.com/randycoulman/mix_test_interactive/pull/105) - Thanks [@callmiy](https://github.com/callmiy)!) 92 | 93 | ## [v3.2.0](https://github.com/randycoulman/mix_test_interactive/compare/v3.1.0...v3.2.0) - 2024-08-24 94 | 95 | ### Changed 96 | 97 | - Made pattern matching more flexible. Previously, when given multiple patterns, we would not do any file filtering if any of the patterns was a `file:line`-style pattern. Instead, we'd pass all of the patterns to `mix test` literally. Now, we run normal file filtering for any non-`file:line`-style patterns and concatenate the results with any `file:line`-style patterns. ([#99](https://github.com/randycoulman/mix_test_interactive/pull/99)) 98 | - Added documentation for missing configuration options in the mix task's module documentation. ([#100](https://github.com/randycoulman/mix_test_interactive/pull/100)) 99 | 100 | ## [v3.1.0](https://github.com/randycoulman/mix_test_interactive/compare/v3.0.0...v3.1.0) - 2024-08-24 101 | 102 | ### Added 103 | 104 | - Add a new `command` configuration option that allows use of a custom command instead of `mix` for running tests. See the [README](https://github.com/randycoulman/mix_test_interactive#command-use-a-custom-command) for more details. ([#96](https://github.com/randycoulman/mix_test_interactive/pull/96)) 105 | 106 | ### Changed 107 | 108 | - Added [documentation for missing configuration options](https://github.com/randycoulman/mix_test_interactive#configuration) in the README. ([#96](https://github.com/randycoulman/mix_test_interactive/pull/96)) 109 | 110 | ## [v3.0.0](https://github.com/randycoulman/mix_test_interactive/compare/v2.1.0...v3.0.0) - 2024-07-13 111 | 112 | ### 💥 BREAKING CHANGE 💥 113 | 114 | - This release drops support for Elixir 1.12. We officially support the 115 | [same versions as Elixir 116 | itself](https://hexdocs.pm/elixir/1.17.2/compatibility-and-deprecations.html), 117 | so support for Elixir 1.12. 118 | ([#94](https://github.com/randycoulman/mix_test_interactive/pull/94)) 119 | 120 | There are no actual breaking changes in the code itself, so as long as you're on 121 | Elixir 1.13 or later, you should have no problems upgrading to this version. 122 | 123 | ### Changed 124 | 125 | - Update to the latest version of [ex_doc](https://hexdocs.pm/ex_doc/readme.html). The [documentation](https://hexdocs.pm/mix_test_interactive/readme.html) reflects these changes. ([#93](https://github.com/randycoulman/mix_test_interactive/pull/93)) 126 | 127 | ## [v2.1.0](https://github.com/randycoulman/mix_test_interactive/compare/v2.0.4...v2.1.0) - 2024-07-13 128 | 129 | ### Fixed 130 | 131 | - Fix compiler warnings on Elixir 1.17. ([#89](https://github.com/randycoulman/mix_test_interactive/pull/89) - Thanks [@jfpedroza](https://github.com/jfpedroza)!) 132 | 133 | ## [v2.0.4](https://github.com/randycoulman/mix_test_interactive/compare/v2.0.3...v2.0.4) - 2024-03-04 134 | 135 | ### Fixed 136 | 137 | - Ignore some filesystem events emitted by inotify. Some events are triggered later than others and end up causing the tests to run twice for a single file change. ([#86](https://github.com/randycoulman/mix_test_interactive/pull/86) - Thanks 138 | [@jwilger](https://github.com/jwilger)!) 139 | 140 | ## [v2.0.3](https://github.com/randycoulman/mix_test_interactive/compare/v2.0.2...v2.0.3) - 2024-01-27 141 | 142 | ### Changed 143 | 144 | - Update `file_system` dependency to allow versions 0.2 and 1.0 to avoid dependency conflicts with popular libraries like `phoenix_live_reload`. Previously, we'd specified v0.3, but there is no such version of `file_system`. ([#83](https://github.com/randycoulman/mix_test_interactive/pull/83)) 145 | 146 | ## [v2.0.2](https://github.com/randycoulman/mix_test_interactive/compare/v2.0.1...v2.0.2) - 2024-01-25 147 | 148 | ### Changed 149 | 150 | - Allow `file_system` versions 0.3 and 1.0 to avoid dependency conflicts with popular libraries like `phoenix_live_reload`. ([#81](https://github.com/randycoulman/mix_test_interactive/pull/81)) 151 | 152 | ## [v2.0.1](https://github.com/randycoulman/mix_test_interactive/compare/v2.0.0...v2.0.1) - 2024-01-25 153 | 154 | ### Fixed 155 | 156 | - Make the `styler` dependency a dev-only dependency to avoid pulling it into client projects with a potential version conflict. ([#79](https://github.com/randycoulman/mix_test_interactive/pull/79)) 157 | 158 | ## [v2.0.0](https://github.com/randycoulman/mix_test_interactive/compare/v1.2.1...v2.0.0) - 2024-01-22 159 | 160 | ### 💥 BREAKING CHANGES 💥 161 | 162 | - This release drops support for older Elixir versions. We officially support the 163 | [same versions as Elixir 164 | itself](https://hexdocs.pm/elixir/1.16.0/compatibility-and-deprecations.html), 165 | so support for Elixir 1.11 and prior has been dropped. 166 | ([#67](https://github.com/randycoulman/mix_test_interactive/pull/67), 167 | [#75](https://github.com/randycoulman/mix_test_interactive/pull/75)) 168 | - Upgrade [file_system](https://hex.pm/packages/file_system) dependency to 169 | version 1.0. This appears to be a simple bump to 1.0 with no breaking changes, 170 | so should be safe to upgrade to. It might break dependency resolution 171 | if you're locked to a pre-1.0 version, so it's noted here. 172 | ([#72](https://github.com/randycoulman/mix_test_interactive/pull/72) - Thanks 173 | [@andyl](https://github.com/andyl)!) 174 | 175 | There are no actual breaking changes, so as long as you're on Elixir 1.12 or 176 | later and aren't depending on a pre-1.0 version of `file_system`, you should 177 | have no problems upgrading to this version. 178 | 179 | ### Added 180 | 181 | - Add full task documentation. `mix help test.interactive` will now show a 182 | summary of usage and configuration information ([#70](https://github.com/randycoulman/mix_test_interactive/pull/70)) 183 | 184 | - Add support for newer `mix test` options ([#71](https://github.com/randycoulman/mix_test_interactive/pull/71)) 185 | 186 | ## [v1.2.2](https://github.com/randycoulman/mix_test_interactive/compare/v1.2.1...v1.2.2) - 2022-11-15 187 | 188 | No functional changes; purely administrative. 189 | 190 | ## Changed 191 | 192 | - Migrated repository ownership from @influxdata to @randycoulman ([#60](https://github.com/randycoulman/mix_test_interactive/pull/60)) 193 | 194 | ## [v1.2.1](https://github.com/randycoulman/mix_test_interactive/compare/v1.2.0...v1.2.1) - 2022-06-01 195 | 196 | ### Fixed 197 | 198 | - Include .heex in list of watched file extensions 199 | ([#57](https://github.com/randycoulman/mix_test_interactive/pull/57) - Thanks @juddey!) 200 | 201 | ## [v1.2.0](https://github.com/randycoulman/mix_test_interactive/compare/v1.1.0...v1.2.0) - 2022-04-07 202 | 203 | ### Changed 204 | 205 | - Now tested against Elixir 1.12 and 1.13. ([#55](https://github.com/randycoulman/mix_test_interactive/pull/55)) 206 | - Misc. dependency upgrades. ([#55](https://github.com/randycoulman/mix_test_interactive/pull/55)) 207 | 208 | ### Documentation 209 | 210 | - Include proper source ref in the generated documentation so that it now points at the correct version of the source code. ([#51](https://github.com/randycoulman/mix_test_interactive/pull/51) - Thanks @kianmeng!) 211 | 212 | - Include license and changelog in generated documentation. ([#51](https://github.com/randycoulman/mix_test_interactive/pull/51) - Thanks @kianmeng!) 213 | 214 | ## [v1.1.0](https://github.com/randycoulman/mix_test_interactive/compare/v1.0.1...v1.1.0) - 2021-10-08 215 | 216 | ### Fixed 217 | 218 | - The `p` (pattern) command now works properly in umbrella projects. Previously, it was unable to find any test files in order to filter the pattern and would therefore not run any tests. Now, in an umbrella project, `mix test.interactive` looks for test files in `apps/*/test` by default, but still respects the `:test_paths` config option used by `mix test`. ([#48](https://github.com/randycoulman/mix_test_interactive/pull/48)) 219 | 220 | ### Documentation 221 | 222 | - Fixed the spelling of Louis Pilfold's name in the README. Sorry, Louis! 🤦‍♂️ ([#49](https://github.com/randycoulman/mix_test_interactive/pull/49)) 223 | 224 | ## [v1.0.1](https://github.com/randycoulman/mix_test_interactive/compare/v1.0.0...v1.0.1) - 2021-03-09 225 | 226 | ### Fixed 227 | 228 | - Eliminates a GenServer call timeout that can occur if a command is typed while a long-running test run is in progress. ([#44](https://github.com/randycoulman/mix_test_interactive/pull/44)). 229 | 230 | ## [v1.0.0](https://github.com/randycoulman/mix_test_interactive/compare/14eb50c742a042de7bfc37c41b8af68d839eb443...v1.0.0) - 2021-02-18 231 | 232 | 🎉 Happy Birthday! 233 | 234 | The following sections describe changes from [mix-test.watch](https://github.com/lpil/mix-test.watch), which served as the basis of this project. 235 | 236 | ### Added 237 | 238 | - Interactive mode allows dynamically filtering test files based on a substring pattern or switching to run only failed or stale tests without having to restart. 239 | 240 | - File-watching mode can be turned on and off, either by passing `--no-watch` on the command line, or by using the `w` command to dynamically toggle watch mode on and off. When file-watching mode is on, tests will be run in response to file changes as with `mix-test.watch`. When off, tests must be run explicitly using the `Enter` key or by using another command that changes the set of tests to be run. 241 | 242 | ### Removed 243 | 244 | - It is no longer possible to customize the CLI executable. We always use `mix`. Previously, this allowed the use of `iex -S mix` instead, but that doesn't work well with interactive mode. 245 | 246 | - It is no longer possible to specify multiple tasks to run on file changes. This ability added complexity and the feature didn't work very well because it assumed that all tasks would take the exact same set of command-line arguments. It is still possible to specify a different task name than `test`, but `mix test.interactive` assumes that the custom task accepts the same command-line arguments as `mix test`. 247 | 248 | ### Fixed 249 | 250 | - On Windows, `mix test.interactive` runs the correct `mix` task, including a custom task provided in the configuration, rather than always running `mix test`. It also passes along other provided command-line arguments as well as those injected by `mix test.interactive`. 251 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 Randy Coulman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mix test.interactive 2 | 3 | [![Build 4 | Status](https://github.com/randycoulman/mix_test_interactive/actions/workflows/ci.yml/badge.svg)](https://github.com/randycoulman/mix_test_interactive/actions) 5 | [![Module 6 | Version](https://img.shields.io/hexpm/v/mix_test_interactive.svg)](https://hex.pm/packages/mix_test_interactive) 7 | [![Hex 8 | Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/mix_test_interactive/) 9 | [![License](https://img.shields.io/hexpm/l/mix_test_interactive.svg)](https://github.com/randycoulman/mix_test_interactive/blob/master/LICENSE.md) 10 | 11 | `mix test.interactive` is an interactive test runner for ExUnit tests. 12 | 13 | Based on Louis Pilfold's wonderful 14 | [mix-test.watch](https://github.com/lpil/mix-test.watch) and inspired by Jest's 15 | interactive watch mode, `mix test.interactive` allows you to dynamically change 16 | which tests should be run with a few keystrokes. 17 | 18 | It allows you to easily switch between running all tests, stale tests, or failed 19 | tests. Or, you can run only the tests whose filenames contain a substring. You 20 | can also control which tags are included or excluded, modify the maximum number 21 | of failures allowed, repeat the test suite until a failure occurs, specify the 22 | test seed to use, and toggle tracing on and off. Includes an optional "watch 23 | mode" which runs tests after every file change. 24 | 25 | ## Installation 26 | 27 | `mix test.interactive` can be added as a dependency to your project, or it can 28 | be run from an Elixir script without being added to your project. 29 | 30 | ### Installing as a Dependency 31 | 32 | To install `mix test.interactive` as a dependency of your project, making it 33 | available to anyone working in the project, add `mix_test_interactive` to the 34 | list of dependencies in your project's `mix.exs` file: 35 | 36 | ```elixir 37 | def deps do 38 | [ 39 | {:mix_test_interactive, "~> 4.3", only: :dev, runtime: false} 40 | ] 41 | end 42 | ``` 43 | 44 | ### Running from an Elixir Script 45 | 46 | If you are working on a 3rd-party project, you may not be able to add 47 | `mix test.interactive` as a dependency. In this case, it is possible 48 | to invoke `mix test.interactive` from an Elixir script. 49 | 50 | To accomplish this, put the following script somewhere on your PATH and make it 51 | executable. 52 | 53 | ```elixir 54 | #!/usr/bin/env elixir 55 | 56 | Mix.install([ 57 | {:mix_test_interactive, "~> 4.1"} 58 | ]) 59 | 60 | MixTestInteractive.run(System.argv()) 61 | ``` 62 | 63 | As an example, let's assume you've named the script `mti_exec`. 64 | 65 | Now you can `cd` to the project's root directory, and run `mti_exec`. The script 66 | will accept all of `mix_test_interactive`'s [command-line options](#options) and 67 | allow you to use any of its [interactive commands](#interactive-commands). 68 | 69 | ## Usage 70 | 71 | ```shell 72 | mix test.interactive [-- ] 73 | mix test.interactive 74 | mix test.interactive --help 75 | mix test.interactive --version 76 | ``` 77 | 78 | Your tests will run immediately (and every time a file changes). 79 | 80 | ### Options 81 | 82 | `mix test.interactive` understands the following options, most of which 83 | correspond to configuration settings below. 84 | 85 | Note that, if you want to pass both mix test.interactive options and mix test 86 | arguments, you must separate them with `--`. 87 | 88 | If an option is provided on the command line, it will override the same option 89 | specified in the configuration. 90 | 91 | - `--(no-)ansi-enabled`: Enable ANSI (colored) output when running tests 92 | (default `false` on Windows; `true` on other platforms). 93 | - `--(no-)clear`: Clear the console before each run (default `false`). 94 | - `--command [--arg ]`: Custom command and arguments for running 95 | tests (default: "mix" with no arguments). NOTE: Use `--arg` multiple times to 96 | specify more than one argument. 97 | - `--exclude `: Exclude files/directories from triggering test runs 98 | (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) NOTE: Use `--exclude` 99 | multiple times to specify more than one regex. 100 | - `--extra-extensions `: Watch files with additional extensions 101 | (default: []). 102 | - `--runner `: Use a custom runner module (default: 103 | `MixTestInteractive.PortRunner`). 104 | - `--task `: Run a different mix task (default: `"test"`). 105 | - `--(no-)timestamp`: Display the current time before running the tests 106 | (default: `false`). 107 | - `--(no-)verbose`: Display the command to be run before running the tests 108 | (default: `false`). 109 | - `--(no-)watch`: Don't run tests when a file changes (default: `true`). 110 | 111 | All of the `` are passed through to `mix test` on every test 112 | run. 113 | 114 | `mix test.interactive` will detect the `--exclude`, `--failed`, `--include`, 115 | `--only`, `--seed`, and `--stale` options and use those as initial settings in 116 | interactive mode. You can then use the interactive mode commands to adjust those 117 | options as needed. It will also detect any filename or pattern arguments and use 118 | those as initial settings. Note that if you specify a pattern on the 119 | command-line, `mix test.interactive` will find all test files matching that 120 | pattern and pass those to `mix test` as if you had used the `p` command. 121 | 122 | ### Patterns and filenames 123 | 124 | `mix test.interactive` can take the same filename or filename:line_number 125 | patterns that `mix test` understands. It also allows you to specify one or more 126 | "patterns" - strings that match one or more test files. When you provide one or 127 | more patterns on the command-line, `mix test.interactive` will find all test 128 | files matching those patterns and pass them to `mix test` as if you had used the 129 | `p` command (described below). 130 | 131 | ## Interactive Commands 132 | 133 | After the tests run, you can use the interactive commands to change which tests 134 | will run. 135 | 136 | - `a`: Run all tests. Clears the `--failed` and `--stale` options as well as 137 | any patterns. 138 | - `d `: Run the tests with a specific seed. 139 | - `d`: Clear any previously specified seed. 140 | - `f`: Run only tests that failed on the last run (equivalent to the 141 | `--failed` option of `mix test`). 142 | - `i `: Include tests tagged with the listed tags (equivalent to the 143 | `--include` option of `mix test`). 144 | - `i`: Clear any included tags. 145 | - `m `: Specify the maximum number of failures allowed (equivalent to the 146 | `--max-failures` option of `mix test`). 147 | - `m`: Clear any previously specified maximum number of failures. 148 | - `o `: Run only tests tagged with the listed tags (equivalent to the 149 | `--only` option of `mix test`). 150 | - `o`: Clear any "only" tags. 151 | - `p`: Run only test files that match one or more provided patterns. A pattern 152 | is the project-root-relative path to a test file (with or without a line 153 | number specification) or a string that matches a portion of full pathname. 154 | e.g. `test/my_project/my_test.exs`, `test/my_project/my_test.exs:12:24` or 155 | `my`. 156 | - `q`: Exit the program. (Can also use `Ctrl-D`.) 157 | - `r `: (Elixir 1.17.0 and later) Run tests up to times until a 158 | failure occurs (equivalent to the `--repeat-until-failure` option of `mix 159 | test`). 160 | - `r`: (Elixir 1.17.0 and later) Clear the "repeat-until-failure" count. 161 | - `s`: Run only test files that reference modules that have changed since the 162 | last run (equivalent to the `--stale` option of `mix test`). 163 | - `t`: Turn test tracing on or off (equivalent to the `--trace` option of `mix 164 | test`). 165 | - `x `: Exclude tests tagged with the listed tags (equivalent to the 166 | `--exclude` option of `mix test`). 167 | - `x`: Clear any excluded tags. 168 | - `w`: Turn file-watching mode on or off. 169 | - `Enter`: Re-run the current set of tests without requiring a file change. 170 | - `?`: Show usage help. 171 | 172 | ## Configuration 173 | 174 | `mix test.interactive` can be configured with various options using application 175 | configuration. You can also use command line arguments to specify these 176 | configuration options, or to override configured options. 177 | 178 | ### `ansi_enabled`: Enable ANSI (colored) output when running tests 179 | 180 | When `ansi_enabled` is set to true, `mix test.interactive` will enable ANSI 181 | output when running tests, allowing for `mix test`'s normal colored output. 182 | 183 | ```elixir 184 | # config/config.exs 185 | import Config 186 | 187 | if Mix.env == :dev do 188 | config :mix_test_interactive, 189 | ansi_enabled: false 190 | end 191 | ``` 192 | 193 | The default is `false` on Windows and `true` on other platforms. 194 | 195 | ### `clear`: Clear the console before each run 196 | 197 | If you want `mix test.interactive` to clear the console before each run, you can 198 | enable this option in your config/dev.exs as follows: 199 | 200 | ```elixir 201 | # config/config.exs 202 | import Config 203 | 204 | if Mix.env == :dev do 205 | config :mix_test_interactive, 206 | clear: true 207 | end 208 | ``` 209 | 210 | ### `command`: Use a custom command 211 | 212 | By default, `mix test.interactive` uses `mix test` to run tests. 213 | 214 | You might want to provide a custom command that does other things before or 215 | after running `mix`. In that case, you can customize the command used for 216 | running tests. 217 | 218 | For example, you might want to provide a name for the test runner process to 219 | allow connection from other Erlang nodes. Or you might want to run other 220 | commands before or after running the tests. 221 | 222 | In those cases, you can customize the command that `mix test.interactive` will 223 | use to run your tests. `mix test.interactive` assumes that the custom command 224 | ultimately runs `mix` under the hood (or at least accepts all of the same 225 | command-line arguments as `mix`). The custom command can either be a string or a 226 | `{command, [..args..]}` tuple. 227 | 228 | Examples: 229 | 230 | ```elixir 231 | # config/config.exs 232 | import Config 233 | 234 | if Mix.env == :dev do 235 | config :mix_test_interactive, 236 | command: "path/to/my/test_runner.sh" 237 | end 238 | ``` 239 | 240 | ```elixir 241 | # config/config.exs 242 | import Config 243 | 244 | if Mix.env == :dev do 245 | config :mix_test_interactive, 246 | command: {"elixir", ["--sname", "name", "-S", "mix"]} 247 | end 248 | ``` 249 | 250 | To run a different mix task instead, see the `task` option below. 251 | 252 | ### `exclude`: Excluding files or directories 253 | 254 | To stop changes to specific files or directories from triggering test runs, you 255 | can add `exclude:` regexp patterns to your config in `mix.exs`: 256 | 257 | ```elixir 258 | # config/config.exs 259 | import Config 260 | 261 | if Mix.env == :dev do 262 | config :mix_test_interactive, 263 | exclude: [~r/db_migration\/.*/, 264 | ~r/useless_.*\.exs/] 265 | end 266 | ``` 267 | 268 | The default is `exclude: [~r/\.#/, ~r{priv/repo/migrations}]`. 269 | 270 | ### `extra_extensions`: Watch files with additional extensions 271 | 272 | By default, `mix test.interactive` will trigger a test run when a known Elixir 273 | or Erlang file has changed, but not when any other file changes. 274 | 275 | You can specify additional file extensions to be included with the 276 | `extra_extensions` option. 277 | 278 | ```elixir 279 | # config/config.exs 280 | import Config 281 | 282 | if Mix.env == :dev do 283 | config :mix_test_interactive, 284 | extra_extensions: ["json"] 285 | end 286 | ``` 287 | 288 | `mix test.interactive` always watches files with the following extensions: 289 | `.erl`, `.ex`, `.exs`, `.eex`, `.leex`, `.heex`, `.xrl`, `.yrl`, and `.hrl`. To 290 | ignore files with any of these extensions, you can specify an `exclude` regexp 291 | (see above). 292 | 293 | ### `runner`: Use a custom runner module 294 | 295 | By default `mix test.interactive` uses an internal module named 296 | `MixTestInteractive.PortRunner` to run the tests. If you want to run the tests 297 | in a different way, you can supply your own runner module instead. Your module 298 | must implement the `MixTestInteractive.TestRunner` behaviour, either implicitly 299 | or explicitly. 300 | 301 | ```elixir 302 | # config/config.exs 303 | import Config 304 | 305 | if Mix.env == :dev do 306 | config :mix_test_interactive, 307 | runner: MyApp.FancyTestRunner 308 | end 309 | ``` 310 | 311 | ### `task`: Run a different mix task 312 | 313 | By default, `mix test.interactive` runs `mix test`. 314 | 315 | Through the mix config it is possible to run a different mix task. `mix 316 | test.interactive` assumes that this alternative task accepts the same 317 | command-line arguments as `mix test`. 318 | 319 | ```elixir 320 | # config/config.exs 321 | import Config 322 | 323 | if Mix.env == :dev do 324 | config :mix_test_interactive, 325 | task: "custom_test_task" 326 | end 327 | ``` 328 | 329 | The task is run with `MIX_ENV` set to `test`. 330 | 331 | To use a custom command instead, see the `command` option above. 332 | 333 | ### `timestamp`: Display the current time before running the tests 334 | 335 | When `timestamp` is set to true, `mix test.interactive` will display the current 336 | time (UTC) just before running the tests. 337 | 338 | ```elixir 339 | # config/config.exs 340 | import Config 341 | 342 | if Mix.env == :dev do 343 | config :mix_test_interactive, 344 | timestamp: true 345 | end 346 | ``` 347 | 348 | ### `verbose`: Display the command to be run before running the tests 349 | 350 | When `verbose` is set to true, `mix test.interactive` will display the command 351 | line it is about to execute just before running the tests. 352 | 353 | ```elixir 354 | # config/config.exs 355 | import Config 356 | 357 | if Mix.env == :dev do 358 | config :mix_test_interactive, 359 | verbose: true 360 | end 361 | ``` 362 | 363 | ## Compatibility Notes 364 | 365 | On Linux you may need to install `inotify-tools`. 366 | 367 | ## Desktop Notifications 368 | 369 | You can enable desktop notifications with 370 | [ex_unit_notifier](https://github.com/navinpeiris/ex_unit_notifier). 371 | 372 | ## Acknowledgements 373 | 374 | This project started as a clone of the wonderful 375 | [mix-test.watch](https://github.com/lpil/mix-test.watch) project, which I've 376 | used and loved for years. I've added the interactive mode features to the 377 | existing feature set. 378 | 379 | The idea for having an interactive mode comes from [Jest](https://jestjs.io/) 380 | and its incredibly useful interactive watch mode. 381 | 382 | ## Copyright and License 383 | 384 | Copyright (c) 2021-2024 Randy Coulman 385 | 386 | This work is free. You can redistribute it and/or modify it under the terms of 387 | the MIT License. See the [LICENSE.md](./LICENSE.md) file for more details. 388 | -------------------------------------------------------------------------------- /lib/mix/tasks/test/interactive.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Test.Interactive do 2 | @shortdoc "Interactively run tests" 3 | @moduledoc """ 4 | Interactive test runner for ExUnit tests. 5 | 6 | `mix test.interactive` allows you to easily switch between running all tests, 7 | stale tests, or failed tests. Or, you can run only the tests whose filenames 8 | contain a substring. You can also control which tags are included or excluded, 9 | modify the maximum number of failures allowed, repeat the test suite until a 10 | failure occurs, specify the test seed to use, and toggle tracing on and off. 11 | Includes an optional "watch mode" which runs tests after every file change. 12 | 13 | ## Usage 14 | 15 | ```shell 16 | mix test.interactive [-- ] 17 | mix test.interactive 18 | mix test.interactive --help 19 | mix test.interactive --version 20 | ``` 21 | 22 | Your tests will run immediately (and every time a file changes). 23 | 24 | ### Options 25 | 26 | `mix test.interactive` understands the following options, most of which 27 | correspond to configuration settings below. 28 | 29 | Note that, if you want to pass both mix test.interactive options and mix test 30 | arguments, you must separate them with `--`. 31 | 32 | If an option is provided on the command line, it will override the same option 33 | specified in the configuration. 34 | 35 | - `--(no-)ansi-enabled`: Enable ANSI (colored) output when running tests 36 | (default `false` on Windows; `true` on other platforms). 37 | - `--(no-)clear`: Clear the console before each run (default `false`). 38 | - `--command [--arg ]`: Custom command and arguments for 39 | running tests (default: "mix" with no arguments). NOTE: Use `--arg` multiple 40 | times to specify more than one argument. 41 | - `--exclude `: Exclude files/directories from triggering test runs 42 | (default: `["~r/\.#/", "~r{priv/repo/migrations}"`]) NOTE: Use `--exclude` 43 | multiple times to specify more than one regex. 44 | - `--extra-extensions `: Watch files with additional extensions 45 | (default: []). 46 | - `--runner `: Use a custom runner module (default: 47 | `MixTestInteractive.PortRunner`). 48 | - `--task `: Run a different mix task (default: `"test"`). 49 | - `--(no-)timestamp`: Display the current time before running the tests 50 | (default: `false`). 51 | - `--(no-)verbose`: Display the command to be run before running the tests 52 | (default: `false`). 53 | - `--(no-)watch`: Don't run tests when a file changes (default: `true`). 54 | 55 | All of the `` are passed through to `mix test` on every 56 | test run. 57 | 58 | `mix test.interactive` will detect the `--exclude`, `--failed`, `--include`, 59 | `--only`, `--seed`, and `--stale` options and use those as initial settings in 60 | interactive mode. You can then use the interactive mode commands to adjust 61 | those options as needed. It will also detect any filename or pattern arguments 62 | and use those as initial settings. Note that if you specify a pattern on the 63 | command-line, `mix test.interactive` will find all test files matching that 64 | pattern and pass those to `mix test` as if you had used the `p` command. 65 | 66 | ### Patterns and filenames 67 | 68 | `mix test.interactive` can take the same filename or filename:line_number 69 | patterns that `mix test` understands. It also allows you to specify one or 70 | more "patterns" - strings that match one or more test files. When you provide 71 | one or more patterns on the command-line, `mix test.interactive` will find all 72 | test files matching those patterns and pass them to `mix test` as if you had 73 | used the `p` command (described below). 74 | 75 | ## Interactive Commands 76 | 77 | After the tests run, you can use the interactive commands to change which 78 | tests will run. 79 | 80 | - `a`: Run all tests. Clears the `--failed` and `--stale` options as well as 81 | any patterns. 82 | - `d `: Run the tests with a specific seed. 83 | - `d`: Clear any previously specified seed. 84 | - `f`: Run only tests that failed on the last run (equivalent to the 85 | `--failed` option of `mix test`). 86 | - `i `: Include tests tagged with the listed tags (equivalent to the 87 | `--include` option of `mix test`). 88 | - `i`: Clear any included tags. 89 | - `m `: Specify the maximum number of failures allowed (equivalent to the 90 | `--max-failures` option of `mix test`). 91 | - `m`: Clear any previously specified maximum number of failures. 92 | - `o `: Run only tests tagged with the listed tags (equivalent to the 93 | `--only` option of `mix test`). 94 | - `o`: Clear any "only" tags. 95 | - `p`: Run only test files that match one or more provided patterns. A pattern 96 | is the project-root-relative path to a test file (with or without a line 97 | number specification) or a string that matches a portion of full pathname. 98 | e.g. `test/my_project/my_test.exs`, `test/my_project/my_test.exs:12:24` or 99 | `my`. 100 | - `q`: Exit the program. (Can also use `Ctrl-D`.) 101 | - `r `: (Elixir 1.17.0 and later) Run tests up to times until a 102 | failure occurs (equivalent to the `--repeat-until-failure` option of `mix 103 | test`). 104 | - `r`: (Elixir 1.17.0 and later) Clear the "repeat-until-failure" count. 105 | - `s`: Run only test files that reference modules that have changed since the 106 | last run (equivalent to the `--stale` option of `mix test`). 107 | - `t`: Turn test tracing on or off (equivalent to the `--trace` option of `mix 108 | test`). 109 | - `x `: Exclude tests tagged with the listed tags (equivalent to the 110 | `--exclude` option of `mix test`). 111 | - `x`: Clear any excluded tags. 112 | - `w`: Turn file-watching mode on or off. 113 | - `Enter`: Re-run the current set of tests without requiring a file change. 114 | - `?`: Show usage help. 115 | 116 | ## Configuration 117 | 118 | If your project has a `config/config.exs` file, you can customize the 119 | operation of `mix test.interactive` with the following settings: 120 | 121 | - `ansi_enabled: true`: Enable ANSI (colored) output when running tests 122 | (default `false` on Windows; `true` on other platforms). 123 | - `clear: true`: Clear the console before each run (default: `false`). 124 | - `command: ` or `command: {, [, ...]}`: Use the 125 | provided command and arguments to run the test task (default: `mix`). 126 | - `exclude: [patterns...]`: A list of `Regex`es to ignore when watching for 127 | changes (default: `[~r/\.#/, ~r{priv/repo/migrations}]`). 128 | - `extra_extensions: [...]`: Additional filename extensions to include 129 | when watching for file changes (default: `[]`). 130 | - `runner: `: A custom runner for running the tests (default: 131 | `MixTestInteractive.PortRunner`). 132 | - `task: `: The mix task to use when running tests (default: 133 | `"test"`). 134 | - `timestamp: true`: Display the current time (UTC) before running the tests 135 | (default: false). 136 | - `verbose: true`: Display the command to be run before running the tests 137 | (default: `false`) 138 | """ 139 | 140 | use Mix.Task 141 | 142 | @preferred_cli_env :test 143 | @requirements ["app.config"] 144 | 145 | defdelegate run(args), to: MixTestInteractive 146 | end 147 | -------------------------------------------------------------------------------- /lib/mix_test_interactive.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive do 2 | @moduledoc """ 3 | Interactively run your Elixir project's tests. 4 | """ 5 | alias MixTestInteractive.CommandLineParser 6 | alias MixTestInteractive.InitialSupervisor 7 | alias MixTestInteractive.InteractiveMode 8 | alias MixTestInteractive.MainSupervisor 9 | 10 | @application :mix_test_interactive 11 | 12 | @doc """ 13 | Start the interactive test runner. 14 | """ 15 | def run(args \\ []) when is_list(args) do 16 | case CommandLineParser.parse(args) do 17 | {:ok, :help} -> 18 | IO.puts(CommandLineParser.usage_message()) 19 | 20 | {:ok, :version} -> 21 | IO.puts("mix test.interactive v#{Application.spec(@application, :vsn)}") 22 | 23 | {:ok, %{config: config, settings: settings}} -> 24 | {:ok, _apps} = Application.ensure_all_started(@application) 25 | 26 | {:ok, _supervisor} = 27 | DynamicSupervisor.start_child(InitialSupervisor, {MainSupervisor, config: config, settings: settings}) 28 | 29 | loop() 30 | 31 | {:error, error} -> 32 | message = [ 33 | :bright, 34 | :red, 35 | Exception.message(error), 36 | :reset, 37 | "\n\nTry `mix test.interactive --help` for more information" 38 | ] 39 | 40 | formatted = IO.ANSI.format(message) 41 | IO.puts(:standard_error, formatted) 42 | exit({:shutdown, 1}) 43 | end 44 | end 45 | 46 | defp loop do 47 | command = IO.gets("") 48 | InteractiveMode.process_command(command) 49 | loop() 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl Application 7 | def start(_type, _args) do 8 | children = [ 9 | {DynamicSupervisor, strategy: :one_for_one, name: MixTestInteractive.InitialSupervisor} 10 | ] 11 | 12 | opts = [strategy: :one_for_one, name: MixTestInteractive.Supervisor] 13 | Supervisor.start_link(children, opts) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command do 2 | @moduledoc """ 3 | Behaviour for interactive mode commands. 4 | 5 | All commands must implement this behaviour. 6 | 7 | It is recommended to `use` this module in the command's module: 8 | 9 | ``` 10 | defmodule MyCommand do 11 | use MixTestInteractive.Command, 12 | command: "c", 13 | desc: "do the thing" 14 | 15 | # ... 16 | end 17 | ``` 18 | 19 | This will provide overridable implementations of most of the callbacks. 20 | 21 | `:command` is the key sequence the user will use to invoke the command. If a more appropriate 22 | command name is required in the help text, you can override the `name/0` callback. 23 | 24 | `:desc` is the command's description. 25 | 26 | `:command` and `:desc` should be written so that the following pattern reads nicely in the 27 | usage output: ` to `. For example, `a to run all tests`. 28 | """ 29 | 30 | alias MixTestInteractive.Settings 31 | 32 | @type response :: {:ok, Settings.t()} | {:no_run, Settings.t()} | :help | :quit | :unknown 33 | 34 | @doc """ 35 | Is the command applicable given the current configuration? 36 | 37 | Returns `true` by default if not overridden. 38 | """ 39 | @callback applies?(Settings.t()) :: boolean() 40 | 41 | @doc """ 42 | The command's description. 43 | 44 | Descriptions should be written to fit the pattern ` to `. 45 | For example, `a to run all tests`. 46 | 47 | Returns the value of the `:desc` argument passed in the `use` statement. 48 | 49 | Not overridable. 50 | """ 51 | @callback description :: String.t() 52 | 53 | @doc """ 54 | The command's key sequence. 55 | 56 | Key sequences should be short (single character preferred) and unique. 57 | 58 | Returns the value of the `:command` argument passed in the `use` statement. 59 | 60 | Not overridable. 61 | """ 62 | @callback command :: String.t() 63 | 64 | @doc """ 65 | The command's name. 66 | 67 | Readable name for the command. 68 | 69 | Defaults to the `command/0`, but can be overridden to make usage output clearer. 70 | """ 71 | @callback name :: String.t() 72 | 73 | @doc """ 74 | Execute the command. 75 | 76 | Performs the desired action in response to the command. 77 | 78 | Most commands return an `:ok` tuple with an updated configuration, allowing 79 | `MixTestInteractive.InteractiveMode` to run the tests with the new configuration. 80 | 81 | A command can return a `:no_run` tuple with an updated configuration if the tests 82 | should not be run in response to the command. 83 | 84 | A command can return `:help` to show detailed usage information, or `:quit` to 85 | exit `mix test.interactive`. 86 | 87 | No default provided. 88 | """ 89 | @callback run([String.t()], Settings.t()) :: response() 90 | 91 | defmacro __using__(opts) do 92 | description = Keyword.fetch!(opts, :desc) 93 | command = Keyword.fetch!(opts, :command) 94 | 95 | quote do 96 | @behaviour MixTestInteractive.Command 97 | 98 | @impl true 99 | def applies?(_settings), do: true 100 | 101 | @impl true 102 | def description, do: unquote(description) 103 | 104 | @impl true 105 | def command, do: unquote(command) 106 | 107 | @impl true 108 | def name, do: unquote(command) 109 | 110 | defoverridable applies?: 1, name: 0 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/all_tests.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.AllTests do 2 | @moduledoc """ 3 | Run all tests, removing any flags or filters. 4 | """ 5 | 6 | use MixTestInteractive.Command, command: "a", desc: "run all tests" 7 | 8 | alias MixTestInteractive.Command 9 | alias MixTestInteractive.Settings 10 | 11 | @impl Command 12 | def applies?(%Settings{failed?: true}), do: true 13 | def applies?(%Settings{patterns: [_h | _t]}), do: true 14 | def applies?(%Settings{stale?: true}), do: true 15 | def applies?(_settings), do: false 16 | 17 | @impl Command 18 | def run(_args, settings) do 19 | {:ok, Settings.all_tests(settings)} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/exclude.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Exclude do 2 | @moduledoc """ 3 | Specify or clear tags to exclude. 4 | 5 | Runs the tests excluding the given tags if provided. If not provided, the 6 | excludes are cleared and the tests will run with any excludes configured in 7 | your `ExUnit.configure/1` call (if any). 8 | """ 9 | use MixTestInteractive.Command, command: "x", desc: "set or clear excluded tags" 10 | 11 | alias MixTestInteractive.Command 12 | alias MixTestInteractive.Settings 13 | 14 | @impl Command 15 | def name, do: "x []" 16 | 17 | @impl Command 18 | def run([], %Settings{} = settings) do 19 | {:ok, Settings.clear_excludes(settings)} 20 | end 21 | 22 | @impl Command 23 | def run(tags, %Settings{} = settings) do 24 | {:ok, Settings.with_excludes(settings, tags)} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/failed.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Failed do 2 | @moduledoc """ 3 | Run only failed tests. 4 | 5 | Equivalent to `mix test --failed`. 6 | """ 7 | 8 | use MixTestInteractive.Command, command: "f", desc: "run only failed tests" 9 | 10 | alias MixTestInteractive.Command 11 | alias MixTestInteractive.Settings 12 | 13 | @impl Command 14 | def applies?(%Settings{failed?: false}), do: true 15 | def applies?(_settings), do: false 16 | 17 | @impl Command 18 | def run(_args, settings) do 19 | {:ok, Settings.only_failed(settings)} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/help.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Help do 2 | @moduledoc """ 3 | Show detailed usage information. 4 | 5 | Lists all commands applicable to the current context. 6 | """ 7 | 8 | use MixTestInteractive.Command, command: "?", desc: "show help" 9 | 10 | alias MixTestInteractive.Command 11 | 12 | @impl Command 13 | def run(_args, _settings), do: :help 14 | end 15 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/include.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Include do 2 | @moduledoc """ 3 | Specify or clear tags to include. 4 | 5 | Runs the tests excluding the given tags if provided. If not provided, the 6 | includes are cleared and the tests will run with any includes configured in 7 | your `ExUnit.configure/1` call (if any). 8 | """ 9 | use MixTestInteractive.Command, command: "i", desc: "set or clear included tags" 10 | 11 | alias MixTestInteractive.Command 12 | alias MixTestInteractive.Settings 13 | 14 | @impl Command 15 | def name, do: "i []" 16 | 17 | @impl Command 18 | def run([], %Settings{} = settings) do 19 | {:ok, Settings.clear_includes(settings)} 20 | end 21 | 22 | @impl Command 23 | def run(tags, %Settings{} = settings) do 24 | {:ok, Settings.with_includes(settings, tags)} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/max_failures.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.MaxFailures do 2 | @moduledoc """ 3 | Specify or clear the maximum number of failures during a test run. 4 | 5 | Runs the tests with the given maximum failures if provided. If not provided, 6 | the max is cleared and the tests will run until completion as usual. 7 | """ 8 | use MixTestInteractive.Command, command: "m", desc: "set or clear the maximum number of failures" 9 | 10 | alias MixTestInteractive.Command 11 | alias MixTestInteractive.Settings 12 | 13 | @impl Command 14 | def name, do: "m []" 15 | 16 | @impl Command 17 | def run([], %Settings{} = settings) do 18 | {:ok, Settings.clear_max_failures(settings)} 19 | end 20 | 21 | @impl Command 22 | def run([max], %Settings{} = settings) do 23 | {:ok, Settings.with_max_failures(settings, max)} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/only.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Only do 2 | @moduledoc """ 3 | Specify or clear the only tags to run. 4 | 5 | Runs the tests with only the given tags if provided. If not provided, the list 6 | of only tags is cleared and the tests will run with any only configured in 7 | your `ExUnit.configure/1` call (if any). 8 | """ 9 | use MixTestInteractive.Command, command: "o", desc: "set or clear only tags" 10 | 11 | alias MixTestInteractive.Command 12 | alias MixTestInteractive.Settings 13 | 14 | @impl Command 15 | def name, do: "o []" 16 | 17 | @impl Command 18 | def run([], %Settings{} = settings) do 19 | {:ok, Settings.clear_only(settings)} 20 | end 21 | 22 | @impl Command 23 | def run(tags, %Settings{} = settings) do 24 | {:ok, Settings.with_only(settings, tags)} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/pattern.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Pattern do 2 | @moduledoc """ 3 | Specify one or more filename-matching patterns. 4 | 5 | Runs all tests that contain at least one of the patterns as a substring of their filename. 6 | 7 | If any pattern looks like a filename/line number pattern, then all of the patterns 8 | are passed on to `mix test` directly; no filtering is done. This is because `mix test` 9 | only supports a single filename-with-line-number pattern at a time. 10 | """ 11 | 12 | use MixTestInteractive.Command, command: "p", desc: "run only test files matching pattern(s)" 13 | 14 | alias MixTestInteractive.Command 15 | alias MixTestInteractive.Settings 16 | 17 | @impl Command 18 | def name, do: "p " 19 | 20 | @impl Command 21 | def run(patterns, settings) do 22 | {:ok, Settings.only_patterns(settings, patterns)} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/quit.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Quit do 2 | @moduledoc """ 3 | Exit mix test.interactive. 4 | """ 5 | 6 | use MixTestInteractive.Command, command: "q", desc: "quit" 7 | 8 | alias MixTestInteractive.Command 9 | 10 | @impl Command 11 | def run(_args, _settings), do: :quit 12 | end 13 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/repeat_until_failure.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.RepeatUntilFailure do 2 | @moduledoc """ 3 | Specify or clear the number of repetitions for running until failure. 4 | 5 | Runs the tests repeatedly until failure or until the specified number of runs. 6 | If not provided, the count is cleared and the tests will run just once as 7 | usual. 8 | 9 | Corresponds to `mix test --repeat-until-failure `. 10 | 11 | This option is only available in `mix test` in Elixir 1.17.0 and later. 12 | """ 13 | use MixTestInteractive.Command, command: "r", desc: "set or clear the repeat-until-failure count" 14 | 15 | alias MixTestInteractive.Command 16 | alias MixTestInteractive.Settings 17 | 18 | @impl Command 19 | def name, do: "r []" 20 | 21 | @impl Command 22 | def run([], %Settings{} = settings) do 23 | {:ok, Settings.clear_repeat_count(settings)} 24 | end 25 | 26 | @impl Command 27 | def run([count], %Settings{} = settings) do 28 | {:ok, Settings.with_repeat_count(settings, count)} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/run_tests.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.RunTests do 2 | @moduledoc """ 3 | Run all tests matching the current flags and filter settings. 4 | """ 5 | 6 | use MixTestInteractive.Command, command: "", desc: "trigger a test run" 7 | 8 | alias MixTestInteractive.Command 9 | 10 | @impl Command 11 | def name, do: "Enter" 12 | 13 | @impl Command 14 | def run(_args, settings), do: {:ok, settings} 15 | end 16 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/seed.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Seed do 2 | @moduledoc """ 3 | Specify or clear the random number seed for test runs. 4 | 5 | Runs the tests with the given seed if provided. If not provided, the seed is 6 | cleared and the tests will run with a random seed as usual. 7 | """ 8 | use MixTestInteractive.Command, command: "d", desc: "set or clear the test seed" 9 | 10 | alias MixTestInteractive.Command 11 | alias MixTestInteractive.Settings 12 | 13 | @impl Command 14 | def name, do: "d []" 15 | 16 | @impl Command 17 | def run([], %Settings{} = settings) do 18 | {:ok, Settings.clear_seed(settings)} 19 | end 20 | 21 | @impl Command 22 | def run([seed], %Settings{} = settings) do 23 | {:ok, Settings.with_seed(settings, seed)} 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/stale.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.Stale do 2 | @moduledoc """ 3 | Run only stale tests. 4 | 5 | Equivalent to `mix test --stale`. 6 | """ 7 | 8 | use MixTestInteractive.Command, command: "s", desc: "run only stale tests" 9 | 10 | alias MixTestInteractive.Command 11 | alias MixTestInteractive.Settings 12 | 13 | @impl Command 14 | def applies?(%Settings{stale?: false}), do: true 15 | def applies?(_settings), do: false 16 | 17 | @impl Command 18 | def run(_args, settings) do 19 | {:ok, Settings.only_stale(settings)} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/toggle_tracing.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.ToggleTracing do 2 | @moduledoc """ 3 | Toggle test tracing on or off. 4 | 5 | Runs the tests in trace mode when tracing is on and normally when off. 6 | 7 | Corresponds to `mix test --trace`. 8 | """ 9 | 10 | use MixTestInteractive.Command, command: "t", desc: "turn test tracing on/off" 11 | 12 | alias MixTestInteractive.Command 13 | alias MixTestInteractive.Settings 14 | 15 | @impl Command 16 | def run(_args, settings) do 17 | {:ok, Settings.toggle_tracing(settings)} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command/toggle_watch_mode.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Command.ToggleWatchMode do 2 | @moduledoc """ 3 | Toggle file-watching mode on or off. 4 | 5 | When watch mode is on, `mix test.interactive` will re-run the 6 | current set of tests whenever a file changes. 7 | 8 | When it is off, tests must be run manually, either by changing 9 | using a command to change the configuration, or by using the 10 | `MixTestInteractive.Command.RunTests` command. 11 | """ 12 | 13 | use MixTestInteractive.Command, command: "w", desc: "turn watch mode on/off" 14 | 15 | alias MixTestInteractive.Command 16 | alias MixTestInteractive.Settings 17 | 18 | @impl Command 19 | def run(_args, settings) do 20 | {:no_run, Settings.toggle_watch_mode(settings)} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command_line_formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandLineFormatter do 2 | @moduledoc false 3 | 4 | @special_chars ~r/[\s&|;<>*?()\[\]{}$`'"]/ 5 | @whitespace ~r/\s/ 6 | 7 | def call(command, args) do 8 | Enum.map_join([command | args], " ", &format_argument/1) 9 | end 10 | 11 | defp format_argument(arg) do 12 | if arg =~ @special_chars do 13 | quote_argument(arg) 14 | else 15 | arg 16 | end 17 | end 18 | 19 | defp quote_argument(arg) do 20 | cond do 21 | # Prefer double quotes for arguments with only spaces or special characters 22 | String.match?(arg, @whitespace) and not String.contains?(arg, ~s(")) -> 23 | ~s("#{arg}") 24 | 25 | # Use single quotes if the argument contains double quotes but no single quotes 26 | String.contains?(arg, ~s(")) and not String.contains?(arg, "'") -> 27 | ~s('#{arg}') 28 | 29 | # Escape single quotes using the '"'"' trick if both quotes are present 30 | String.contains?(arg, "'") -> 31 | ~s('#{String.replace(arg, "'", "'\\''")}') 32 | 33 | # Default to double quotes for other special characters 34 | true -> 35 | ~s("#{arg}") 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command_line_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandLineParser do 2 | @moduledoc false 3 | 4 | use TypedStruct 5 | 6 | alias MixTestInteractive.Config 7 | alias MixTestInteractive.Settings 8 | alias OptionParser.ParseError 9 | 10 | defmodule UsageError do 11 | @moduledoc false 12 | defexception [:message] 13 | 14 | @type t :: %__MODULE__{ 15 | message: String.t() 16 | } 17 | 18 | def exception(other) when is_exception(other) do 19 | exception(Exception.message(other)) 20 | end 21 | 22 | def exception(opts), do: super(opts) 23 | end 24 | 25 | @options [ 26 | ansi_enabled: :boolean, 27 | arg: :keep, 28 | clear: :boolean, 29 | command: :string, 30 | exclude: :keep, 31 | extra_extensions: :keep, 32 | help: :boolean, 33 | runner: :string, 34 | task: :string, 35 | timestamp: :boolean, 36 | verbose: :boolean, 37 | version: :boolean, 38 | watch: :boolean 39 | ] 40 | 41 | @usage """ 42 | Usage: 43 | mix test.interactive [-- ] 44 | mix test.interactive 45 | mix test.interactive --help 46 | mix test.interactive --version 47 | 48 | where: 49 | : 50 | --(no-)ansi-enabled Enable ANSI (colored) output when running tests 51 | (default `false` on Windows; `true` on other 52 | platforms). 53 | --(no-)clear Clear the console before each run 54 | (default: `false`) 55 | --command /--arg Custom command and arguments for running 56 | tests (default: `"mix"` with no args) 57 | NOTE: Use `--arg` multiple times to 58 | specify more than one argument 59 | --exclude Exclude files/directories from triggering 60 | test runs (default: 61 | `["~r/\.#/", "~r{priv/repo/migrations}"`]) 62 | NOTE: Use `--exclude` multiple times to 63 | specify more than one regex 64 | --extra-extensions Watch files with additional extensions 65 | (default: []) 66 | NOTE: Use `--extra-extensions` multiple 67 | times to specify more than one extension. 68 | --runner Use a custom runner module 69 | (default: `MixTestInteractive.PortRunner`) 70 | --task Run a different mix task 71 | (default: `"test"`) 72 | --(no-)timestamp Display the current time before running 73 | the tests (default: `false`) 74 | --(no-)verbose Display the command to be run before 75 | running the tests (default: `false`) 76 | --(no-)watch Run tests when a watched file changes 77 | (default: `true`) 78 | 79 | : 80 | any arguments accepted by `mix test` 81 | """ 82 | 83 | @mix_test_options [ 84 | all_warnings: :boolean, 85 | archives_check: :boolean, 86 | breakpoints: :boolean, 87 | color: :boolean, 88 | compile: :boolean, 89 | cover: :boolean, 90 | deps_check: :boolean, 91 | elixir_version_check: :boolean, 92 | exclude: :keep, 93 | exit_status: :integer, 94 | export_coverage: :string, 95 | failed: :boolean, 96 | force: :boolean, 97 | formatter: :keep, 98 | include: :keep, 99 | listen_on_stdin: :boolean, 100 | max_cases: :integer, 101 | max_failures: :integer, 102 | only: :keep, 103 | partitions: :integer, 104 | preload_modules: :boolean, 105 | profile_require: :string, 106 | raise: :boolean, 107 | repeat_until_failure: :integer, 108 | seed: :integer, 109 | slowest: :integer, 110 | slowest_modules: :integer, 111 | stale: :boolean, 112 | start: :boolean, 113 | timeout: :integer, 114 | trace: :boolean, 115 | warnings_as_errors: :boolean 116 | ] 117 | 118 | @mix_test_aliases [ 119 | b: :breakpoints 120 | ] 121 | 122 | @type parse_result :: {:ok, %{config: Config.t(), settings: Settings.t()} | :help | :version} | {:error, UsageError.t()} 123 | 124 | @spec parse([String.t()]) :: parse_result() 125 | def parse(cli_args \\ []) do 126 | with {:ok, mti_opts, mix_test_args} <- parse_mti_args(cli_args), 127 | {:ok, mix_test_opts, patterns} <- parse_mix_test_args(mix_test_args) do 128 | cond do 129 | Keyword.get(mti_opts, :help, false) -> 130 | {:ok, :help} 131 | 132 | Keyword.get(mti_opts, :version, false) -> 133 | {:ok, :version} 134 | 135 | true -> 136 | with {:ok, config} <- build_config(mti_opts) do 137 | settings = build_settings(mti_opts, mix_test_opts, patterns) 138 | {:ok, %{config: config, settings: settings}} 139 | end 140 | end 141 | end 142 | end 143 | 144 | @spec usage_message :: String.t() 145 | def usage_message, do: @usage 146 | 147 | defp build_config(mti_opts) do 148 | config = 149 | mti_opts 150 | |> Enum.reduce(Config.load_from_environment(), fn 151 | {:ansi_enabled, enabled?}, config -> %{config | ansi_enabled?: enabled?} 152 | {:clear, clear?}, config -> %{config | clear?: clear?} 153 | {:exclude, excludes}, config -> %{config | exclude: excludes} 154 | {:extra_extensions, extra_extensions}, config -> %{config | extra_extensions: extra_extensions} 155 | {:runner, runner}, config -> %{config | runner: runner} 156 | {:timestamp, show_timestamp?}, config -> %{config | show_timestamp?: show_timestamp?} 157 | {:task, task}, config -> %{config | task: task} 158 | {:verbose, verbose}, config -> %{config | verbose?: verbose} 159 | _pair, config -> config 160 | end) 161 | |> handle_custom_command(mti_opts) 162 | 163 | {:ok, config} 164 | end 165 | 166 | defp handle_custom_command(%Config{} = config, mti_opts) do 167 | case Keyword.fetch(mti_opts, :command) do 168 | {:ok, command} -> %{config | command: {command, Keyword.get(mti_opts, :arg, [])}} 169 | :error -> config 170 | end 171 | end 172 | 173 | defp build_settings(mti_opts, mix_test_opts, patterns) do 174 | no_patterns? = Enum.empty?(patterns) 175 | {excludes, mix_test_opts} = Keyword.pop_values(mix_test_opts, :exclude) 176 | {failed?, mix_test_opts} = Keyword.pop(mix_test_opts, :failed, false) 177 | {includes, mix_test_opts} = Keyword.pop_values(mix_test_opts, :include) 178 | {only, mix_test_opts} = Keyword.pop_values(mix_test_opts, :only) 179 | {max_failures, mix_test_opts} = Keyword.pop(mix_test_opts, :max_failures) 180 | {repeat_count, mix_test_opts} = Keyword.pop(mix_test_opts, :repeat_until_failure) 181 | {seed, mix_test_opts} = Keyword.pop(mix_test_opts, :seed) 182 | {stale?, mix_test_opts} = Keyword.pop(mix_test_opts, :stale, false) 183 | {trace?, mix_test_opts} = Keyword.pop(mix_test_opts, :trace, false) 184 | watching? = Keyword.get(mti_opts, :watch, true) 185 | 186 | %Settings{ 187 | excludes: excludes, 188 | failed?: no_patterns? && failed?, 189 | includes: includes, 190 | initial_cli_args: OptionParser.to_argv(mix_test_opts), 191 | max_failures: max_failures && to_string(max_failures), 192 | only: only, 193 | patterns: patterns, 194 | repeat_count: repeat_count && to_string(repeat_count), 195 | seed: seed && to_string(seed), 196 | stale?: no_patterns? && !failed? && stale?, 197 | tracing?: trace?, 198 | watching?: watching? 199 | } 200 | end 201 | 202 | defp parse_mix_test_args(mix_test_args) do 203 | {mix_test_opts, patterns} = 204 | OptionParser.parse!(mix_test_args, aliases: @mix_test_aliases, switches: @mix_test_options) 205 | 206 | {:ok, mix_test_opts, patterns} 207 | end 208 | 209 | defp parse_mti_args(cli_args) do 210 | with {:ok, mti_opts, mix_test_args} <- parse_mti_args_raw(cli_args), 211 | {:ok, parsed} <- parse_mti_option_values(mti_opts) do 212 | {:ok, combine_multiples(parsed), mix_test_args} 213 | end 214 | end 215 | 216 | defp parse_mti_args_raw(cli_args) do 217 | case Enum.find_index(cli_args, &(&1 == "--")) do 218 | nil -> 219 | case try_parse_as_mti_args(cli_args) do 220 | {:ok, mti_opts} -> {:ok, mti_opts, []} 221 | {:error, :maybe_mix_test_args} -> {:ok, [], cli_args} 222 | {:error, error} -> {:error, error} 223 | end 224 | 225 | index -> 226 | mti_args = Enum.take(cli_args, index) 227 | 228 | with {:ok, mti_opts} <- force_parse_as_mti_args(mti_args) do 229 | mix_test_args = Enum.drop(cli_args, index + 1) 230 | {:ok, mti_opts, mix_test_args} 231 | end 232 | end 233 | end 234 | 235 | defp force_parse_as_mti_args(args) do 236 | {mti_opts, _args} = OptionParser.parse!(args, strict: @options) 237 | {:ok, mti_opts} 238 | rescue 239 | error in ParseError -> {:error, UsageError.exception(error)} 240 | end 241 | 242 | defp try_parse_as_mti_args(args) do 243 | {mti_opts, patterns, invalid} = OptionParser.parse(args, strict: @options) 244 | 245 | cond do 246 | invalid == [] and patterns == [] -> {:ok, mti_opts} 247 | mti_opts[:help] || mti_opts[:version] -> {:ok, mti_opts} 248 | mti_opts == [] -> {:error, :maybe_mix_test_args} 249 | true -> force_parse_as_mti_args(args) 250 | end 251 | end 252 | 253 | defp parse_mti_option_values(mti_opts) do 254 | mti_opts 255 | |> Enum.reduce_while([], fn {name, value}, acc -> 256 | case parse_one_option_value(name, value) do 257 | {:ok, parsed} -> {:cont, [{name, parsed} | acc]} 258 | error -> {:halt, error} 259 | end 260 | end) 261 | |> case do 262 | {:error, _error} = error -> error 263 | new_opts -> {:ok, Enum.reverse(new_opts)} 264 | end 265 | end 266 | 267 | defp parse_one_option_value(:exclude, exclude) do 268 | {:ok, Regex.compile!(exclude)} 269 | rescue 270 | error -> 271 | {:error, UsageError.exception("--exclude '#{exclude}': #{Exception.message(error)}")} 272 | end 273 | 274 | defp parse_one_option_value(:runner, runner) do 275 | module = runner |> String.split(".") |> Module.concat() 276 | 277 | if function_exported?(module, :run, 2) do 278 | {:ok, module} 279 | else 280 | {:error, 281 | UsageError.exception( 282 | "--runner: '#{runner}' must name a module that implements the `MixTestInteractive.TestRunner` behaviour" 283 | )} 284 | end 285 | end 286 | 287 | defp parse_one_option_value(_name, value), do: {:ok, value} 288 | 289 | defp combine_multiples(opts) do 290 | @options 291 | |> Enum.filter(fn {_name, type} -> type == :keep end) 292 | |> Enum.reduce(opts, fn {name, _type}, acc -> 293 | case Keyword.pop_values(acc, name) do 294 | {[], _new_opts} -> acc 295 | {values, new_opts} -> Keyword.put(new_opts, name, values) 296 | end 297 | end) 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/command_processor.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandProcessor do 2 | @moduledoc """ 3 | Processes interactive mode commands. 4 | """ 5 | 6 | alias MixTestInteractive.Command 7 | alias MixTestInteractive.Command.AllTests 8 | alias MixTestInteractive.Command.Exclude 9 | alias MixTestInteractive.Command.Failed 10 | alias MixTestInteractive.Command.Help 11 | alias MixTestInteractive.Command.Include 12 | alias MixTestInteractive.Command.MaxFailures 13 | alias MixTestInteractive.Command.Only 14 | alias MixTestInteractive.Command.Pattern 15 | alias MixTestInteractive.Command.Quit 16 | alias MixTestInteractive.Command.RepeatUntilFailure 17 | alias MixTestInteractive.Command.RunTests 18 | alias MixTestInteractive.Command.Seed 19 | alias MixTestInteractive.Command.Stale 20 | alias MixTestInteractive.Command.ToggleTracing 21 | alias MixTestInteractive.Command.ToggleWatchMode 22 | alias MixTestInteractive.Settings 23 | 24 | @type response :: Command.response() 25 | 26 | @commands [ 27 | AllTests, 28 | Exclude, 29 | Failed, 30 | Help, 31 | Include, 32 | MaxFailures, 33 | Only, 34 | Pattern, 35 | Quit, 36 | RepeatUntilFailure, 37 | RunTests, 38 | Seed, 39 | Stale, 40 | ToggleTracing, 41 | ToggleWatchMode 42 | ] 43 | 44 | @doc """ 45 | Processes a single interactive mode command. 46 | """ 47 | @spec call(String.t() | :eof, Settings.t()) :: response() 48 | def call(:eof, _settings), do: :quit 49 | 50 | def call(command_line, settings) when is_binary(command_line) do 51 | case String.split(command_line) do 52 | [] -> process_command("", [], settings) 53 | [command | args] -> process_command(command, args, settings) 54 | end 55 | end 56 | 57 | @doc """ 58 | Returns an ANSI-formatted usage summary. 59 | 60 | Includes only commands that are applicable to the current configuration. 61 | """ 62 | @spec usage(Settings.t()) :: IO.chardata() 63 | def usage(settings) do 64 | usage = 65 | settings 66 | |> applicable_commands() 67 | |> Enum.sort_by(& &1.command()) 68 | |> Enum.flat_map(&usage_line/1) 69 | 70 | IO.ANSI.format([:bright, "Usage:\n", :normal] ++ usage) 71 | end 72 | 73 | defp usage_line(command) do 74 | IO.ANSI.format_fragment(["› ", :bright, command.name(), :normal, " to ", command.description(), ".\n"]) 75 | end 76 | 77 | defp process_command(command, args, settings) do 78 | case settings 79 | |> applicable_commands() 80 | |> Enum.find(nil, &(&1.command() == command)) do 81 | nil -> :unknown 82 | cmd -> cmd.run(args, settings) 83 | end 84 | end 85 | 86 | defp applicable_commands(settings) do 87 | Enum.filter(@commands, & &1.applies?(settings)) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/config.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Config do 2 | @moduledoc """ 3 | Configuration for the task. 4 | """ 5 | use TypedStruct 6 | 7 | @application :mix_test_interactive 8 | 9 | typedstruct do 10 | field :ansi_enabled?, boolean() 11 | field :clear?, boolean(), default: false 12 | field :command, {String.t(), [String.t()]}, default: {"mix", []} 13 | field :exclude, [Regex.t()], default: [~r/\.#/, ~r{priv/repo/migrations}] 14 | field :extra_extensions, [String.t()], default: [] 15 | field :runner, module(), default: MixTestInteractive.PortRunner 16 | field :show_timestamp?, boolean(), default: false 17 | field :task, String.t(), default: "test" 18 | field :verbose?, boolean(), default: false 19 | end 20 | 21 | @doc """ 22 | Create a new config struct, taking values from the application environment. 23 | """ 24 | @spec load_from_environment :: t() 25 | def load_from_environment do 26 | new() 27 | |> load(:ansi_enabled, rename: :ansi_enabled?) 28 | |> load(:clear, rename: :clear?) 29 | |> load(:command, transform: &parse_command/1) 30 | |> load(:exclude) 31 | |> load(:extra_extensions) 32 | |> load(:runner) 33 | |> load(:timestamp, rename: :show_timestamp?) 34 | |> load(:task) 35 | |> load(:verbose, rename: :verbose?) 36 | end 37 | 38 | @doc false 39 | def new do 40 | os_type = ProcessTree.get(:os_type, default: :os.type()) 41 | 42 | default_ansi_enabled(%__MODULE__{}, os_type) 43 | end 44 | 45 | defp load(%__MODULE__{} = config, app_key, opts \\ []) do 46 | config_key = Keyword.get(opts, :rename, app_key) 47 | transform = Keyword.get(opts, :transform, & &1) 48 | 49 | case config(app_key) do 50 | {:ok, value} -> Map.put(config, config_key, transform.(value)) 51 | :error -> config 52 | end 53 | end 54 | 55 | defp config(key) do 56 | case ProcessTree.get(key) do 57 | nil -> Application.fetch_env(@application, key) 58 | value -> {:ok, value} 59 | end 60 | end 61 | 62 | defp default_ansi_enabled(%__MODULE__{} = config, {:win32, _os_name} = _os_type) do 63 | %{config | ansi_enabled?: false} 64 | end 65 | 66 | defp default_ansi_enabled(%__MODULE__{} = config, _os_type) do 67 | %{config | ansi_enabled?: true} 68 | end 69 | 70 | defp parse_command({cmd, args} = command) when is_binary(cmd) and is_list(args), do: command 71 | defp parse_command(command) when is_binary(command), do: {command, []} 72 | 73 | defp parse_command(_invalid_command), 74 | do: raise(ArgumentError, "command must be a binary or a {command, [arg, ...]} tuple") 75 | end 76 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/interactive_mode.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.InteractiveMode do 2 | @moduledoc """ 3 | Server for interactive mode. 4 | 5 | Processes commands from the user and requests to run tests due to file changes. 6 | This ensures that commands cannot be processed while tests are already running. 7 | 8 | Any commands that come in while the tests are running will be processed once the 9 | test run has completed. 10 | """ 11 | 12 | use GenServer, restart: :transient 13 | 14 | alias MixTestInteractive.CommandProcessor 15 | alias MixTestInteractive.Config 16 | alias MixTestInteractive.Runner 17 | alias MixTestInteractive.RunSummary 18 | alias MixTestInteractive.Settings 19 | 20 | @type option :: {:config, Config.t()} | {:name | String.t()} 21 | 22 | @doc """ 23 | Start the interactive mode server. 24 | """ 25 | @spec start_link([option]) :: GenServer.on_start() 26 | def start_link(options) do 27 | name = Keyword.get(options, :name, __MODULE__) 28 | config = Keyword.fetch!(options, :config) 29 | settings = Keyword.fetch!(options, :settings) 30 | initial_state = %{config: config, settings: settings} 31 | GenServer.start_link(__MODULE__, initial_state, name: name) 32 | end 33 | 34 | @doc """ 35 | Process a command from the user. 36 | """ 37 | @spec process_command(String.t()) :: :ok 38 | @spec process_command(GenServer.server(), String.t()) :: :ok 39 | def process_command(server \\ __MODULE__, command) do 40 | GenServer.cast(server, {:command, command}) 41 | end 42 | 43 | @doc """ 44 | Tell InteractiveMode that one or more files have changed. 45 | """ 46 | @spec note_file_changed() :: :ok 47 | @spec note_file_changed(GenServer.server()) :: :ok 48 | def note_file_changed(server \\ __MODULE__) do 49 | GenServer.call(server, :note_file_changed, :infinity) 50 | end 51 | 52 | @impl GenServer 53 | def init(initial_state) do 54 | {:ok, initial_state, {:continue, :run_tests}} 55 | end 56 | 57 | @impl GenServer 58 | def handle_call(:note_file_changed, _from, state) do 59 | if state.settings.watching? do 60 | run_tests(state) 61 | end 62 | 63 | {:reply, :ok, state} 64 | end 65 | 66 | @impl GenServer 67 | def handle_cast({:command, command}, state) do 68 | case CommandProcessor.call(command, state.settings) do 69 | {:ok, new_settings} -> 70 | {:noreply, %{state | settings: new_settings}, {:continue, :run_tests}} 71 | 72 | {:no_run, new_settings} -> 73 | show_usage_prompt(new_settings) 74 | {:noreply, %{state | settings: new_settings}} 75 | 76 | :help -> 77 | show_help(state.settings) 78 | {:noreply, state} 79 | 80 | :unknown -> 81 | {:noreply, state} 82 | 83 | :quit -> 84 | IO.puts("Shutting down...") 85 | System.stop(0) 86 | {:stop, :shutdown, state} 87 | end 88 | end 89 | 90 | @impl GenServer 91 | def handle_continue(:run_tests, state) do 92 | run_tests(state) 93 | {:noreply, state} 94 | end 95 | 96 | defp run_tests(%{config: config, settings: settings}) do 97 | :ok = do_run_tests(config, settings) 98 | show_summary(settings) 99 | show_usage_prompt(settings) 100 | end 101 | 102 | defp do_run_tests(config, settings) do 103 | with {:ok, args} <- Settings.cli_args(settings), 104 | :ok <- Runner.run(config, args) do 105 | :ok 106 | else 107 | {:error, :no_matching_files} -> 108 | [:red, "No matching tests found"] 109 | |> IO.ANSI.format() 110 | |> IO.puts() 111 | 112 | :ok 113 | end 114 | end 115 | 116 | defp show_summary(settings) do 117 | IO.puts("") 118 | 119 | settings 120 | |> RunSummary.from_settings() 121 | |> IO.puts() 122 | end 123 | 124 | defp show_usage_prompt(settings) do 125 | IO.puts("") 126 | 127 | if settings.watching? do 128 | IO.puts("Watching for file changes...") 129 | else 130 | IO.puts("Ignoring file changes") 131 | end 132 | 133 | IO.puts("") 134 | 135 | [:bright, "Usage: ?", :normal, " to show more"] 136 | |> IO.ANSI.format() 137 | |> IO.puts() 138 | end 139 | 140 | defp show_help(settings) do 141 | IO.puts("") 142 | 143 | settings 144 | |> CommandProcessor.usage() 145 | |> IO.puts() 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/main_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.MainSupervisor do 2 | @moduledoc false 3 | use Supervisor 4 | 5 | alias MixTestInteractive.InteractiveMode 6 | alias MixTestInteractive.Watcher 7 | 8 | def start_link(opts) do 9 | Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 10 | end 11 | 12 | @impl Supervisor 13 | def init(opts) do 14 | config = Keyword.fetch!(opts, :config) 15 | settings = Keyword.fetch!(opts, :settings) 16 | 17 | children = [ 18 | {InteractiveMode, config: config, settings: settings}, 19 | {Watcher, config: config} 20 | ] 21 | 22 | Supervisor.init(children, strategy: :one_for_one) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/message_inbox.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.MessageInbox do 2 | @moduledoc false 3 | 4 | @spec flush :: :ok 5 | def flush do 6 | receive do 7 | _ -> flush() 8 | after 9 | 0 -> :ok 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/paths.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Paths do 2 | @moduledoc false 3 | 4 | alias MixTestInteractive.Config 5 | 6 | @elixir_source_endings ~w(.erl .ex .exs .eex .leex .heex .xrl .yrl .hrl) 7 | @ignored_dirs ~w(deps/ _build/) 8 | 9 | @doc """ 10 | Determines if we should respond to changes in a file. 11 | """ 12 | @spec watching?(String.t(), Config.t()) :: boolean 13 | def watching?(path, config) do 14 | watched_directory?(path) and elixir_extension?(path, config.extra_extensions) and 15 | not excluded?(config, path) 16 | end 17 | 18 | defp excluded?(config, path) do 19 | config.exclude 20 | |> Enum.map(fn pattern -> Regex.match?(pattern, path) end) 21 | |> Enum.any?() 22 | end 23 | 24 | defp watched_directory?(path) do 25 | not String.starts_with?(path, @ignored_dirs) 26 | end 27 | 28 | defp elixir_extension?(path, extra_extensions) do 29 | String.ends_with?(path, @elixir_source_endings ++ extra_extensions) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/pattern_filter.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.PatternFilter do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Filters filenames based on one or more patterns. 6 | 7 | Returns all filenames that contain at least one of the patterns 8 | as a substring. 9 | 10 | If any pattern looks like a filename/line number pattern, then no 11 | filtering is done and the pattern(s) are returned as-is. This is 12 | because `mix test` only allows a single filename/line number pattern 13 | at a time. 14 | """ 15 | @spec matches([String.t()], String.t() | [String.t()]) :: [String.t()] 16 | def matches(files, pattern) when is_binary(pattern) do 17 | matches(files, [pattern]) 18 | end 19 | 20 | def matches(files, patterns) do 21 | {with_line_number, simple} = Enum.split_with(patterns, &is_line_number_pattern?/1) 22 | 23 | files 24 | |> Enum.filter(&String.contains?(&1, simple)) 25 | |> Kernel.++(with_line_number) 26 | end 27 | 28 | defp is_line_number_pattern?(pattern) do 29 | case ExUnit.Filters.parse_path(pattern) do 30 | {_path, []} -> false 31 | _ -> true 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/port_runner.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.PortRunner do 2 | @moduledoc """ 3 | Run the tasks in a new OS process via `Port`s. 4 | 5 | On Unix-like operating systems, it runs the tests using a `zombie_killer` script 6 | as described in https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes. 7 | It also enables ANSI output mode. 8 | 9 | On Windows, `mix` is run directly and ANSI mode is not enabled, as it is not always 10 | supported by Windows command processors. 11 | """ 12 | @behaviour MixTestInteractive.TestRunner 13 | 14 | alias MixTestInteractive.CommandLineFormatter 15 | alias MixTestInteractive.Config 16 | alias MixTestInteractive.TestRunner 17 | 18 | @application :mix_test_interactive 19 | 20 | @type runner :: 21 | (String.t(), [String.t()], keyword() -> 22 | {Collectable.t(), exit_status :: non_neg_integer()}) 23 | 24 | @doc """ 25 | Run tests based on the current configuration. 26 | """ 27 | @impl TestRunner 28 | def run(%Config{} = config, task_args, runner \\ &System.cmd/3) do 29 | {command, command_args} = config.command 30 | task = if config.ansi_enabled?, do: enable_ansi(config.task), else: [config.task] 31 | 32 | {runner_program, runner_program_args} = 33 | case Process.get(:os_type, default: :os.type()) do 34 | {:win32, _} -> 35 | {command, command_args ++ task ++ task_args} 36 | 37 | _ -> 38 | {zombie_killer(), [command] ++ command_args ++ task ++ task_args} 39 | end 40 | 41 | maybe_print_command(config, runner_program, runner_program_args) 42 | 43 | runner.(runner_program, runner_program_args, 44 | env: [{"MIX_ENV", "test"}], 45 | into: IO.stream(:stdio, :line) 46 | ) 47 | 48 | :ok 49 | end 50 | 51 | defp enable_ansi(task) do 52 | enable_command = "Application.put_env(:elixir, :ansi_enabled, true)" 53 | 54 | ["do", "eval", enable_command, ",", task] 55 | end 56 | 57 | defp maybe_print_command(%Config{verbose?: false} = _config, _runner_program, _runner_program_args), do: :ok 58 | 59 | defp maybe_print_command(%Config{} = _config, runner_program, runner_program_args) do 60 | runner_program 61 | |> CommandLineFormatter.call(runner_program_args) 62 | |> IO.puts() 63 | end 64 | 65 | defp zombie_killer do 66 | @application 67 | |> :code.priv_dir() 68 | |> Path.join("zombie_killer") 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/run_summary.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.RunSummary do 2 | @moduledoc false 3 | alias MixTestInteractive.Settings 4 | 5 | @doc """ 6 | Return a text summary of the current interactive mode settings. 7 | """ 8 | @spec from_settings(Settings.t()) :: String.t() 9 | def from_settings(%Settings{} = settings) do 10 | [&base_summary/1, &all_tag_filters/1, &max_failures/1, &repeat_count/1, &seed/1, &tracing/1] 11 | |> Enum.flat_map(fn fun -> List.wrap(fun.(settings)) end) 12 | |> Enum.join("\n") 13 | end 14 | 15 | defp all_tag_filters(%Settings{} = settings) do 16 | Enum.reject( 17 | [ 18 | tag_filters("Excluding tags", settings.excludes), 19 | tag_filters("Including tags", settings.includes), 20 | tag_filters("Only tags", settings.only) 21 | ], 22 | &is_nil/1 23 | ) 24 | end 25 | 26 | defp base_summary(%Settings{} = settings) do 27 | cond do 28 | settings.failed? -> 29 | "Ran only failed tests" 30 | 31 | settings.stale? -> 32 | "Ran only stale tests" 33 | 34 | !Enum.empty?(settings.patterns) -> 35 | "Ran all test files matching #{Enum.join(settings.patterns, ", ")}" 36 | 37 | true -> 38 | "Ran all tests" 39 | end 40 | end 41 | 42 | defp max_failures(%Settings{max_failures: nil}), do: nil 43 | 44 | defp max_failures(%Settings{} = settings) do 45 | "Max failures: #{settings.max_failures}" 46 | end 47 | 48 | defp repeat_count(%Settings{repeat_count: nil}), do: nil 49 | 50 | defp repeat_count(%Settings{} = settings) do 51 | "Repeat until failure: #{settings.repeat_count}" 52 | end 53 | 54 | def seed(%Settings{seed: nil}), do: nil 55 | 56 | def seed(%Settings{} = settings) do 57 | "Seed: #{settings.seed}" 58 | end 59 | 60 | defp tracing(%Settings{tracing?: false}), do: nil 61 | 62 | defp tracing(%Settings{}) do 63 | "Tracing: ON" 64 | end 65 | 66 | defp tag_filters(_label, []), do: nil 67 | 68 | defp tag_filters(label, tags) do 69 | label <> ": " <> inspect(tags) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Runner do 2 | @moduledoc """ 3 | Runs tests based on current configuration. 4 | 5 | Also responsible for optionally clearing the terminal and printing the current time. 6 | """ 7 | 8 | alias MixTestInteractive.Config 9 | 10 | @doc """ 11 | Run tests using configured runner. 12 | """ 13 | @spec run(Config.t(), [String.t()]) :: :ok 14 | def run(config, args) do 15 | :ok = maybe_clear_terminal(config) 16 | 17 | IO.puts("") 18 | 19 | [:cyan, :bright, "Running tests..."] 20 | |> IO.ANSI.format() 21 | |> IO.puts() 22 | 23 | :ok = maybe_print_timestamp(config) 24 | config.runner.run(config, args) 25 | end 26 | 27 | defp maybe_clear_terminal(%{clear?: false}), do: :ok 28 | defp maybe_clear_terminal(%{clear?: true}), do: :ok = IO.puts(IO.ANSI.clear() <> IO.ANSI.home()) 29 | 30 | defp maybe_print_timestamp(%{show_timestamp?: false}), do: :ok 31 | 32 | defp maybe_print_timestamp(%{show_timestamp?: true}) do 33 | :ok = 34 | DateTime.utc_now() 35 | |> DateTime.to_string() 36 | |> IO.puts() 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/settings.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Settings do 2 | @moduledoc """ 3 | Interactive mode settings. 4 | 5 | Keeps track of the current settings of `MixTestInteractive.InteractiveMode`, making changes 6 | in response to user commands. 7 | """ 8 | 9 | use TypedStruct 10 | 11 | alias MixTestInteractive.PatternFilter 12 | alias MixTestInteractive.TestFiles 13 | 14 | @default_list_all_files &TestFiles.list/0 15 | 16 | typedstruct do 17 | field :excludes, [String.t()], default: [] 18 | field :failed?, boolean(), default: false 19 | field :includes, [String.t()], default: [] 20 | field :initial_cli_args, [String.t()], default: [] 21 | field :list_all_files, (-> [String.t()]), default: @default_list_all_files 22 | field :max_failures, String.t() 23 | field :only, [String.t()], default: [] 24 | field :patterns, [String.t()], default: [] 25 | field :repeat_count, String.t() 26 | field :seed, String.t() 27 | field :stale?, boolean(), default: false 28 | field :tracing?, boolean(), default: false 29 | field :watching?, boolean(), default: true 30 | end 31 | 32 | @doc """ 33 | Update settings to run all tests, removing any flags or filter patterns. 34 | """ 35 | @spec all_tests(t()) :: t() 36 | def all_tests(%__MODULE__{} = settings) do 37 | %{settings | failed?: false, patterns: [], stale?: false} 38 | end 39 | 40 | @doc """ 41 | Update settings to clear any excluded tags. 42 | """ 43 | @spec clear_excludes(t()) :: t() 44 | def clear_excludes(%__MODULE__{} = settings) do 45 | %{settings | excludes: []} 46 | end 47 | 48 | @doc """ 49 | Update settings to clear any included tags. 50 | """ 51 | @spec clear_includes(t()) :: t() 52 | def clear_includes(%__MODULE__{} = settings) do 53 | %{settings | includes: []} 54 | end 55 | 56 | @doc """ 57 | Update settings to run with unlimited failures, clearing any specified maximum 58 | number of failures. 59 | """ 60 | @spec clear_max_failures(t()) :: t() 61 | def clear_max_failures(%__MODULE__{} = settings) do 62 | %{settings | max_failures: nil} 63 | end 64 | 65 | @doc """ 66 | Update settings to clear any "only" tags. 67 | """ 68 | @spec clear_only(t()) :: t() 69 | def clear_only(%__MODULE__{} = settings) do 70 | %{settings | only: []} 71 | end 72 | 73 | @doc """ 74 | Update settings to run tests only once, clearing any repeat-until-failure 75 | count. 76 | """ 77 | @spec clear_repeat_count(t()) :: t() 78 | def clear_repeat_count(%__MODULE__{} = settings) do 79 | %{settings | repeat_count: nil} 80 | end 81 | 82 | @doc """ 83 | Update settings to run tests with a random seed, clearing any specified seed. 84 | """ 85 | @spec clear_seed(t()) :: t() 86 | def clear_seed(%__MODULE__{} = settings) do 87 | %{settings | seed: nil} 88 | end 89 | 90 | @doc """ 91 | Assemble command-line arguments to pass to `mix test`. 92 | 93 | Includes arguments originally passed to `mix test.interactive` when it was started 94 | as well as arguments based on the current interactive mode settings. 95 | """ 96 | @spec cli_args(t()) :: {:ok, [String.t()]} | {:error, :no_matching_files} 97 | def cli_args(%__MODULE__{} = settings) do 98 | with {:ok, args} <- args_from_settings(settings) do 99 | {:ok, settings.initial_cli_args ++ args} 100 | end 101 | end 102 | 103 | @doc false 104 | def list_files_with(%__MODULE__{} = settings, list_fn) do 105 | %{settings | list_all_files: list_fn} 106 | end 107 | 108 | @doc """ 109 | Update settings to only run failing tests. 110 | 111 | Corresponds to `mix test --failed`. 112 | """ 113 | @spec only_failed(t()) :: t() 114 | def only_failed(%__MODULE__{} = settings) do 115 | settings 116 | |> all_tests() 117 | |> Map.put(:failed?, true) 118 | end 119 | 120 | @doc """ 121 | Provide a list of file-name filter patterns. 122 | 123 | Only test filenames matching one or more patterns will be run. 124 | """ 125 | @spec only_patterns(t(), [String.t()]) :: t() 126 | def only_patterns(%__MODULE__{} = settings, patterns) do 127 | settings 128 | |> all_tests() 129 | |> Map.put(:patterns, patterns) 130 | end 131 | 132 | @doc """ 133 | Update settings to only run "stale" tests. 134 | 135 | Corresponds to `mix test --stale`. 136 | """ 137 | @spec only_stale(t()) :: t() 138 | def only_stale(%__MODULE__{} = settings) do 139 | settings 140 | |> all_tests() 141 | |> Map.put(:stale?, true) 142 | end 143 | 144 | @doc """ 145 | Return a text summary of the current interactive mode settings. 146 | """ 147 | @spec summary(t()) :: String.t() 148 | def summary(%__MODULE__{} = settings) do 149 | run_summary = 150 | cond do 151 | settings.failed? -> 152 | "Ran only failed tests" 153 | 154 | settings.stale? -> 155 | "Ran only stale tests" 156 | 157 | !Enum.empty?(settings.patterns) -> 158 | "Ran all test files matching #{Enum.join(settings.patterns, ", ")}" 159 | 160 | true -> 161 | "Ran all tests" 162 | end 163 | 164 | with_seed = 165 | case settings.seed do 166 | nil -> run_summary 167 | seed -> run_summary <> " with seed: #{seed}" 168 | end 169 | 170 | with_seed 171 | |> append_tag_filters(settings) 172 | |> append_max_failures(settings) 173 | |> append_repeat_count(settings) 174 | |> append_tracing(settings) 175 | end 176 | 177 | defp append_max_failures(summary, %__MODULE__{max_failures: nil} = _settings) do 178 | summary 179 | end 180 | 181 | defp append_max_failures(summary, %__MODULE__{} = settings) do 182 | summary <> "\nMax failures: #{settings.max_failures}" 183 | end 184 | 185 | defp append_repeat_count(summary, %__MODULE__{repeat_count: nil}), do: summary 186 | 187 | defp append_repeat_count(summary, %__MODULE__{} = settings) do 188 | summary <> "\nRepeat until failure: #{settings.repeat_count}" 189 | end 190 | 191 | defp append_tag_filters(summary, %__MODULE__{} = settings) do 192 | [ 193 | summary, 194 | tag_filters("Excluding tags", settings.excludes), 195 | tag_filters("Including tags", settings.includes), 196 | tag_filters("Only tags", settings.only) 197 | ] 198 | |> Enum.reject(&is_nil/1) 199 | |> Enum.join("\n") 200 | end 201 | 202 | defp append_tracing(summary, %__MODULE__{tracing?: false}), do: summary 203 | 204 | defp append_tracing(summary, %__MODULE__{tracing?: true}) do 205 | summary <> "\nTracing: ON" 206 | end 207 | 208 | defp tag_filters(_label, []), do: nil 209 | 210 | defp tag_filters(label, tags) do 211 | label <> ": " <> inspect(tags) 212 | end 213 | 214 | @doc """ 215 | Toggle test tracing on or off. 216 | """ 217 | @spec toggle_tracing(t()) :: t() 218 | def toggle_tracing(%__MODULE__{} = settings) do 219 | %{settings | tracing?: !settings.tracing?} 220 | end 221 | 222 | @doc """ 223 | Toggle file-watching mode on or off. 224 | """ 225 | @spec toggle_watch_mode(t()) :: t() 226 | def toggle_watch_mode(%__MODULE__{} = settings) do 227 | %{settings | watching?: !settings.watching?} 228 | end 229 | 230 | @doc """ 231 | Exclude tests with the specified tags. 232 | 233 | Corresponds to `mix test --exclude --exclude ...`. 234 | """ 235 | @spec with_excludes(t(), [String.t()]) :: t() 236 | def with_excludes(%__MODULE__{} = settings, excludes) do 237 | %{settings | excludes: excludes} 238 | end 239 | 240 | @doc """ 241 | Include tests with the specified tags. 242 | 243 | Corresponds to `mix test --include --include ...`. 244 | """ 245 | @spec with_includes(t(), [String.t()]) :: t() 246 | def with_includes(%__MODULE__{} = settings, includes) do 247 | %{settings | includes: includes} 248 | end 249 | 250 | @doc """ 251 | Stop running tests after a maximum number of failures. 252 | 253 | Corresponds to `mix test --max-failures `. 254 | """ 255 | @spec with_max_failures(t(), String.t()) :: t() 256 | def with_max_failures(%__MODULE__{} = settings, max) do 257 | %{settings | max_failures: max} 258 | end 259 | 260 | @doc """ 261 | Run only the tests with the specified tags. 262 | 263 | Corresponds to `mix test --only --only ...`. 264 | """ 265 | @spec with_only(t(), [String.t()]) :: t() 266 | def with_only(%__MODULE__{} = settings, only) do 267 | %{settings | only: only} 268 | end 269 | 270 | @doc """ 271 | Update settings to run tests times until failure. 272 | 273 | Corresponds to `mix test --repeat-until-failure `. 274 | """ 275 | @spec with_repeat_count(t(), String.t()) :: t() 276 | def with_repeat_count(%__MODULE__{} = settings, count) do 277 | %{settings | repeat_count: count} 278 | end 279 | 280 | @doc """ 281 | Update settings to run tests with a specific seed. 282 | 283 | Corresponds to `mix test --seed `. 284 | """ 285 | @spec with_seed(t(), String.t()) :: t() 286 | def with_seed(%__MODULE__{} = settings, seed) do 287 | %{settings | seed: seed} 288 | end 289 | 290 | defp args_from_settings(%__MODULE{} = settings) do 291 | with {:ok, files} <- files_from_patterns(settings) do 292 | {:ok, opts_from_settings(settings) ++ files} 293 | end 294 | end 295 | 296 | defp files_from_patterns(%__MODULE__{patterns: []} = _settings) do 297 | {:ok, []} 298 | end 299 | 300 | defp files_from_patterns(%__MODULE__{patterns: patterns} = settings) do 301 | case PatternFilter.matches(settings.list_all_files.(), patterns) do 302 | [] -> {:error, :no_matching_files} 303 | files -> {:ok, files} 304 | end 305 | end 306 | 307 | defp opts_from_settings(%__MODULE__{} = settings) do 308 | settings 309 | |> Map.from_struct() 310 | |> Enum.sort() 311 | |> Enum.flat_map(&opts_from_single_setting/1) 312 | end 313 | 314 | defp opts_from_single_setting({:excludes, excludes}) do 315 | Enum.flat_map(excludes, &["--exclude", &1]) 316 | end 317 | 318 | defp opts_from_single_setting({:failed?, false}), do: [] 319 | defp opts_from_single_setting({:failed?, true}), do: ["--failed"] 320 | 321 | defp opts_from_single_setting({:includes, includes}) do 322 | Enum.flat_map(includes, &["--include", &1]) 323 | end 324 | 325 | defp opts_from_single_setting({:max_failures, nil}), do: [] 326 | defp opts_from_single_setting({:max_failures, max}), do: ["--max-failures", max] 327 | 328 | defp opts_from_single_setting({:only, only}) do 329 | Enum.flat_map(only, &["--only", &1]) 330 | end 331 | 332 | defp opts_from_single_setting({:repeat_count, nil}), do: [] 333 | defp opts_from_single_setting({:repeat_count, count}), do: ["--repeat-until-failure", count] 334 | 335 | defp opts_from_single_setting({:seed, nil}), do: [] 336 | defp opts_from_single_setting({:seed, seed}), do: ["--seed", seed] 337 | 338 | defp opts_from_single_setting({:stale?, false}), do: [] 339 | defp opts_from_single_setting({:stale?, true}), do: ["--stale"] 340 | 341 | defp opts_from_single_setting({:tracing?, false}), do: [] 342 | defp opts_from_single_setting({:tracing?, true}), do: ["--trace"] 343 | 344 | defp opts_from_single_setting({_key, _value}), do: [] 345 | end 346 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/test_files.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.TestFiles do 2 | @moduledoc false 3 | 4 | @doc """ 5 | List available test files 6 | 7 | Respects the configured `:test_paths` and `:test_pattern` settings. 8 | Used internally to filter test files by pattern on each test run. 9 | That way, any new files that match an existing pattern will be picked 10 | up immediately. 11 | """ 12 | @spec list() :: [String.t()] 13 | def list do 14 | config = Mix.Project.config() 15 | paths = config[:test_paths] || default_test_paths() 16 | pattern = config[:test_pattern] || "*_test.exs" 17 | 18 | Mix.Utils.extract_files(paths, pattern) 19 | end 20 | 21 | def default_test_paths do 22 | if Mix.Project.umbrella?() do 23 | Path.wildcard("apps/*/test") 24 | else 25 | ["test"] 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/test_runner.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.TestRunner do 2 | @moduledoc """ 3 | Behaviour for custom test runners. 4 | 5 | Any custom runner defined in the configuration or by command-line options must 6 | implement this behaviour, either explicitly or implicitly. 7 | """ 8 | alias MixTestInteractive.Config 9 | 10 | @callback run(Config.t(), [String.t()]) :: :ok 11 | end 12 | -------------------------------------------------------------------------------- /lib/mix_test_interactive/watcher.ex: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.Watcher do 2 | @moduledoc """ 3 | A server that runs tests whenever source files change. 4 | """ 5 | 6 | use GenServer 7 | 8 | alias MixTestInteractive.InteractiveMode 9 | alias MixTestInteractive.MessageInbox 10 | alias MixTestInteractive.Paths 11 | 12 | require Logger 13 | 14 | # The following events (among many others, depending on platform) are emitted 15 | # by the `FileSystem` library and are the ones that we look for in order to 16 | # kick off a test run. 17 | @trigger_events [ 18 | :created, 19 | :deleted, 20 | :modified, 21 | :moved_from, 22 | :moved_to, 23 | :removed, 24 | :renamed 25 | ] 26 | 27 | @doc """ 28 | Start the file watcher. 29 | """ 30 | def start_link(options) do 31 | config = Keyword.fetch!(options, :config) 32 | GenServer.start_link(__MODULE__, config, name: __MODULE__) 33 | end 34 | 35 | @impl GenServer 36 | def init(config) do 37 | opts = [dirs: [Path.absname("")], name: :mix_test_watcher] 38 | 39 | case FileSystem.start_link(opts) do 40 | {:ok, _} -> 41 | FileSystem.subscribe(:mix_test_watcher) 42 | {:ok, config} 43 | 44 | other -> 45 | Logger.warning(""" 46 | Could not start the file system monitor. 47 | """) 48 | 49 | other 50 | end 51 | end 52 | 53 | @impl GenServer 54 | def handle_info({:file_event, _, {path, events}}, config) do 55 | if Enum.any?(events, &(&1 in @trigger_events)) do 56 | path = to_string(path) 57 | 58 | if Paths.watching?(path, config) do 59 | InteractiveMode.note_file_changed() 60 | MessageInbox.flush() 61 | end 62 | end 63 | 64 | {:noreply, config} 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.MixProject do 2 | use Mix.Project 3 | 4 | @version "4.3.0" 5 | @source_url "https://github.com/randycoulman/mix_test_interactive" 6 | 7 | def project do 8 | [ 9 | app: :mix_test_interactive, 10 | deps: deps(), 11 | description: description(), 12 | docs: docs(), 13 | elixir: "~> 1.13", 14 | name: "mix test.interactive", 15 | package: package(), 16 | source_url: @source_url, 17 | start_permanent: Mix.env() == :prod, 18 | version: @version 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger, :file_system], 25 | mod: {MixTestInteractive.Application, []} 26 | ] 27 | end 28 | 29 | defp description do 30 | "Interactive test runner for mix test with watch mode." 31 | end 32 | 33 | defp deps do 34 | [ 35 | {:ex_doc, "~> 0.35.1", only: :dev, runtime: false}, 36 | {:file_system, "~> 0.2 or ~> 1.0"}, 37 | {:process_tree, "~> 0.1.3 or ~> 0.2.0"}, 38 | {:styler, "~> 1.2", only: [:dev, :test], runtime: false}, 39 | {:typed_struct, "~> 0.3.0"} 40 | ] 41 | end 42 | 43 | defp docs do 44 | [ 45 | extras: [ 46 | "CHANGELOG.md": [], 47 | "LICENSE.md": [title: "License"], 48 | "README.md": [title: "Overview"] 49 | ], 50 | formatters: ["html"], 51 | groups_for_modules: [ 52 | Commands: [~r/^MixTestInteractive\.Command\..*/] 53 | ], 54 | main: "readme", 55 | source_ref: "#{@version}" 56 | ] 57 | end 58 | 59 | defp package do 60 | [ 61 | licenses: ["MIT"], 62 | links: %{ 63 | "Changelog" => "https://hexdocs.pm/mix_test_interactive/changelog.html", 64 | "GitHub" => @source_url 65 | } 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 3 | "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, 4 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 5 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 9 | "process_tree": {:hex, :process_tree, "0.2.0", "a934111ff00c1b696731edf35ecc6a4724105e045efa95a1977cac21ebad62cd", [:mix], [], "hexpm", "92901d3e9d2f40f4e09c7774a97ed0d1fac5d3541c62552df8346dfa0f9ee19b"}, 10 | "styler": {:hex, :styler, "1.2.1", "28f9e3d4b065c22575c56b8ae03d05188add1b21bec5ae664fc1551e2dfcc41b", [:mix], [], "hexpm", "71dc33980e530d21ca54db9c2075e646faa6e7b744a9d4a3dfb0ff01f56595f0"}, 11 | "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, 12 | } 13 | -------------------------------------------------------------------------------- /priv/zombie_killer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Start the program in the background 4 | exec "$@" & 5 | pid1=$! 6 | 7 | # Silence warnings from here on 8 | exec >/dev/null 2>&1 9 | 10 | # Read from stdin in the background and 11 | # kill running program when stdin closes 12 | exec 0<&0 $( 13 | while read; do :; done 14 | kill -KILL $pid1 15 | ) & 16 | pid2=$! 17 | 18 | # Clean up 19 | wait $pid1 20 | ret=$? 21 | kill -KILL $pid2 22 | exit $ret 23 | -------------------------------------------------------------------------------- /test/mix_test_interactive/command_line_formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandLineFormatterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.CommandLineFormatter 5 | 6 | describe "formatting a command line with proper quoting" do 7 | test "formats simple commands without quoting" do 8 | assert CommandLineFormatter.call("mix", ["test", "--stale", "file.exs"]) == 9 | ~s(mix test --stale file.exs) 10 | end 11 | 12 | test "double-quotes arguments with spaces" do 13 | assert CommandLineFormatter.call("mix", ["test", "file with spaces.txt"]) == 14 | ~s(mix test "file with spaces.txt") 15 | end 16 | 17 | test "double-quotes arguments with special characters" do 18 | args = ["do", "eval", "Application.put_env(:elixir,:ansi_enabled,true)", ",", "test"] 19 | expected = ~s[mix do eval "Application.put_env(:elixir,:ansi_enabled,true)" , test] 20 | 21 | assert CommandLineFormatter.call("mix", args) == expected 22 | end 23 | 24 | test "single-quotes arguments containing only double quotes" do 25 | args = ["do", "eval", ~s[IO.puts("running tests!")], ",", "test"] 26 | expected = ~s[mix do eval 'IO.puts("running tests!")' , test] 27 | 28 | assert CommandLineFormatter.call("mix", args) == expected 29 | end 30 | 31 | test "double-quotes arguments with mixed single and double quotes" do 32 | args = ["do", "eval", ~s[IO.puts("I'm testing")], ",", "test"] 33 | expected = ~s[mix do eval 'IO.puts(\"I'\\''m testing\")' , test] 34 | 35 | assert CommandLineFormatter.call("mix", args) == expected 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/mix_test_interactive/command_line_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandLineParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.CommandLineParser 5 | alias MixTestInteractive.CommandLineParser.UsageError 6 | alias MixTestInteractive.Config 7 | 8 | defmodule CustomRunner do 9 | @moduledoc false 10 | @behaviour MixTestInteractive.TestRunner 11 | 12 | alias MixTestInteractive.TestRunner 13 | 14 | @impl TestRunner 15 | def run(_config, _args), do: :ok 16 | end 17 | 18 | defmodule NotARunner do 19 | @moduledoc false 20 | end 21 | 22 | describe "help option" do 23 | test "returns :help with --help" do 24 | assert {:ok, :help} == CommandLineParser.parse(["--help"]) 25 | end 26 | 27 | test "returns :help even with other options" do 28 | assert {:ok, :help} == CommandLineParser.parse(["--clear", "--help", "--no-watch"]) 29 | end 30 | 31 | test "returns :help even with unknown options" do 32 | assert {:ok, :help} == CommandLineParser.parse(["--unknown", "--help"]) 33 | end 34 | 35 | test "returns :help even with mix test options only" do 36 | assert {:ok, :help} == CommandLineParser.parse(["--stale", "--help"]) 37 | end 38 | end 39 | 40 | describe "version option" do 41 | test "returns :version with --version" do 42 | assert {:ok, :version} == CommandLineParser.parse(["--version"]) 43 | end 44 | 45 | test "returns :help with both --help and --version" do 46 | assert {:ok, :help} == CommandLineParser.parse(["--version", "--help"]) 47 | end 48 | 49 | test "returns :version even with other options" do 50 | assert {:ok, :version} == CommandLineParser.parse(["--clear", "--version", "--no-watch"]) 51 | end 52 | 53 | test "returns :version even with unknown options" do 54 | assert {:ok, :version} == CommandLineParser.parse(["--unknown", "--version"]) 55 | end 56 | 57 | test "returns :version even with mix test options only" do 58 | assert {:ok, :version} == CommandLineParser.parse(["--stale", "--version"]) 59 | end 60 | end 61 | 62 | describe "mix test.interactive options" do 63 | test "retains original defaults when no options" do 64 | {:ok, %{config: config}} = CommandLineParser.parse([]) 65 | assert config == Config.new() 66 | end 67 | 68 | test "sets ansi_enabled? flag with --ansi-enabled" do 69 | Process.put(:os_type, {:win32, :nt}) 70 | {:ok, %{config: config}} = CommandLineParser.parse(["--ansi-enabled"]) 71 | assert config.ansi_enabled? 72 | end 73 | 74 | test "clears ansi_enabled? flag with --no-ansi-enabled" do 75 | Process.put(:os_type, {:unix, :darwin}) 76 | {:ok, %{config: config}} = CommandLineParser.parse(["--no-ansi-enabled"]) 77 | refute config.ansi_enabled? 78 | end 79 | 80 | test "sets clear? flag with --clear" do 81 | {:ok, %{config: config}} = CommandLineParser.parse(["--clear"]) 82 | assert config.clear? 83 | end 84 | 85 | test "clears clear? flag with --no-clear" do 86 | {:ok, %{config: config}} = CommandLineParser.parse(["--no-clear"]) 87 | refute config.clear? 88 | end 89 | 90 | test "configures custom command with --command" do 91 | {:ok, %{config: config}} = CommandLineParser.parse(["--command", "custom_command"]) 92 | assert config.command == {"custom_command", []} 93 | end 94 | 95 | test "configures custom command with single argument with --command and --arg" do 96 | {:ok, %{config: config}} = CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg"]) 97 | assert config.command == {"custom_command", ["custom_arg"]} 98 | end 99 | 100 | test "configures custom command with multiple arguments with --command and repeated --arg options" do 101 | {:ok, %{config: config}} = 102 | CommandLineParser.parse(["--command", "custom_command", "--arg", "custom_arg1", "--arg", "custom_arg2"]) 103 | 104 | assert config.command == {"custom_command", ["custom_arg1", "custom_arg2"]} 105 | end 106 | 107 | test "ignores custom command arguments if command is not specified" do 108 | {:ok, %{config: config}} = CommandLineParser.parse(["--arg", "arg_with_missing_command"]) 109 | assert config.command == %Config{}.command 110 | end 111 | 112 | test "configures watch exclusions with --exclude" do 113 | {:ok, %{config: config}} = CommandLineParser.parse(["--exclude", "~$"]) 114 | assert config.exclude == [~r/~$/] 115 | end 116 | 117 | test "configures multiple watch exclusions with repeated --exclude options" do 118 | {:ok, %{config: config}} = CommandLineParser.parse(["--exclude", "~$", "--exclude", "\.secret\.exs"]) 119 | assert config.exclude == [~r/~$/, ~r/.secret.exs/] 120 | end 121 | 122 | test "fails if watch exclusion is an invalid Regex" do 123 | assert {:error, %UsageError{}} = CommandLineParser.parse(["--exclude", "[A-Za-z"]) 124 | end 125 | 126 | test "configures additional extensions to watch with --extra-extensions" do 127 | {:ok, %{config: config}} = CommandLineParser.parse(["--extra-extensions", "md"]) 128 | assert config.extra_extensions == ["md"] 129 | end 130 | 131 | test "configures multiple additional extensions to watch with repeated --extra-extensions options" do 132 | {:ok, %{config: config}} = CommandLineParser.parse(["--extra-extensions", "md", "--extra-extensions", "json"]) 133 | assert config.extra_extensions == ["md", "json"] 134 | end 135 | 136 | test "configures custom runner module with --runner" do 137 | {:ok, %{config: config}} = CommandLineParser.parse(["--runner", inspect(CustomRunner)]) 138 | assert config.runner == CustomRunner 139 | end 140 | 141 | test "fails if custom runner doesn't have a run function" do 142 | assert {:error, %UsageError{}} = CommandLineParser.parse(["--runner", inspect(NotARunner)]) 143 | end 144 | 145 | test "fails if custom runner module doesn't exist" do 146 | assert {:error, %UsageError{}} = CommandLineParser.parse(["--runner", "NotAModule"]) 147 | end 148 | 149 | test "sets show_timestamp? flag with --timestamp" do 150 | {:ok, %{config: config}} = CommandLineParser.parse(["--timestamp"]) 151 | assert config.show_timestamp? 152 | end 153 | 154 | test "clears show_timestamp? flag with --no-timestamp" do 155 | {:ok, %{config: config}} = CommandLineParser.parse(["--no-timestamp"]) 156 | refute config.show_timestamp? 157 | end 158 | 159 | test "sets verbose? flag with --verbose" do 160 | {:ok, %{config: config}} = CommandLineParser.parse(["--verbose"]) 161 | assert config.verbose? 162 | end 163 | 164 | test "clears verbose? flag with --no-verbose" do 165 | {:ok, %{config: config}} = CommandLineParser.parse(["--no-verbose"]) 166 | refute config.verbose? 167 | end 168 | 169 | test "configures custom mix task with --task" do 170 | {:ok, %{config: config}} = CommandLineParser.parse(["--task", "custom_task"]) 171 | assert config.task == "custom_task" 172 | end 173 | 174 | test "initially enables watch mode with --watch flag" do 175 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--watch"]) 176 | assert settings.watching? 177 | end 178 | 179 | test "initially disables watch mode with --no-watch flag" do 180 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--no-watch"]) 181 | refute settings.watching? 182 | end 183 | 184 | test "does not pass mti options to mix test" do 185 | {:ok, %{settings: settings}} = 186 | CommandLineParser.parse([ 187 | "--clear", 188 | "--no-clear", 189 | "--command", 190 | "custom_command", 191 | "--arg", 192 | "custom_arg", 193 | "--exclude", 194 | "~$", 195 | "--extra-extensions", 196 | "md", 197 | "--runner", 198 | inspect(CustomRunner), 199 | "--timestamp", 200 | "--no-timestamp", 201 | "--watch", 202 | "--no-watch" 203 | ]) 204 | 205 | assert settings.initial_cli_args == [] 206 | end 207 | end 208 | 209 | describe "mix test arguments" do 210 | test "records initial `mix test` arguments" do 211 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--raise"]) 212 | assert settings.initial_cli_args == ["--color", "--raise"] 213 | end 214 | 215 | test "records no `mix test` arguments by default" do 216 | {:ok, %{settings: settings}} = CommandLineParser.parse() 217 | assert settings.initial_cli_args == [] 218 | end 219 | 220 | test "omits unknown arguments" do 221 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--unknown-arg"]) 222 | 223 | assert settings.initial_cli_args == [] 224 | end 225 | 226 | test "extracts excludes from arguments" do 227 | {:ok, %{settings: settings}} = 228 | CommandLineParser.parse([ 229 | "--", 230 | "--exclude", 231 | "tag1", 232 | "--color", 233 | "--exclude", 234 | "tag2", 235 | "--failed", 236 | "--raise", 237 | "--exclude", 238 | "tag3" 239 | ]) 240 | 241 | assert settings.excludes == ["tag1", "tag2", "tag3"] 242 | assert settings.initial_cli_args == ["--color", "--raise"] 243 | end 244 | 245 | test "extracts failed flag from arguments" do 246 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--failed", "--raise"]) 247 | assert settings.failed? 248 | assert settings.initial_cli_args == ["--color", "--raise"] 249 | end 250 | 251 | test "extracts includes from arguments" do 252 | {:ok, %{settings: settings}} = 253 | CommandLineParser.parse([ 254 | "--include", 255 | "tag1", 256 | "--color", 257 | "--include", 258 | "tag2", 259 | "--failed", 260 | "--raise", 261 | "--include", 262 | "tag3" 263 | ]) 264 | 265 | assert settings.includes == ["tag1", "tag2", "tag3"] 266 | assert settings.initial_cli_args == ["--color", "--raise"] 267 | end 268 | 269 | test "extracts max-failures from arguments" do 270 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--max-failures", "7", "--raise"]) 271 | assert settings.max_failures == "7" 272 | assert settings.initial_cli_args == ["--color", "--raise"] 273 | end 274 | 275 | test "extracts only from arguments" do 276 | {:ok, %{settings: settings}} = 277 | CommandLineParser.parse(["--only", "tag1", "--color", "--only", "tag2", "--failed", "--raise", "--only", "tag3"]) 278 | 279 | assert settings.only == ["tag1", "tag2", "tag3"] 280 | assert settings.initial_cli_args == ["--color", "--raise"] 281 | end 282 | 283 | test "extracts repeat-until-failure from arguments" do 284 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--repeat-until-failure", "1000", "--raise"]) 285 | assert settings.repeat_count == "1000" 286 | assert settings.initial_cli_args == ["--color", "--raise"] 287 | end 288 | 289 | test "extracts seed from arguments" do 290 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--seed", "5432", "--raise"]) 291 | assert settings.seed == "5432" 292 | assert settings.initial_cli_args == ["--color", "--raise"] 293 | end 294 | 295 | test "extracts stale setting from arguments" do 296 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--stale", "--raise"]) 297 | assert settings.stale? 298 | assert settings.initial_cli_args == ["--color", "--raise"] 299 | end 300 | 301 | test "extracts trace flag from arguments" do 302 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--color", "--trace", "--raise"]) 303 | assert settings.tracing? 304 | assert settings.initial_cli_args == ["--color", "--raise"] 305 | end 306 | 307 | test "extracts patterns from arguments" do 308 | {:ok, %{settings: settings}} = CommandLineParser.parse(["pattern1", "--color", "pattern2"]) 309 | assert settings.patterns == ["pattern1", "pattern2"] 310 | assert settings.initial_cli_args == ["--color"] 311 | end 312 | 313 | test "extracts patterns even when no other flags are present" do 314 | {:ok, %{settings: settings}} = CommandLineParser.parse(["pattern1", "pattern2"]) 315 | assert settings.patterns == ["pattern1", "pattern2"] 316 | assert settings.initial_cli_args == [] 317 | end 318 | 319 | test "failed takes precedence over stale" do 320 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--failed", "--stale"]) 321 | refute settings.stale? 322 | assert settings.failed? 323 | end 324 | 325 | test "patterns take precedence over stale/failed flags" do 326 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--failed", "--stale", "pattern"]) 327 | assert settings.patterns == ["pattern"] 328 | refute settings.failed? 329 | refute settings.stale? 330 | assert settings.initial_cli_args == [] 331 | end 332 | end 333 | 334 | describe "passing both mix test.interactive (mti) and mix test arguments" do 335 | test "process arguments for mti and mix test separately" do 336 | {:ok, %{config: config, settings: settings}} = CommandLineParser.parse(["--clear", "--", "--stale"]) 337 | assert config.clear? 338 | assert settings.stale? 339 | end 340 | 341 | test "handles mti and mix test options with the same name" do 342 | {:ok, %{config: config, settings: settings}} = 343 | CommandLineParser.parse(["--exclude", "~$", "--", "--exclude", "integration"]) 344 | 345 | assert config.exclude == [~r/~$/] 346 | assert settings.excludes == ["integration"] 347 | end 348 | 349 | test "requires -- separator to distinguish the sets of arguments" do 350 | assert {:error, %UsageError{}} = CommandLineParser.parse(["--clear", "--stale"]) 351 | end 352 | 353 | test "handles mix test options with leading `--` separator" do 354 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--", "--stale"]) 355 | assert settings.stale? 356 | end 357 | 358 | test "ignores --{no-}watch if specified in mix test options" do 359 | {:ok, %{settings: settings}} = CommandLineParser.parse(["--", "--no-watch"]) 360 | 361 | assert settings.watching? 362 | end 363 | 364 | test "fails with unknown options before --" do 365 | assert {:error, %UsageError{}} = CommandLineParser.parse(["--unknown-arg", "--", "--stale"]) 366 | end 367 | 368 | test "omits unknown options after --" do 369 | {:ok, %{config: config}} = CommandLineParser.parse(["--clear", "--", "--unknown-arg"]) 370 | 371 | assert config.clear? 372 | end 373 | end 374 | end 375 | -------------------------------------------------------------------------------- /test/mix_test_interactive/command_processor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.CommandProcessorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.CommandProcessor 5 | alias MixTestInteractive.Settings 6 | 7 | defp process_command(command, settings \\ %Settings{}) do 8 | CommandProcessor.call(command, settings) 9 | end 10 | 11 | describe "commands" do 12 | test ":eof returns :quit" do 13 | assert :quit = process_command(:eof) 14 | end 15 | 16 | test "q returns :quit" do 17 | assert :quit = process_command("q") 18 | end 19 | 20 | test "Enter returns ok tuple" do 21 | settings = %Settings{} 22 | assert {:ok, ^settings} = process_command("", settings) 23 | end 24 | 25 | test "a runs all tests" do 26 | {:ok, settings} = process_command("s", %Settings{}) 27 | expected = Settings.all_tests(settings) 28 | 29 | assert {:ok, ^expected} = process_command("a", settings) 30 | end 31 | 32 | test "d sets the test seed" do 33 | settings = %Settings{} 34 | expected = Settings.with_seed(settings, "4258") 35 | 36 | assert {:ok, ^expected} = process_command("d 4258", settings) 37 | end 38 | 39 | test "d with no seed clears the test seed" do 40 | {:ok, settings} = process_command("d 1234", %Settings{}) 41 | expected = Settings.clear_seed(settings) 42 | 43 | assert {:ok, ^expected} = process_command("d", settings) 44 | end 45 | 46 | test "f runs only failed tests" do 47 | settings = %Settings{} 48 | expected = Settings.only_failed(settings) 49 | 50 | assert {:ok, ^expected} = process_command("f", settings) 51 | end 52 | 53 | test "i includes the given tags" do 54 | settings = %Settings{} 55 | expected = Settings.with_includes(settings, ["tag1", "tag2"]) 56 | 57 | assert {:ok, ^expected} = process_command("i tag1 tag2", settings) 58 | end 59 | 60 | test "i with no tags clears the includes" do 61 | {:ok, settings} = process_command("i tag1", %Settings{}) 62 | expected = Settings.clear_includes(settings) 63 | 64 | assert {:ok, ^expected} = process_command("i", settings) 65 | end 66 | 67 | test "m sets max-failures" do 68 | settings = %Settings{} 69 | expected = Settings.with_max_failures(settings, "4") 70 | 71 | assert {:ok, ^expected} = process_command("m 4", settings) 72 | end 73 | 74 | test "m with no seed clears max-failures" do 75 | {:ok, settings} = process_command("m 1", %Settings{}) 76 | expected = Settings.clear_max_failures(settings) 77 | 78 | assert {:ok, ^expected} = process_command("m", settings) 79 | end 80 | 81 | test "o runs with only the given tags" do 82 | settings = %Settings{} 83 | expected = Settings.with_only(settings, ["tag1", "tag2"]) 84 | 85 | assert {:ok, ^expected} = process_command("o tag1 tag2", settings) 86 | end 87 | 88 | test "o with no tags clears the only" do 89 | {:ok, settings} = process_command("o tag1", %Settings{}) 90 | expected = Settings.clear_only(settings) 91 | 92 | assert {:ok, ^expected} = process_command("o", settings) 93 | end 94 | 95 | test "p filters test files to those matching provided pattern" do 96 | settings = %Settings{} 97 | expected = Settings.only_patterns(settings, ["pattern"]) 98 | 99 | assert {:ok, ^expected} = process_command("p pattern", settings) 100 | end 101 | 102 | test "p a second time replaces patterns with new ones" do 103 | settings = %Settings{} 104 | {:ok, first_config} = process_command("p first", %Settings{}) 105 | expected = Settings.only_patterns(settings, ["second"]) 106 | 107 | assert {:ok, ^expected} = process_command("p second", first_config) 108 | end 109 | 110 | test "r sets the repeat until failure count" do 111 | settings = %Settings{} 112 | expected = Settings.with_repeat_count(settings, "4200") 113 | 114 | assert {:ok, ^expected} = process_command("r 4200", settings) 115 | end 116 | 117 | test "r with no count clears the repeat until failure count" do 118 | {:ok, settings} = process_command("r 1000", %Settings{}) 119 | expected = Settings.clear_repeat_count(settings) 120 | 121 | assert {:ok, ^expected} = process_command("r", settings) 122 | end 123 | 124 | test "s runs only stale tests" do 125 | settings = %Settings{} 126 | expected = Settings.only_stale(settings) 127 | 128 | assert {:ok, ^expected} = process_command("s", settings) 129 | end 130 | 131 | test "t toggles tracing" do 132 | settings = %Settings{} 133 | expected = Settings.toggle_tracing(settings) 134 | 135 | assert {:ok, ^expected} = process_command("t", settings) 136 | end 137 | 138 | test "w toggles watch mode" do 139 | settings = %Settings{} 140 | expected = Settings.toggle_watch_mode(settings) 141 | 142 | assert {:no_run, ^expected} = process_command("w", settings) 143 | end 144 | 145 | test "x excludes the given tags" do 146 | settings = %Settings{} 147 | expected = Settings.with_excludes(settings, ["tag1", "tag2"]) 148 | 149 | assert {:ok, ^expected} = process_command("x tag1 tag2", settings) 150 | end 151 | 152 | test "x with no tags clears the excludes" do 153 | {:ok, settings} = process_command("x tag1", %Settings{}) 154 | expected = Settings.clear_excludes(settings) 155 | 156 | assert {:ok, ^expected} = process_command("x", settings) 157 | end 158 | 159 | test "? returns :help" do 160 | settings = %Settings{} 161 | 162 | assert :help = process_command("?", settings) 163 | end 164 | 165 | test "trims whitespace from commands" do 166 | assert :quit = process_command("\t q \n \t") 167 | end 168 | end 169 | 170 | describe "usage information" do 171 | test "shows relevant commands when running all tests" do 172 | settings = %Settings{} 173 | 174 | assert_commands(settings, ["p ", "s", "f"], ~w(a)) 175 | end 176 | 177 | test "shows relevant commands when filtering by pattern" do 178 | settings = 179 | Settings.only_patterns(%Settings{}, ["pattern"]) 180 | 181 | assert_commands(settings, ["p ", "s", "f", "a"], ~w(p)) 182 | end 183 | 184 | test "shows relevant commands when running failed tests" do 185 | settings = 186 | Settings.only_failed(%Settings{}) 187 | 188 | assert_commands(settings, ["p ", "s", "a"], ~w(f)) 189 | end 190 | 191 | test "shows relevant commands when running stale tests" do 192 | settings = 193 | Settings.only_stale(%Settings{}) 194 | 195 | assert_commands(settings, ["p ", "f", "a"], ~w(s)) 196 | end 197 | 198 | defp assert_commands(settings, included, excluded) do 199 | included = included ++ ~w(Enter ? q) 200 | usage = CommandProcessor.usage(settings) 201 | 202 | assert contains?(usage, "Usage:\n") 203 | 204 | for command <- included do 205 | assert contains?(usage, command) 206 | end 207 | 208 | for command <- excluded do 209 | refute contains?(usage, command) 210 | end 211 | end 212 | 213 | defp contains?([], _string), do: false 214 | 215 | defp contains?([h | t], string) do 216 | contains?(h, string) || contains?(t, string) 217 | end 218 | 219 | defp contains?(usage, string) when is_binary(usage) do 220 | usage == string 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /test/mix_test_interactive/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.Config 5 | 6 | describe "loading from the environment" do 7 | test "takes :ansi_enabled? from the env" do 8 | Process.put(:os_type, {:unix, :darwin}) 9 | Process.put(:ansi_enabled, false) 10 | config = Config.load_from_environment() 11 | refute config.ansi_enabled? 12 | end 13 | 14 | test "defaults :ansi_enabled? to false on Windows" do 15 | Process.put(:os_type, {:win32, :nt}) 16 | config = Config.load_from_environment() 17 | refute config.ansi_enabled? 18 | end 19 | 20 | test "defaults :ansi_enabled? to true on other platforms" do 21 | Process.put(:os_type, {:unix, :darwin}) 22 | config = Config.load_from_environment() 23 | assert config.ansi_enabled? 24 | end 25 | 26 | test "takes :clear? from the env" do 27 | Process.put(:clear, true) 28 | config = Config.load_from_environment() 29 | assert config.clear? 30 | end 31 | 32 | test "takes :command as a string from the env" do 33 | command = "/path/to/command" 34 | 35 | Process.put(:command, command) 36 | config = Config.load_from_environment() 37 | assert config.command == {command, []} 38 | end 39 | 40 | test "takes :command as a tuple from the env" do 41 | command = {"command", ["arg1", "arg2"]} 42 | 43 | Process.put(:command, command) 44 | config = Config.load_from_environment() 45 | assert config.command == command 46 | end 47 | 48 | test "raises an error if :command is invalid" do 49 | Process.put(:command, ["invalid_command", "arg1", "arg2"]) 50 | 51 | assert_raise ArgumentError, fn -> 52 | Config.load_from_environment() 53 | end 54 | end 55 | 56 | test "defaults :command to `{\"mix\", []}`" do 57 | config = Config.load_from_environment() 58 | assert config.command == {"mix", []} 59 | end 60 | 61 | test "takes :exclude from the env" do 62 | Process.put(:exclude, [~r/migration_.*/]) 63 | config = Config.load_from_environment() 64 | assert config.exclude == [~r/migration_.*/] 65 | end 66 | 67 | test ":exclude contains common editor temp/swap files by default" do 68 | config = Config.load_from_environment() 69 | # Emacs lock symlink 70 | assert ~r/\.#/ in config.exclude 71 | end 72 | 73 | test "excludes default Phoenix migrations directory by default" do 74 | config = Config.load_from_environment() 75 | assert ~r{priv/repo/migrations} in config.exclude 76 | end 77 | 78 | test "takes :extra_extensions from the env" do 79 | Process.put(:extra_extensions, [".haml"]) 80 | config = Config.load_from_environment() 81 | assert config.extra_extensions == [".haml"] 82 | end 83 | 84 | test "takes :show_timestamps? from the env" do 85 | Process.put(:timestamp, true) 86 | config = Config.load_from_environment() 87 | assert config.show_timestamp? 88 | end 89 | 90 | test "takes :task from the env" do 91 | Process.put(:task, :env_task) 92 | config = Config.load_from_environment() 93 | assert config.task == :env_task 94 | end 95 | 96 | test ~s(defaults :task to "test") do 97 | config = Config.load_from_environment() 98 | assert config.task == "test" 99 | end 100 | 101 | test "takes :verbose from the env" do 102 | Process.put(:verbose, true) 103 | config = Config.load_from_environment() 104 | assert config.verbose? 105 | end 106 | 107 | test "defaults verbose to false" do 108 | config = Config.load_from_environment() 109 | refute config.verbose? 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/mix_test_interactive/end_to_end_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.EndToEndTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.Config 5 | alias MixTestInteractive.InteractiveMode 6 | alias MixTestInteractive.Settings 7 | 8 | defmodule DummyRunner do 9 | @moduledoc false 10 | @behaviour MixTestInteractive.TestRunner 11 | 12 | use Agent 13 | 14 | alias MixTestInteractive.TestRunner 15 | 16 | def start_link(test_pid) do 17 | Agent.start_link(fn -> test_pid end, name: __MODULE__) 18 | end 19 | 20 | @impl TestRunner 21 | def run(config, args) do 22 | Agent.update(__MODULE__, fn test_pid -> 23 | send(test_pid, {config, args}) 24 | test_pid 25 | end) 26 | 27 | :ok 28 | end 29 | end 30 | 31 | @config %Config{runner: DummyRunner} 32 | @settings %Settings{} 33 | 34 | setup do 35 | {:ok, io} = StringIO.open("") 36 | Process.group_leader(self(), io) 37 | 38 | _pid = start_supervised!({DummyRunner, self()}) 39 | pid = start_supervised!({InteractiveMode, config: @config, name: :end_to_end, settings: @settings}) 40 | 41 | %{pid: pid} 42 | end 43 | 44 | test "failed/stale/pattern workflow", %{pid: pid} do 45 | assert_ran_tests() 46 | 47 | assert :ok = InteractiveMode.process_command(pid, "") 48 | assert_ran_tests() 49 | 50 | assert :ok = InteractiveMode.process_command(pid, "p test_file:42") 51 | assert_ran_tests(["test_file:42"]) 52 | 53 | assert :ok = InteractiveMode.process_command(pid, "f") 54 | assert_ran_tests(["--failed"]) 55 | 56 | assert :ok = InteractiveMode.process_command(pid, "a") 57 | assert_ran_tests() 58 | 59 | assert :ok = InteractiveMode.process_command(pid, "s") 60 | assert_ran_tests(["--stale"]) 61 | 62 | assert :ok = InteractiveMode.note_file_changed(pid) 63 | assert_ran_tests(["--stale"]) 64 | end 65 | 66 | test "max failures workflow", %{pid: pid} do 67 | assert_ran_tests() 68 | 69 | assert :ok = InteractiveMode.process_command(pid, "m 3") 70 | assert_ran_tests(["--max-failures", "3"]) 71 | 72 | assert :ok = InteractiveMode.process_command(pid, "m") 73 | assert_ran_tests() 74 | end 75 | 76 | test "repeat until failure workflow", %{pid: pid} do 77 | assert_ran_tests() 78 | 79 | assert :ok = InteractiveMode.process_command(pid, "r 1000") 80 | assert_ran_tests(["--repeat-until-failure", "1000"]) 81 | 82 | assert :ok = InteractiveMode.process_command(pid, "r") 83 | assert_ran_tests() 84 | end 85 | 86 | test "seed workflow", %{pid: pid} do 87 | assert_ran_tests() 88 | 89 | assert :ok = InteractiveMode.process_command(pid, "d 4242") 90 | assert_ran_tests(["--seed", "4242"]) 91 | 92 | assert :ok = InteractiveMode.note_file_changed(pid) 93 | assert_ran_tests(["--seed", "4242"]) 94 | 95 | assert :ok = InteractiveMode.process_command(pid, "s") 96 | assert_ran_tests(["--seed", "4242", "--stale"]) 97 | 98 | assert :ok = InteractiveMode.process_command(pid, "d") 99 | assert_ran_tests(["--stale"]) 100 | end 101 | 102 | test "tag workflow", %{pid: pid} do 103 | assert_ran_tests() 104 | 105 | assert :ok = InteractiveMode.process_command(pid, "i tag1 tag2") 106 | assert_ran_tests(["--include", "tag1", "--include", "tag2"]) 107 | 108 | assert :ok = InteractiveMode.process_command(pid, "o tag3") 109 | assert_ran_tests(["--include", "tag1", "--include", "tag2", "--only", "tag3"]) 110 | 111 | assert :ok = InteractiveMode.process_command(pid, "x tag4 tag5") 112 | 113 | assert_ran_tests([ 114 | "--exclude", 115 | "tag4", 116 | "--exclude", 117 | "tag5", 118 | "--include", 119 | "tag1", 120 | "--include", 121 | "tag2", 122 | "--only", 123 | "tag3" 124 | ]) 125 | 126 | assert :ok = InteractiveMode.process_command(pid, "o") 127 | assert_ran_tests(["--exclude", "tag4", "--exclude", "tag5", "--include", "tag1", "--include", "tag2"]) 128 | 129 | assert :ok = InteractiveMode.process_command(pid, "i") 130 | assert_ran_tests(["--exclude", "tag4", "--exclude", "tag5"]) 131 | 132 | assert :ok = InteractiveMode.process_command(pid, "x") 133 | assert_ran_tests() 134 | end 135 | 136 | test "trace on/off workflow", %{pid: pid} do 137 | assert_ran_tests() 138 | 139 | assert :ok = InteractiveMode.process_command(pid, "t") 140 | assert_ran_tests(["--trace"]) 141 | 142 | assert :ok = InteractiveMode.process_command(pid, "t") 143 | assert_ran_tests() 144 | end 145 | 146 | test "watch on/off workflow", %{pid: pid} do 147 | assert_ran_tests() 148 | 149 | assert :ok = InteractiveMode.process_command(pid, "w") 150 | refute_ran_tests() 151 | 152 | assert :ok = InteractiveMode.note_file_changed(pid) 153 | refute_ran_tests() 154 | 155 | assert :ok = InteractiveMode.process_command(pid, "w") 156 | refute_ran_tests() 157 | 158 | assert :ok = InteractiveMode.note_file_changed(pid) 159 | assert_ran_tests() 160 | end 161 | 162 | defp assert_ran_tests(args \\ []) do 163 | assert_receive {@config, ^args}, 100 164 | end 165 | 166 | defp refute_ran_tests do 167 | refute_receive _, 100 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /test/mix_test_interactive/message_inbox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.MessageInboxTest do 2 | use ExUnit.Case 3 | 4 | alias MixTestInteractive.MessageInbox 5 | 6 | test "flush clears the process inbox of messages" do 7 | Enum.each(1..10, &send(self(), &1)) 8 | MessageInbox.flush() 9 | refute_received _any_messages 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/mix_test_interactive/paths_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.PathsTest do 2 | use ExUnit.Case 3 | 4 | alias MixTestInteractive.Config 5 | alias MixTestInteractive.Paths 6 | 7 | test ".ex files are watched" do 8 | assert watching?("foo.ex") 9 | end 10 | 11 | test ".exs files are watched" do 12 | assert watching?("foo.exs") 13 | end 14 | 15 | test ".eex files are watched" do 16 | assert watching?("foo.eex") 17 | end 18 | 19 | test ".leex files are watched" do 20 | assert watching?("foo.leex") 21 | end 22 | 23 | test ".heex files are watched" do 24 | assert watching?("foo.heex") 25 | end 26 | 27 | test ".erl files are watched" do 28 | assert watching?("foo.erl") 29 | end 30 | 31 | test ".xrl files are watched" do 32 | assert watching?("foo.xrl") 33 | end 34 | 35 | test ".yrl files are watched" do 36 | assert watching?("foo.yrl") 37 | end 38 | 39 | test ".hrl files are watched" do 40 | assert watching?("foo.hrl") 41 | end 42 | 43 | test "extra extensions are watched" do 44 | config = %Config{extra_extensions: [".ex", ".haml", ".foo", ".txt"]} 45 | assert watching?("foo.ex", config) 46 | assert watching?("index.html.haml", config) 47 | assert watching?("my.foo", config) 48 | assert watching?("best.txt", config) 49 | end 50 | 51 | test "misc files are not watched" do 52 | refute watching?("foo.rb") 53 | refute watching?("foo.js") 54 | refute watching?("foo.css") 55 | refute watching?("foo.lock") 56 | refute watching?("foo.html") 57 | refute watching?("foo.yaml") 58 | refute watching?("foo.json") 59 | end 60 | 61 | test "_build directory is not watched" do 62 | refute watching?("_build/dev/lib/mix_test_watch/ebin/mix_test_watch.ex") 63 | refute watching?("_build/dev/lib/mix_test_watch/ebin/mix_test_watch.exs") 64 | refute watching?("_build/dev/lib/mix_test_watch/ebin/mix_test_watch.eex") 65 | end 66 | 67 | test "deps directory is not watched" do 68 | refute watching?("deps/dogma/lib/dogma.ex") 69 | refute watching?("deps/dogma/lib/dogma.exs") 70 | refute watching?("deps/dogma/lib/dogma.eex") 71 | end 72 | 73 | test "migrations_.* files should be excluded watched" do 74 | refute watching?("migrations_files/foo.exs", %Config{exclude: [~r/migrations_.*/]}) 75 | end 76 | 77 | test "app.ex is not excluded by migrations_.* pattern" do 78 | assert watching?("app.ex", %Config{exclude: [~r/migrations_.*/]}) 79 | end 80 | 81 | defp watching?(path, config \\ %Config{}) do 82 | Paths.watching?(path, config) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/mix_test_interactive/pattern_filter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.PatternFilterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.PatternFilter 5 | 6 | @abc "test/project/abc_test.exs" 7 | @bcd "test/project/bcd_test.exs" 8 | @cde "test/project/cde_test.exs" 9 | 10 | @files [@abc, @bcd, @cde] 11 | 12 | test "returns files matching a pattern" do 13 | matches = PatternFilter.matches(@files, "bc") 14 | 15 | assert matches == [@abc, @bcd] 16 | end 17 | 18 | test "returns empty list if no matches" do 19 | matches = PatternFilter.matches(@files, "xyz") 20 | 21 | assert matches == [] 22 | end 23 | 24 | test "returns files matching at least one of multiple patterns" do 25 | matches = PatternFilter.matches(@files, ["ab", "de"]) 26 | 27 | assert matches == [@abc, @cde] 28 | end 29 | 30 | test "returns multiple file with line number patterns" do 31 | patterns = ["some_test.exs:42", "other_test.exs:58"] 32 | matches = PatternFilter.matches(@files, patterns) 33 | 34 | assert matches == patterns 35 | end 36 | 37 | test "returns a mix of matching files and files with line numbers" do 38 | patterns = ["some_test.exs:42", "bc", "other_test.exs:58"] 39 | matches = PatternFilter.matches(@files, patterns) 40 | 41 | assert matches == [@abc, @bcd, "some_test.exs:42", "other_test.exs:58"] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/mix_test_interactive/port_runner_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.PortRunnerTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias MixTestInteractive.Config 7 | alias MixTestInteractive.PortRunner 8 | 9 | @unix {:unix, :darwin} 10 | @windows {:win32, :nt} 11 | 12 | defp config(overrides \\ %{}) do 13 | Map.merge(%Config{ansi_enabled?: false}, overrides) 14 | end 15 | 16 | defp run(options \\ []) do 17 | case run_raw(options) do 18 | :no_message_received = result -> 19 | result 20 | 21 | {command, args, options} = result -> 22 | if command =~ ~r{/zombie_killer$} do 23 | [real_command | rest] = args 24 | {real_command, rest, options} 25 | else 26 | result 27 | end 28 | end 29 | end 30 | 31 | defp run_raw(options \\ []) do 32 | config = Keyword.get(options, :config, config()) 33 | args = Keyword.get(options, :args, []) 34 | 35 | runner = fn command, args, options -> 36 | send(self(), {command, args, options}) 37 | end 38 | 39 | PortRunner.run(config, args, runner) 40 | 41 | receive do 42 | message -> message 43 | after 44 | 0 -> :no_message_received 45 | end 46 | end 47 | 48 | test "on Unix-like operating systems, runs mix test via zombie killer" do 49 | Process.put(:os_type, @unix) 50 | 51 | {command, ["mix", "test"], _options} = run_raw() 52 | 53 | assert command =~ ~r{/zombie_killer$} 54 | end 55 | 56 | test "on Windows, runs mix test directly" do 57 | Process.put(:os_type, @windows) 58 | 59 | assert {"mix", ["test"], _options} = run_raw() 60 | end 61 | 62 | for os_type <- [@unix, @windows] do 63 | describe "running on #{inspect(os_type)}" do 64 | setup do 65 | Process.put(:os_type, unquote(os_type)) 66 | :ok 67 | end 68 | 69 | test "runs in test environment" do 70 | {_command, _args, options} = run() 71 | 72 | assert Keyword.get(options, :env) == [{"MIX_ENV", "test"}] 73 | end 74 | 75 | test "enables ansi output when turned on" do 76 | config = config(%{ansi_enabled?: true}) 77 | {"mix", ["do", "eval", ansi, ",", "test"], _options} = run(config: config) 78 | 79 | assert ansi =~ ~r/:ansi_enabled/ 80 | end 81 | 82 | test "passes no-start flag to test task" do 83 | assert {_command, ["test", "--no-start"], _options} = run(args: ["--no-start"]) 84 | end 85 | 86 | test "appends extra command-line arguments" do 87 | assert {_command, ["test", "--cover"], _options} = run(args: ["--cover"]) 88 | end 89 | 90 | test "uses custom task" do 91 | config = config(%{task: "custom_task"}) 92 | assert {_command, ["custom_task"], _options} = run(config: config) 93 | end 94 | 95 | test "uses custom command with no args" do 96 | config = config(%{command: {"custom_command", []}}) 97 | assert {"custom_command", _args, _options} = run(config: config) 98 | end 99 | 100 | test "uses custom command with args" do 101 | config = config(%{command: {"custom_command", ["--custom_arg"]}}) 102 | assert {"custom_command", ["--custom_arg", "test"], _options} = run(config: config) 103 | end 104 | 105 | test "prepends command args to test args" do 106 | config = config(%{command: {"custom_command", ["--custom_arg"]}}) 107 | 108 | assert {"custom_command", ["--custom_arg", "test", "--cover"], _options} = 109 | run(args: ["--cover"], config: config) 110 | end 111 | 112 | test "does not display command by default" do 113 | {result, output} = with_io(fn -> run() end) 114 | 115 | assert {"mix", _args, _env} = result 116 | assert output == "" 117 | end 118 | 119 | test "displays command in verbose mode" do 120 | config = config(%{verbose?: true}) 121 | 122 | {result, output} = with_io(fn -> run(config: config) end) 123 | 124 | assert {"mix", _args, _env} = result 125 | assert output =~ "mix test" 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/mix_test_interactive/run_summary_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.RunSummaryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.RunSummary 5 | alias MixTestInteractive.Settings 6 | 7 | describe "summarizing a test run" do 8 | test "ran all tests" do 9 | settings = %Settings{} 10 | 11 | assert RunSummary.from_settings(settings) == "Ran all tests" 12 | end 13 | 14 | test "ran failed tests" do 15 | settings = Settings.only_failed(%Settings{}) 16 | 17 | assert RunSummary.from_settings(settings) == "Ran only failed tests" 18 | end 19 | 20 | test "ran stale tests" do 21 | settings = Settings.only_stale(%Settings{}) 22 | 23 | assert RunSummary.from_settings(settings) == "Ran only stale tests" 24 | end 25 | 26 | test "ran specific patterns" do 27 | settings = 28 | Settings.only_patterns(%Settings{}, ["p1", "p2"]) 29 | 30 | assert RunSummary.from_settings(settings) == "Ran all test files matching p1, p2" 31 | end 32 | 33 | test "appends max failures" do 34 | settings = Settings.with_max_failures(%Settings{}, "6") 35 | 36 | assert RunSummary.from_settings(settings) =~ "Max failures: 6" 37 | end 38 | 39 | test "appends repeat count" do 40 | settings = Settings.with_repeat_count(%Settings{}, "150") 41 | 42 | assert RunSummary.from_settings(settings) =~ "Repeat until failure: 150" 43 | end 44 | 45 | test "appends seed" do 46 | settings = Settings.with_seed(%Settings{}, "4242") 47 | 48 | assert RunSummary.from_settings(settings) =~ "Seed: 4242" 49 | end 50 | 51 | test "appends tag filters" do 52 | settings = 53 | %Settings{} 54 | |> Settings.with_excludes(["tag1", "tag2"]) 55 | |> Settings.with_includes(["tag3", "tag4"]) 56 | |> Settings.with_only(["tag5", "tag6"]) 57 | 58 | summary = RunSummary.from_settings(settings) 59 | 60 | assert summary =~ ~s(Excluding tags: ["tag1", "tag2"]) 61 | assert summary =~ ~s(Including tags: ["tag3", "tag4"]) 62 | assert summary =~ ~s(Only tags: ["tag5", "tag6"]) 63 | end 64 | 65 | test "appends tracing" do 66 | settings = Settings.toggle_tracing(%Settings{}) 67 | 68 | assert RunSummary.from_settings(settings) =~ "Tracing: ON" 69 | end 70 | 71 | test "includes only relevant information with no extra blank lines" do 72 | settings = 73 | %Settings{} 74 | |> Settings.only_stale() 75 | |> Settings.toggle_tracing() 76 | |> Settings.with_only(["tag1", "tag2"]) 77 | |> Settings.with_seed("4258") 78 | 79 | expected = """ 80 | Ran only stale tests 81 | Only tags: ["tag1", "tag2"] 82 | Seed: 4258 83 | Tracing: ON 84 | """ 85 | 86 | assert RunSummary.from_settings(settings) == String.trim(expected) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/mix_test_interactive/runner_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.RunnerTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias MixTestInteractive.Config 7 | alias MixTestInteractive.Runner 8 | 9 | defmodule DummyRunner do 10 | @moduledoc false 11 | @behaviour MixTestInteractive.TestRunner 12 | 13 | use Agent 14 | 15 | alias MixTestInteractive.TestRunner 16 | 17 | def start_link(initial_state) do 18 | Agent.start_link(fn -> initial_state end, name: __MODULE__) 19 | end 20 | 21 | @impl TestRunner 22 | def run(config, args) do 23 | Agent.get_and_update(__MODULE__, fn data -> {:ok, [{config, args} | data]} end) 24 | end 25 | end 26 | 27 | setup do 28 | _pid = start_supervised!({DummyRunner, []}) 29 | :ok 30 | end 31 | 32 | describe "run/1" do 33 | test "It delegates to the runner specified by the config" do 34 | config = %Config{runner: DummyRunner} 35 | args = ["--cover", "--raise"] 36 | 37 | output = 38 | capture_io(fn -> 39 | Runner.run(config, args) 40 | end) 41 | 42 | assert Agent.get(DummyRunner, fn x -> x end) == [{config, args}] 43 | 44 | assert output =~ "Running tests..." 45 | end 46 | 47 | test "It outputs timestamp when specified by the config" do 48 | config = %Config{runner: DummyRunner, show_timestamp?: true} 49 | 50 | output = 51 | capture_io(fn -> 52 | Runner.run(config, []) 53 | end) 54 | 55 | assert Agent.get(DummyRunner, fn x -> x end) == [{config, []}] 56 | 57 | timestamp = 58 | output 59 | |> String.split("\n", trim: true) 60 | |> List.last() 61 | 62 | assert {:ok, _} = NaiveDateTime.from_iso8601(timestamp) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/mix_test_interactive/settings_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.SettingsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.Settings 5 | 6 | describe "filtering test files" do 7 | test "filters to files matching patterns" do 8 | all_files = ~w(file1 file2 no_match other) 9 | 10 | settings = 11 | %Settings{initial_cli_args: ["--color"]} 12 | |> with_fake_file_list(all_files) 13 | |> Settings.only_patterns(["file", "other"]) 14 | 15 | {:ok, args} = Settings.cli_args(settings) 16 | assert args == ["--color", "file1", "file2", "other"] 17 | end 18 | 19 | test "returns error if no files match pattern" do 20 | settings = 21 | %Settings{} 22 | |> with_fake_file_list([]) 23 | |> Settings.only_patterns(["file"]) 24 | 25 | assert {:error, :no_matching_files} = Settings.cli_args(settings) 26 | end 27 | 28 | test "restricts to failed tests" do 29 | settings = 30 | Settings.only_failed(%Settings{initial_cli_args: ["--color"]}) 31 | 32 | {:ok, args} = Settings.cli_args(settings) 33 | assert args == ["--color", "--failed"] 34 | end 35 | 36 | test "restricts to stale tests" do 37 | settings = 38 | Settings.only_stale(%Settings{initial_cli_args: ["--color"]}) 39 | 40 | {:ok, args} = Settings.cli_args(settings) 41 | assert args == ["--color", "--stale"] 42 | end 43 | 44 | test "pattern filter clears failed flag" do 45 | settings = 46 | %Settings{} 47 | |> with_fake_file_list(["file"]) 48 | |> Settings.only_failed() 49 | |> Settings.only_patterns(["f"]) 50 | 51 | {:ok, args} = Settings.cli_args(settings) 52 | assert args == ["file"] 53 | end 54 | 55 | test "pattern filter clears stale flag" do 56 | settings = 57 | %Settings{} 58 | |> with_fake_file_list(["file"]) 59 | |> Settings.only_stale() 60 | |> Settings.only_patterns(["f"]) 61 | 62 | {:ok, args} = Settings.cli_args(settings) 63 | assert args == ["file"] 64 | end 65 | 66 | test "failed flag clears pattern filters" do 67 | settings = 68 | %Settings{} 69 | |> Settings.only_patterns(["file"]) 70 | |> Settings.only_failed() 71 | 72 | {:ok, args} = Settings.cli_args(settings) 73 | assert args == ["--failed"] 74 | end 75 | 76 | test "failed flag clears stale flag" do 77 | settings = 78 | %Settings{} 79 | |> Settings.only_stale() 80 | |> Settings.only_failed() 81 | 82 | {:ok, args} = Settings.cli_args(settings) 83 | assert args == ["--failed"] 84 | end 85 | 86 | test "stale flag clears pattern filters" do 87 | settings = 88 | %Settings{} 89 | |> Settings.only_patterns(["file"]) 90 | |> Settings.only_stale() 91 | 92 | {:ok, args} = Settings.cli_args(settings) 93 | assert args == ["--stale"] 94 | end 95 | 96 | test "stale flag clears failed flag" do 97 | settings = 98 | %Settings{} 99 | |> Settings.only_failed() 100 | |> Settings.only_stale() 101 | 102 | {:ok, args} = Settings.cli_args(settings) 103 | assert args == ["--stale"] 104 | end 105 | 106 | test "all tests clears pattern filters" do 107 | settings = 108 | %Settings{} 109 | |> Settings.only_patterns(["pattern"]) 110 | |> Settings.all_tests() 111 | 112 | {:ok, args} = Settings.cli_args(settings) 113 | assert args == [] 114 | end 115 | 116 | test "all tests removes stale flag" do 117 | settings = 118 | %Settings{} 119 | |> Settings.only_stale() 120 | |> Settings.all_tests() 121 | 122 | {:ok, args} = Settings.cli_args(settings) 123 | assert args == [] 124 | end 125 | 126 | test "all tests removes failed flag" do 127 | settings = 128 | %Settings{} 129 | |> Settings.only_failed() 130 | |> Settings.all_tests() 131 | 132 | {:ok, args} = Settings.cli_args(settings) 133 | assert args == [] 134 | end 135 | 136 | defp with_fake_file_list(settings, files) do 137 | Settings.list_files_with(settings, fn -> files end) 138 | end 139 | end 140 | 141 | describe "filtering tests by tags" do 142 | test "excludes specified tags" do 143 | tags = ["tag1", "tag2"] 144 | settings = Settings.with_excludes(%Settings{initial_cli_args: ["--color"]}, tags) 145 | 146 | {:ok, args} = Settings.cli_args(settings) 147 | assert args == ["--color", "--exclude", "tag1", "--exclude", "tag2"] 148 | end 149 | 150 | test "clears excluded tags" do 151 | settings = 152 | %Settings{} 153 | |> Settings.with_excludes(["tag1"]) 154 | |> Settings.clear_excludes() 155 | 156 | {:ok, args} = Settings.cli_args(settings) 157 | assert args == [] 158 | end 159 | 160 | test "includes specified tags" do 161 | tags = ["tag1", "tag2"] 162 | settings = Settings.with_includes(%Settings{initial_cli_args: ["--color"]}, tags) 163 | 164 | {:ok, args} = Settings.cli_args(settings) 165 | assert args == ["--color", "--include", "tag1", "--include", "tag2"] 166 | end 167 | 168 | test "clears included tags" do 169 | settings = 170 | %Settings{} 171 | |> Settings.with_includes(["tag1"]) 172 | |> Settings.clear_includes() 173 | 174 | {:ok, args} = Settings.cli_args(settings) 175 | assert args == [] 176 | end 177 | 178 | test "runs only specified tags" do 179 | tags = ["tag1", "tag2"] 180 | settings = Settings.with_only(%Settings{initial_cli_args: ["--color"]}, tags) 181 | 182 | {:ok, args} = Settings.cli_args(settings) 183 | assert args == ["--color", "--only", "tag1", "--only", "tag2"] 184 | end 185 | 186 | test "clears only tags" do 187 | settings = 188 | %Settings{} 189 | |> Settings.with_only(["tag1"]) 190 | |> Settings.clear_only() 191 | 192 | {:ok, args} = Settings.cli_args(settings) 193 | assert args == [] 194 | end 195 | end 196 | 197 | describe "specifying maximum failures" do 198 | test "stops after a specified number of failures" do 199 | max = "3" 200 | settings = Settings.with_max_failures(%Settings{initial_cli_args: ["--color"]}, max) 201 | 202 | {:ok, args} = Settings.cli_args(settings) 203 | assert args == ["--color", "--max-failures", max] 204 | end 205 | 206 | test "clears maximum failures" do 207 | settings = 208 | %Settings{} 209 | |> Settings.with_max_failures("2") 210 | |> Settings.clear_max_failures() 211 | 212 | {:ok, args} = Settings.cli_args(settings) 213 | assert args == [] 214 | end 215 | end 216 | 217 | describe "repeating until failure" do 218 | test "re-runs up to specified times until failure" do 219 | count = "56" 220 | settings = Settings.with_repeat_count(%Settings{initial_cli_args: ["--color"]}, count) 221 | 222 | {:ok, args} = Settings.cli_args(settings) 223 | assert args == ["--color", "--repeat-until-failure", count] 224 | end 225 | 226 | test "clears the repeat count" do 227 | settings = 228 | %Settings{} 229 | |> Settings.with_repeat_count("12") 230 | |> Settings.clear_repeat_count() 231 | 232 | {:ok, args} = Settings.cli_args(settings) 233 | assert args == [] 234 | end 235 | end 236 | 237 | describe "specifying the seed" do 238 | test "runs with seed" do 239 | seed = "5678" 240 | settings = Settings.with_seed(%Settings{initial_cli_args: ["--color"]}, seed) 241 | 242 | {:ok, args} = Settings.cli_args(settings) 243 | assert args == ["--color", "--seed", seed] 244 | end 245 | 246 | test "clears the seed" do 247 | settings = 248 | %Settings{} 249 | |> Settings.with_seed("1234") 250 | |> Settings.clear_seed() 251 | 252 | {:ok, args} = Settings.cli_args(settings) 253 | assert args == [] 254 | end 255 | end 256 | 257 | describe "tracing the test run" do 258 | test "toggles tracing on" do 259 | settings = Settings.toggle_tracing(%Settings{initial_cli_args: ["--color"]}) 260 | 261 | {:ok, args} = Settings.cli_args(settings) 262 | assert args == ["--color", "--trace"] 263 | end 264 | 265 | test "toggles tracing off" do 266 | settings = 267 | %Settings{} 268 | |> Settings.toggle_tracing() 269 | |> Settings.toggle_tracing() 270 | 271 | {:ok, args} = Settings.cli_args(settings) 272 | assert args == [] 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /test/mix_test_interactive/test_files_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTestInteractive.TestFilesTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias MixTestInteractive.TestFiles 5 | 6 | test "returns all test files for current project" do 7 | files = TestFiles.list() 8 | this_file = Path.relative_to_cwd(__ENV__.file) 9 | 10 | assert this_file in files 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------