mighty_test 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +257 -0
- data/exe/mt +4 -0
- data/lib/mighty_test/cli.rb +99 -0
- data/lib/mighty_test/console.rb +58 -0
- data/lib/mighty_test/file_system.rb +41 -0
- data/lib/mighty_test/minitest_runner.rb +22 -0
- data/lib/mighty_test/option_parser.rb +77 -0
- data/lib/mighty_test/sharder.rb +46 -0
- data/lib/mighty_test/test_parser.rb +33 -0
- data/lib/mighty_test/version.rb +3 -0
- data/lib/mighty_test/watcher.rb +140 -0
- data/lib/mighty_test.rb +11 -0
- metadata +132 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 83c44a15f432d51e09d5c772fd11bb8e6054eaf98cbcc76e08e8f8050c1e03bd
|
4
|
+
data.tar.gz: 5908e6428920eb6aedcc993a24ad44587b7e6355f9002b07358c095717f865f6
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 912d2cc77b7b732439f129ce082d140d15216221aa8fd8c4e0a6a9082c3129c238bb29f8841303d12dfe6c43b96f4c37f76090bada21114843b5c4db933ea9b2
|
7
|
+
data.tar.gz: 4d1468bc39fa04b7445b97bb58e559d798e3978067a29ad841ba82d3fbe2b6c893592231116f5fa41df3d567cbd352aea49b57fd098b0cf04db3d11bfb2f58e5
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Matt Brictson
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,257 @@
|
|
1
|
+
# mighty_test
|
2
|
+
|
3
|
+
[](https://rubygems.org/gems/mighty_test)
|
4
|
+
[](https://www.ruby-toolbox.com/projects/mighty_test)
|
5
|
+
[](https://github.com/mattbrictson/mighty_test/actions/workflows/ci.yml)
|
6
|
+
[](https://codeclimate.com/github/mattbrictson/mighty_test)
|
7
|
+
|
8
|
+
mighty_test (`mt`) is a TDD-friendly Minitest runner for Ruby projects. It includes a Jest-inspired interactive watch mode, focus mode, CI sharding, run by directory/file/line number, fail-fast, and color formatting.
|
9
|
+
|
10
|
+
---
|
11
|
+
|
12
|
+
**Quick Start**
|
13
|
+
|
14
|
+
- [Install](#install)
|
15
|
+
- [Requirements](#requirements)
|
16
|
+
- [Usage](#usage)
|
17
|
+
|
18
|
+
**Features**
|
19
|
+
|
20
|
+
- ⚙️ [CI Mode](#%EF%B8%8F-ci-mode)
|
21
|
+
- 🧑🔬 [Watch Mode](#-watch-mode)
|
22
|
+
- 🔬 [Focus Mode](#-focus-mode)
|
23
|
+
- 🛑 [Fail Fast](#-fail-fast)
|
24
|
+
- 🚥 [Color Output](#-color-output)
|
25
|
+
- 💬 [More Options](#-more-options)
|
26
|
+
|
27
|
+
**Community**
|
28
|
+
|
29
|
+
- [Support](#support)
|
30
|
+
- [License](#license)
|
31
|
+
- [Code of conduct](#code-of-conduct)
|
32
|
+
- [Contribution guide](#contribution-guide)
|
33
|
+
|
34
|
+
## Install
|
35
|
+
|
36
|
+
The mighty_test gem provides an `mt` binary. To install it into a Ruby project, first add the gem to your Gemfile and run `bundle install`.
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
gem "mighty_test"
|
40
|
+
```
|
41
|
+
|
42
|
+
Then generate a binstub:
|
43
|
+
|
44
|
+
```sh
|
45
|
+
bundle binstub mighty_test
|
46
|
+
```
|
47
|
+
|
48
|
+
Now you can run mighty_test with `bin/mt`.
|
49
|
+
|
50
|
+
> [!TIP]
|
51
|
+
> **When installing mighty_test in a Rails project, make sure to put the gem in the `:test` Gemfile group.** Although Rails has a built-in test runner (`bin/rails test`) that already provides a lot of what mighty_test offers, you can still use `bin/mt` with Rails projects for its unique `--watch` mode and CI `--shard` feature.
|
52
|
+
|
53
|
+
## Requirements
|
54
|
+
|
55
|
+
mighty_test requires modern versions of Minitest and Ruby.
|
56
|
+
|
57
|
+
- Minitest 5.15+
|
58
|
+
- Ruby 3.1+
|
59
|
+
|
60
|
+
Support for older Ruby versions will be dropped when they reach EOL. The EOL schedule can be found here: https://endoflife.date/ruby
|
61
|
+
|
62
|
+
> [!NOTE]
|
63
|
+
> mighty_test currently assumes that your tests are stored in `test/` and are named `*_test.rb`. Watch mode expects implementation files to be in `app/` and/or `lib/`.
|
64
|
+
|
65
|
+
## Usage
|
66
|
+
|
67
|
+
`mt` defaults to running all tests, excluding slow tests (see the explanation of slow tests below). You can also run tests by directory, file, or line number.
|
68
|
+
|
69
|
+
```sh
|
70
|
+
# Run all tests, excluding slow tests
|
71
|
+
bin/mt
|
72
|
+
|
73
|
+
# Run all tests, slow tests included
|
74
|
+
bin/mt --all
|
75
|
+
|
76
|
+
# Run a specific test file
|
77
|
+
bin/mt test/cli_test.rb
|
78
|
+
|
79
|
+
# Run a test by line number
|
80
|
+
bin/mt test/importer_test.rb:43
|
81
|
+
|
82
|
+
# Run a directory of tests
|
83
|
+
bin/mt test/commands
|
84
|
+
```
|
85
|
+
|
86
|
+
> [!TIP]
|
87
|
+
> mighty_test is optimized for TDD, and excludes slow tests by default. **Slow tests** are defined as those found in `test/{e2e,feature,features,integration,system}` directories. You can run slow tests with `--all` or by specifying a slow test file or directory explicitly, like `bin/mt test/system`.
|
88
|
+
|
89
|
+
## ⚙️ CI Mode
|
90
|
+
|
91
|
+
If the `CI` environment variable is set, mighty_test defaults to running _all_ tests, including slow tests. This is equivalent to passing `--all`.
|
92
|
+
|
93
|
+
mighty_test can also distribute test files evenly across parallel CI jobs, using the `--shard` option. The _shard_ nomenclature has been borrowed from similar features in [Jest](https://jestjs.io/docs/cli#--shard) and [Playwright](https://playwright.dev/docs/test-sharding).
|
94
|
+
|
95
|
+
```sh
|
96
|
+
# Run the 1st group of tests out of 4 total groups
|
97
|
+
bin/mt --shard 1/4
|
98
|
+
```
|
99
|
+
|
100
|
+
In GitHub Actions, for example, you can use `--shard` with a matrix strategy to easily divide tests across N jobs.
|
101
|
+
|
102
|
+
```yaml
|
103
|
+
jobs:
|
104
|
+
test:
|
105
|
+
strategy:
|
106
|
+
matrix:
|
107
|
+
shard:
|
108
|
+
- "1/4"
|
109
|
+
- "2/4"
|
110
|
+
- "3/4"
|
111
|
+
- "4/4"
|
112
|
+
steps:
|
113
|
+
- uses: actions/checkout@v4
|
114
|
+
- uses: ruby/setup-ruby@v1
|
115
|
+
with:
|
116
|
+
bundler-cache: true
|
117
|
+
- run: bin/mt --shard ${{ matrix.shard }}
|
118
|
+
```
|
119
|
+
|
120
|
+
In CircleCI, you can use the `parallelism` setting, which automatically injects `$CIRCLE_NODE_INDEX` and `$CIRCLE_NODE_TOTAL` environment variables. Note that `$CIRCLE_NODE_INDEX` is zero-indexed, so it needs to be incremented by 1.
|
121
|
+
|
122
|
+
```yaml
|
123
|
+
jobs:
|
124
|
+
test:
|
125
|
+
parallelism: 4
|
126
|
+
steps:
|
127
|
+
- checkout
|
128
|
+
- ruby/install-deps
|
129
|
+
- run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; bin/mt --shard ${SHARD}/${CIRCLE_NODE_TOTAL}
|
130
|
+
```
|
131
|
+
|
132
|
+
> [!TIP]
|
133
|
+
> `--shard` will shuffle tests and automatically distribute slow tests evenly across jobs.
|
134
|
+
|
135
|
+
## 🧑🔬 Watch Mode
|
136
|
+
|
137
|
+
mighty_test includes a Jest-style watch mode, which can be started with `--watch`. This is ideal for TDD.
|
138
|
+
|
139
|
+
```sh
|
140
|
+
# Start watch mode
|
141
|
+
bin/mt --watch
|
142
|
+
```
|
143
|
+
|
144
|
+
In watch mode, mighty_test will listen for file system activity and run a test file whenever it is modified.
|
145
|
+
|
146
|
+
When you modify an implementation file, mighty_test will find the corresponding test file and run it automatically. This works as long as your implementation and test files follow a standard path naming convention: e.g. `lib/commands/init.rb` is expected to have a corresponding test file named `test/commands/init_test.rb`.
|
147
|
+
|
148
|
+
Watch mode also offers a menu of interactive commands:
|
149
|
+
|
150
|
+
```
|
151
|
+
> Press Enter to run all tests.
|
152
|
+
> Press "a" to run all tests, including slow tests.
|
153
|
+
> Press "d" to run tests for files diffed or added since the last git commit.
|
154
|
+
> Press "h" to show this help menu.
|
155
|
+
> Press "q" to quit.
|
156
|
+
```
|
157
|
+
|
158
|
+
## 🔬 Focus Mode
|
159
|
+
|
160
|
+
You can focus a specific test by annotating the method definition with `focus`.
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
class MyTest < Minitest::Test
|
164
|
+
focus def test_something_important
|
165
|
+
assert # ...
|
166
|
+
end
|
167
|
+
```
|
168
|
+
|
169
|
+
Now running `bin/mt` will execute only the focused test:
|
170
|
+
|
171
|
+
```sh
|
172
|
+
# Only runs MyTest#test_something_important
|
173
|
+
bin/mt
|
174
|
+
```
|
175
|
+
|
176
|
+
In Rails projects that use the `test` syntax, `focus` must be placed on the previous line.
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
class MyTest < ActiveSupport::TestCase
|
180
|
+
focus
|
181
|
+
test "something important" do
|
182
|
+
assert # ...
|
183
|
+
end
|
184
|
+
```
|
185
|
+
|
186
|
+
This functionality is provided by the [minitest-focus](https://github.com/minitest/minitest-focus) plugin, which is included with mighty_test.
|
187
|
+
|
188
|
+
## 🛑 Fail Fast
|
189
|
+
|
190
|
+
By default, mighty_test runs the entire test suite to completion. With the `--fail-fast` option, it will stop on the first failed test.
|
191
|
+
|
192
|
+
```sh
|
193
|
+
# Stop immediately on first test failure
|
194
|
+
bin/mt --fail-fast
|
195
|
+
|
196
|
+
# Use with watch mode for even faster TDD
|
197
|
+
bin/mt --watch --fail-fast
|
198
|
+
```
|
199
|
+
|
200
|
+
This functionality is provided by the [minitest-fail-fast](https://github.com/teoljungberg/minitest-fail-fast) plugin, which is included with mighty_test.
|
201
|
+
|
202
|
+
## 🚥 Color Output
|
203
|
+
|
204
|
+
Successes, failures, errors, and skips are colored appropriately by default.
|
205
|
+
|
206
|
+
```sh
|
207
|
+
# Run tests with color output (if terminal supports it)
|
208
|
+
bin/mt
|
209
|
+
|
210
|
+
# Disable color
|
211
|
+
bin/mt --no-rg
|
212
|
+
```
|
213
|
+
|
214
|
+
(image goes here)
|
215
|
+
|
216
|
+
This functionality is provided by the [minitest-rg](https://github.com/minitest/minitest-rg) plugin, which is included with mighty_test.
|
217
|
+
|
218
|
+
## 💬 More Options
|
219
|
+
|
220
|
+
Minitest options are passed through to Minitest.
|
221
|
+
|
222
|
+
```sh
|
223
|
+
# Run tests with Minitest pride color output
|
224
|
+
bin/mt --pride
|
225
|
+
|
226
|
+
# Run tests with an explicit seed value for test ordering
|
227
|
+
bin/mt --seed 4519
|
228
|
+
|
229
|
+
# Run tests with detailed progress and explanation of skipped tests
|
230
|
+
bin/mt --verbose
|
231
|
+
|
232
|
+
# Show the full list of possible options
|
233
|
+
bin/mt --help
|
234
|
+
```
|
235
|
+
|
236
|
+
If you have Minitest extensions installed, like [minitest-snapshots](https://github.com/mattbrictson/minitest-snapshots), the command line options of those extensions are supported as well.
|
237
|
+
|
238
|
+
```sh
|
239
|
+
# Update snapshots
|
240
|
+
bin/mt -u
|
241
|
+
```
|
242
|
+
|
243
|
+
## Support
|
244
|
+
|
245
|
+
If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/mighty_test/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
|
246
|
+
|
247
|
+
## License
|
248
|
+
|
249
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
250
|
+
|
251
|
+
## Code of conduct
|
252
|
+
|
253
|
+
Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|
254
|
+
|
255
|
+
## Contribution guide
|
256
|
+
|
257
|
+
Pull requests are welcome!
|
data/exe/mt
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
module MightyTest
|
2
|
+
class CLI
|
3
|
+
def initialize(file_system: FileSystem.new, env: ENV, option_parser: OptionParser.new, runner: MinitestRunner.new)
|
4
|
+
@file_system = file_system
|
5
|
+
@env = env.to_h
|
6
|
+
@option_parser = option_parser
|
7
|
+
@runner = runner
|
8
|
+
end
|
9
|
+
|
10
|
+
def run(argv: ARGV)
|
11
|
+
@path_args, @extra_args, @options = option_parser.parse(argv)
|
12
|
+
|
13
|
+
if options[:help]
|
14
|
+
print_help
|
15
|
+
elsif options[:version]
|
16
|
+
puts VERSION
|
17
|
+
elsif options[:watch]
|
18
|
+
watch
|
19
|
+
elsif path_args.grep(/.:\d+$/).any?
|
20
|
+
run_test_by_line_number
|
21
|
+
else
|
22
|
+
run_tests_by_path
|
23
|
+
end
|
24
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
25
|
+
handle_exception(e)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
attr_reader :file_system, :env, :path_args, :extra_args, :options, :option_parser, :runner
|
31
|
+
|
32
|
+
def print_help
|
33
|
+
# Minitest already prints the `-h, --help` option, so omit mighty_test's
|
34
|
+
puts option_parser.to_s.sub(/^\s*-h.*?\n/, "")
|
35
|
+
puts
|
36
|
+
runner.print_help_and_exit!
|
37
|
+
end
|
38
|
+
|
39
|
+
def watch
|
40
|
+
Watcher.new(extra_args:).run
|
41
|
+
end
|
42
|
+
|
43
|
+
def run_test_by_line_number
|
44
|
+
path, line = path_args.first.match(/^(.+):(\d+)$/).captures
|
45
|
+
test_name = TestParser.new(path).test_name_at_line(line.to_i)
|
46
|
+
|
47
|
+
if test_name
|
48
|
+
run_tests_and_exit!(path, flags: ["-n", "/^#{Regexp.quote(test_name)}$/"])
|
49
|
+
else
|
50
|
+
run_tests_and_exit!
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def run_tests_by_path
|
55
|
+
test_paths = find_test_paths
|
56
|
+
test_paths = excluding_slow_paths(test_paths) unless path_args.any? || ci? || options[:all]
|
57
|
+
test_paths = Sharder.from_argv(options[:shard], env:, file_system:).shard(test_paths) if options[:shard]
|
58
|
+
|
59
|
+
run_tests_and_exit!(*test_paths)
|
60
|
+
end
|
61
|
+
|
62
|
+
def excluding_slow_paths(test_paths)
|
63
|
+
test_paths.reject { |path| file_system.slow_test_path?(path) }
|
64
|
+
end
|
65
|
+
|
66
|
+
def ci?
|
67
|
+
!env["CI"].to_s.strip.empty?
|
68
|
+
end
|
69
|
+
|
70
|
+
def find_test_paths
|
71
|
+
return file_system.find_test_paths if path_args.empty?
|
72
|
+
|
73
|
+
path_args.flat_map do |path|
|
74
|
+
if Dir.exist?(path)
|
75
|
+
file_system.find_test_paths(path)
|
76
|
+
elsif File.exist?(path)
|
77
|
+
[path]
|
78
|
+
else
|
79
|
+
raise ArgumentError, "#{path} does not exist"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def run_tests_and_exit!(*test_paths, flags: [])
|
85
|
+
runner.run_inline_and_exit!(*test_paths, args: extra_args + flags)
|
86
|
+
end
|
87
|
+
|
88
|
+
def handle_exception(e) # rubocop:disable Naming/MethodParameterName
|
89
|
+
case e
|
90
|
+
when SignalException
|
91
|
+
exit(128 + e.signo)
|
92
|
+
when Errno::EPIPE
|
93
|
+
# pass
|
94
|
+
else
|
95
|
+
raise e
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require "io/console"
|
2
|
+
|
3
|
+
module MightyTest
|
4
|
+
class Console
|
5
|
+
def initialize(stdin: $stdin, sound_player: "/usr/bin/afplay", sound_paths: SOUNDS)
|
6
|
+
@stdin = stdin
|
7
|
+
@sound_player = sound_player
|
8
|
+
@sound_paths = sound_paths
|
9
|
+
end
|
10
|
+
|
11
|
+
def clear
|
12
|
+
return false unless tty?
|
13
|
+
|
14
|
+
$stdout.clear_screen
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
def wait_for_keypress
|
19
|
+
return stdin.getc unless stdin.respond_to?(:raw) && tty?
|
20
|
+
|
21
|
+
stdin.raw(intr: true) { stdin.getc }
|
22
|
+
end
|
23
|
+
|
24
|
+
def play_sound(name, wait: false)
|
25
|
+
return false unless tty?
|
26
|
+
|
27
|
+
paths = sound_paths.fetch(name) { raise ArgumentError, "Unknown sound name #{name}" }
|
28
|
+
path = paths.find { |p| File.exist?(p) }
|
29
|
+
return false unless path && File.executable?(sound_player)
|
30
|
+
|
31
|
+
thread = Thread.new { system(sound_player, path) }
|
32
|
+
thread.join if wait
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# rubocop:disable Layout/LineLength
|
39
|
+
SOUNDS = {
|
40
|
+
pass: %w[
|
41
|
+
/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Milestone-EncoreInfinitum.caf
|
42
|
+
/System/Library/Sounds/Glass.aiff
|
43
|
+
],
|
44
|
+
fail: %w[
|
45
|
+
/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Rebound-EncoreInfinitum.caf
|
46
|
+
/System/Library/Sounds/Bottle.aiff
|
47
|
+
]
|
48
|
+
}.freeze
|
49
|
+
private_constant :SOUNDS
|
50
|
+
# rubocop:enable Layout/LineLength
|
51
|
+
|
52
|
+
attr_reader :sound_player, :sound_paths, :stdin
|
53
|
+
|
54
|
+
def tty?
|
55
|
+
$stdout.respond_to?(:tty?) && $stdout.tty?
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "open3"
|
2
|
+
|
3
|
+
module MightyTest
|
4
|
+
class FileSystem
|
5
|
+
def listen(&)
|
6
|
+
require "listen"
|
7
|
+
Listen.to(*%w[app lib test].select { |p| Dir.exist?(p) }, relative: true, &).tap(&:start)
|
8
|
+
end
|
9
|
+
|
10
|
+
def find_matching_test_path(path) # rubocop:disable Metrics/CyclomaticComplexity
|
11
|
+
return nil unless path && File.exist?(path) && !Dir.exist?(path)
|
12
|
+
return path if path.match?(%r{^test/.*_test.rb$})
|
13
|
+
|
14
|
+
test_path = path[%r{^(?:app|lib)/(.+)\.[^\.]+$}, 1]&.then { "test/#{_1}_test.rb" }
|
15
|
+
test_path if test_path && File.exist?(test_path)
|
16
|
+
end
|
17
|
+
|
18
|
+
def find_test_paths(directory="test")
|
19
|
+
glob = File.join(directory, "**/*_test.rb")
|
20
|
+
Dir[glob]
|
21
|
+
end
|
22
|
+
|
23
|
+
def slow_test_path?(path)
|
24
|
+
return false if path.nil?
|
25
|
+
|
26
|
+
path.match?(%r{^test/(e2e|feature|features|integration|system)/})
|
27
|
+
end
|
28
|
+
|
29
|
+
def find_new_and_changed_paths
|
30
|
+
out, _err, status = Open3.capture3(*%w[git status --porcelain=1 -uall -z --no-renames -- test app lib])
|
31
|
+
return [] unless status.success?
|
32
|
+
|
33
|
+
out
|
34
|
+
.split("\x0")
|
35
|
+
.filter_map { |line| line[/^.. (.+)/, 1] }
|
36
|
+
.uniq
|
37
|
+
rescue SystemCallError
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module MightyTest
|
2
|
+
class MinitestRunner
|
3
|
+
def print_help_and_exit!
|
4
|
+
require "minitest"
|
5
|
+
Minitest.run(["--help"])
|
6
|
+
exit
|
7
|
+
end
|
8
|
+
|
9
|
+
def run_inline_and_exit!(*test_files, args: [])
|
10
|
+
$LOAD_PATH.unshift "test"
|
11
|
+
ARGV.replace(Array(args))
|
12
|
+
|
13
|
+
require "minitest/focus"
|
14
|
+
require "minitest/rg"
|
15
|
+
|
16
|
+
test_files.flatten.each { |file| require File.expand_path(file.to_s) }
|
17
|
+
|
18
|
+
require "minitest/autorun"
|
19
|
+
exit
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "shellwords"
|
2
|
+
|
3
|
+
module MightyTest
|
4
|
+
class OptionParser
|
5
|
+
def parse(argv)
|
6
|
+
argv, literal_args = split(argv, "--")
|
7
|
+
options = parse_options!(argv)
|
8
|
+
minitest_flags = parse_minitest_flags!(argv) unless options[:help]
|
9
|
+
|
10
|
+
[argv + literal_args, minitest_flags || [], options]
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
<<~USAGE
|
15
|
+
Usage: mt [--all]
|
16
|
+
mt [test file...] [test dir...]
|
17
|
+
mt --watch
|
18
|
+
USAGE
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def parse_options!(argv)
|
24
|
+
options = {}
|
25
|
+
options[:all] = true if argv.delete("--all")
|
26
|
+
options[:watch] = true if argv.delete("--watch")
|
27
|
+
options[:version] = true if argv.delete("--version")
|
28
|
+
options[:help] = true if argv.delete("--help") || argv.delete("-h")
|
29
|
+
parse_shard(argv, options)
|
30
|
+
options
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_minitest_flags!(argv)
|
34
|
+
return [] if argv.grep(/\A-/).none?
|
35
|
+
|
36
|
+
require "minitest"
|
37
|
+
orig_argv = argv.dup
|
38
|
+
|
39
|
+
Minitest.load_plugins unless argv.delete("--no-plugins") || ENV["MT_NO_PLUGINS"]
|
40
|
+
minitest_options = Minitest.process_args(argv)
|
41
|
+
|
42
|
+
minitest_args = Shellwords.split(minitest_options[:args] || "")
|
43
|
+
remove_seed_flag(minitest_args) unless orig_argv.include?("--seed")
|
44
|
+
|
45
|
+
minitest_args
|
46
|
+
end
|
47
|
+
|
48
|
+
def split(array, delim)
|
49
|
+
delim_at = array.index(delim)
|
50
|
+
return [array.dup, []] if delim_at.nil?
|
51
|
+
|
52
|
+
[
|
53
|
+
array[0...delim_at],
|
54
|
+
array[(delim_at + 1)..]
|
55
|
+
]
|
56
|
+
end
|
57
|
+
|
58
|
+
def parse_shard(argv, options)
|
59
|
+
argv.delete_if { |arg| options[:shard] = Regexp.last_match(1) if arg =~ /\A--shard=(.*)/ }
|
60
|
+
|
61
|
+
argv.each_with_index do |flag, i|
|
62
|
+
value = argv[i + 1]
|
63
|
+
next unless flag == "--shard"
|
64
|
+
raise "missing shard value" if value.nil? || value.start_with?("-")
|
65
|
+
|
66
|
+
options[:shard] = value
|
67
|
+
argv.slice!(i, 2)
|
68
|
+
break
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def remove_seed_flag(parsed_argv)
|
73
|
+
index = parsed_argv.index("--seed")
|
74
|
+
parsed_argv.slice!(index, 2) if index
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module MightyTest
|
2
|
+
class Sharder
|
3
|
+
DEFAULT_SEED = 123_456_789
|
4
|
+
|
5
|
+
def self.from_argv(value, env: ENV, file_system: FileSystem.new)
|
6
|
+
index, total = value.to_s.match(%r{\A(\d+)/(\d+)\z})&.captures&.map(&:to_i)
|
7
|
+
raise ArgumentError, "shard: value must be in the form INDEX/TOTAL (e.g. 2/8)" if total.nil?
|
8
|
+
|
9
|
+
git_sha = env.values_at("GITHUB_SHA", "CIRCLE_SHA1").find { |sha| !sha.to_s.strip.empty? }
|
10
|
+
seed = git_sha&.unpack1("l_")
|
11
|
+
|
12
|
+
new(index:, total:, seed:, file_system:)
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :index, :total, :seed
|
16
|
+
|
17
|
+
def initialize(index:, total:, seed: nil, file_system: FileSystem.new)
|
18
|
+
raise ArgumentError, "shard: total shards must be a number greater than 0" unless total > 0
|
19
|
+
|
20
|
+
valid_group = index > 0 && index <= total
|
21
|
+
raise ArgumentError, "shard: shard index must be > 0 and <= #{total}" unless valid_group
|
22
|
+
|
23
|
+
@index = index
|
24
|
+
@total = total
|
25
|
+
@seed = seed || DEFAULT_SEED
|
26
|
+
@file_system = file_system
|
27
|
+
end
|
28
|
+
|
29
|
+
def shard(*test_paths)
|
30
|
+
random = Random.new(seed)
|
31
|
+
|
32
|
+
# Shuffle slow and normal paths separately so that slow ones get evenly distributed
|
33
|
+
shuffled_paths = test_paths
|
34
|
+
.flatten
|
35
|
+
.partition { |path| !file_system.slow_test_path?(path) }
|
36
|
+
.flat_map { |paths| paths.shuffle(random:) }
|
37
|
+
|
38
|
+
slices = shuffled_paths.each_slice(total)
|
39
|
+
slices.filter_map { |slice| slice[index - 1] }
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
attr_reader :file_system
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module MightyTest
|
2
|
+
class TestParser
|
3
|
+
def initialize(test_path)
|
4
|
+
@path = test_path.to_s
|
5
|
+
end
|
6
|
+
|
7
|
+
def test_name_at_line(number)
|
8
|
+
method_name = nil
|
9
|
+
lines = File.read(path).lines
|
10
|
+
lines[2...number].reverse_each.find do |line|
|
11
|
+
method_name =
|
12
|
+
match_minitest_method_name(line) ||
|
13
|
+
match_active_support_test_string(line)&.then { "test_#{_1.gsub(/\s+/, '_')}" }
|
14
|
+
end
|
15
|
+
method_name
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :path
|
21
|
+
|
22
|
+
def match_minitest_method_name(line)
|
23
|
+
line[/^\s*(?:focus\s+)?def\s+(test_\w+)/, 1]
|
24
|
+
end
|
25
|
+
|
26
|
+
def match_active_support_test_string(line)
|
27
|
+
match = line.match(/^\s*test\s+(?:"(.+?)"|'(.+?)')\s*do\s*(?:#.*?)?$/)
|
28
|
+
return unless match
|
29
|
+
|
30
|
+
match.captures.compact.first
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
module MightyTest
|
2
|
+
class Watcher # rubocop:disable Metrics/ClassLength
|
3
|
+
WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "h" for help or "q" to quit.'.freeze
|
4
|
+
|
5
|
+
def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system))
|
6
|
+
@queue = Thread::Queue.new
|
7
|
+
@console = console
|
8
|
+
@extra_args = extra_args
|
9
|
+
@file_system = file_system
|
10
|
+
@system_proc = system_proc
|
11
|
+
end
|
12
|
+
|
13
|
+
def run(iterations: :indefinitely) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
14
|
+
start_file_system_listener
|
15
|
+
start_keypress_listener
|
16
|
+
puts WATCHING_FOR_CHANGES
|
17
|
+
|
18
|
+
loop_for(iterations) do
|
19
|
+
case await_next_event
|
20
|
+
in [:file_system_changed, [_, *] => paths]
|
21
|
+
run_matching_test_files(paths)
|
22
|
+
in [:keypress, "\r" | "\n"]
|
23
|
+
run_all_tests
|
24
|
+
in [:keypress, "a"]
|
25
|
+
run_all_tests(flags: ["--all"])
|
26
|
+
in [:keypress, "d"]
|
27
|
+
run_matching_test_files_from_git_diff
|
28
|
+
in [:keypress, "h"]
|
29
|
+
show_help
|
30
|
+
in [:keypress, "q"]
|
31
|
+
break
|
32
|
+
else
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
end
|
36
|
+
ensure
|
37
|
+
puts "\nExiting."
|
38
|
+
file_system_listener&.stop
|
39
|
+
keypress_listener&.kill
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
attr_reader :console, :extra_args, :file_system, :file_system_listener, :keypress_listener, :system_proc
|
45
|
+
|
46
|
+
def show_help
|
47
|
+
console.clear
|
48
|
+
puts <<~MENU
|
49
|
+
`mt --watch` is watching file system activity and will automatically run
|
50
|
+
test files when they are added or modified. If you modify a source file,
|
51
|
+
mt will find and run the corresponding tests.
|
52
|
+
|
53
|
+
You can also trigger test runs with the following interactive commands.
|
54
|
+
|
55
|
+
> Press Enter to run all tests.
|
56
|
+
> Press "a" to run all tests, including slow tests.
|
57
|
+
> Press "d" to run tests for files diffed or added since the last git commit.
|
58
|
+
> Press "h" to show this help menu.
|
59
|
+
> Press "q" to quit.
|
60
|
+
|
61
|
+
MENU
|
62
|
+
end
|
63
|
+
|
64
|
+
def run_all_tests(flags: [])
|
65
|
+
console.clear
|
66
|
+
puts flags.any? ? "Running tests with #{flags.join(' ')}..." : "Running tests..."
|
67
|
+
puts
|
68
|
+
mt(flags:)
|
69
|
+
end
|
70
|
+
|
71
|
+
def run_matching_test_files(paths)
|
72
|
+
test_paths = paths.flat_map { |path| file_system.find_matching_test_path(path) }.compact.uniq
|
73
|
+
return false if test_paths.empty?
|
74
|
+
|
75
|
+
console.clear
|
76
|
+
puts test_paths.join("\n")
|
77
|
+
puts
|
78
|
+
mt(*test_paths)
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
def run_matching_test_files_from_git_diff
|
83
|
+
return if run_matching_test_files(file_system.find_new_and_changed_paths)
|
84
|
+
|
85
|
+
console.clear
|
86
|
+
puts "No affected test files detected since the last git commit."
|
87
|
+
puts WATCHING_FOR_CHANGES
|
88
|
+
end
|
89
|
+
|
90
|
+
def mt(*test_paths, flags: [])
|
91
|
+
command = ["mt", *extra_args, *flags]
|
92
|
+
command.append("--", *test_paths.flatten) if test_paths.any?
|
93
|
+
|
94
|
+
success = system_proc.call(*command)
|
95
|
+
|
96
|
+
console.play_sound(success ? :pass : :fail)
|
97
|
+
puts "\n#{WATCHING_FOR_CHANGES}"
|
98
|
+
$stdout.flush
|
99
|
+
rescue Interrupt
|
100
|
+
# Pressing ctrl-c kills the fs_event background process, so we have to manually restart it.
|
101
|
+
restart_file_system_listener
|
102
|
+
end
|
103
|
+
|
104
|
+
def start_file_system_listener
|
105
|
+
file_system_listener.stop if file_system_listener && !file_system_listener.stopped?
|
106
|
+
|
107
|
+
@file_system_listener = file_system.listen do |modified, added, _removed|
|
108
|
+
# Pause listener so that subsequent changes are queued up while we are running the tests
|
109
|
+
file_system_listener.pause unless file_system_listener.stopped?
|
110
|
+
post_event(:file_system_changed, [*modified, *added].uniq)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
alias restart_file_system_listener start_file_system_listener
|
114
|
+
|
115
|
+
def start_keypress_listener
|
116
|
+
@keypress_listener = Thread.new do
|
117
|
+
loop do
|
118
|
+
key = console.wait_for_keypress
|
119
|
+
break if key.nil?
|
120
|
+
|
121
|
+
post_event(:keypress, key)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
@keypress_listener.abort_on_exception = true
|
125
|
+
end
|
126
|
+
|
127
|
+
def loop_for(iterations, &)
|
128
|
+
iterations == :indefinitely ? loop(&) : iterations.times(&)
|
129
|
+
end
|
130
|
+
|
131
|
+
def await_next_event
|
132
|
+
file_system_listener.start if file_system_listener.paused?
|
133
|
+
@queue.pop
|
134
|
+
end
|
135
|
+
|
136
|
+
def post_event(*event)
|
137
|
+
@queue << event
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
data/lib/mighty_test.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module MightyTest
|
2
|
+
autoload :VERSION, "mighty_test/version"
|
3
|
+
autoload :CLI, "mighty_test/cli"
|
4
|
+
autoload :Console, "mighty_test/console"
|
5
|
+
autoload :FileSystem, "mighty_test/file_system"
|
6
|
+
autoload :MinitestRunner, "mighty_test/minitest_runner"
|
7
|
+
autoload :OptionParser, "mighty_test/option_parser"
|
8
|
+
autoload :Sharder, "mighty_test/sharder"
|
9
|
+
autoload :TestParser, "mighty_test/test_parser"
|
10
|
+
autoload :Watcher, "mighty_test/watcher"
|
11
|
+
end
|
metadata
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mighty_test
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matt Brictson
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-02-29 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: listen
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5.15'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '5.15'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest-fail-fast
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.1.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.1.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest-focus
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.4'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.4'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: minitest-rg
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '5.3'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '5.3'
|
83
|
+
description:
|
84
|
+
email:
|
85
|
+
- opensource@mattbrictson.com
|
86
|
+
executables:
|
87
|
+
- mt
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- LICENSE.txt
|
92
|
+
- README.md
|
93
|
+
- exe/mt
|
94
|
+
- lib/mighty_test.rb
|
95
|
+
- lib/mighty_test/cli.rb
|
96
|
+
- lib/mighty_test/console.rb
|
97
|
+
- lib/mighty_test/file_system.rb
|
98
|
+
- lib/mighty_test/minitest_runner.rb
|
99
|
+
- lib/mighty_test/option_parser.rb
|
100
|
+
- lib/mighty_test/sharder.rb
|
101
|
+
- lib/mighty_test/test_parser.rb
|
102
|
+
- lib/mighty_test/version.rb
|
103
|
+
- lib/mighty_test/watcher.rb
|
104
|
+
homepage: https://github.com/mattbrictson/mighty_test
|
105
|
+
licenses:
|
106
|
+
- MIT
|
107
|
+
metadata:
|
108
|
+
bug_tracker_uri: https://github.com/mattbrictson/mighty_test/issues
|
109
|
+
changelog_uri: https://github.com/mattbrictson/mighty_test/releases
|
110
|
+
source_code_uri: https://github.com/mattbrictson/mighty_test
|
111
|
+
homepage_uri: https://github.com/mattbrictson/mighty_test
|
112
|
+
rubygems_mfa_required: 'true'
|
113
|
+
post_install_message:
|
114
|
+
rdoc_options: []
|
115
|
+
require_paths:
|
116
|
+
- lib
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
118
|
+
requirements:
|
119
|
+
- - ">="
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '3.1'
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '0'
|
127
|
+
requirements: []
|
128
|
+
rubygems_version: 3.5.5
|
129
|
+
signing_key:
|
130
|
+
specification_version: 4
|
131
|
+
summary: A modern Minitest runner for TDD, with watch mode and more
|
132
|
+
test_files: []
|