kaisoku 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 +133 -0
- data/Rakefile +8 -0
- data/exe/kaisoku +6 -0
- data/lib/kaisoku/adapter.rb +81 -0
- data/lib/kaisoku/adapters/cucumber.rb +91 -0
- data/lib/kaisoku/adapters/minitest.rb +96 -0
- data/lib/kaisoku/adapters/rspec.rb +111 -0
- data/lib/kaisoku/adapters/test_unit.rb +35 -0
- data/lib/kaisoku/checksum.rb +55 -0
- data/lib/kaisoku/cli.rb +158 -0
- data/lib/kaisoku/commands/daemon_command.rb +31 -0
- data/lib/kaisoku/commands/map_command.rb +86 -0
- data/lib/kaisoku/commands/preload_command.rb +40 -0
- data/lib/kaisoku/commands/run_command.rb +93 -0
- data/lib/kaisoku/commands/watch_command.rb +37 -0
- data/lib/kaisoku/configuration.rb +69 -0
- data/lib/kaisoku/coverage_collector.rb +83 -0
- data/lib/kaisoku/daemon/client.rb +22 -0
- data/lib/kaisoku/daemon/server.rb +39 -0
- data/lib/kaisoku/daemon/socket_path.rb +13 -0
- data/lib/kaisoku/doctor.rb +46 -0
- data/lib/kaisoku/entity.rb +77 -0
- data/lib/kaisoku/entity_result.rb +19 -0
- data/lib/kaisoku/environment_tracker.rb +28 -0
- data/lib/kaisoku/evaluator/cli.rb +112 -0
- data/lib/kaisoku/evaluator/command_result.rb +64 -0
- data/lib/kaisoku/evaluator/history.rb +23 -0
- data/lib/kaisoku/evaluator/metrics.rb +45 -0
- data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
- data/lib/kaisoku/evaluator/replay.rb +121 -0
- data/lib/kaisoku/evaluator/report.rb +128 -0
- data/lib/kaisoku/hybrid_policy.rb +14 -0
- data/lib/kaisoku/listen_watcher.rb +34 -0
- data/lib/kaisoku/lsp/message_io.rb +42 -0
- data/lib/kaisoku/lsp/server.rb +100 -0
- data/lib/kaisoku/method_map.rb +75 -0
- data/lib/kaisoku/preload/client.rb +22 -0
- data/lib/kaisoku/preload/daemon.rb +48 -0
- data/lib/kaisoku/preload/fallback_runner.rb +45 -0
- data/lib/kaisoku/preload/server.rb +123 -0
- data/lib/kaisoku/preload/socket_path.rb +13 -0
- data/lib/kaisoku/prioritizer.rb +18 -0
- data/lib/kaisoku/rails/integration.rb +56 -0
- data/lib/kaisoku/reporters/console.rb +24 -0
- data/lib/kaisoku/reporters/factory.rb +24 -0
- data/lib/kaisoku/reporters/json.rb +28 -0
- data/lib/kaisoku/reporters/junit.rb +50 -0
- data/lib/kaisoku/reporters/tui.rb +29 -0
- data/lib/kaisoku/runner.rb +48 -0
- data/lib/kaisoku/runtime_context.rb +28 -0
- data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
- data/lib/kaisoku/scheduler/result_persister.rb +43 -0
- data/lib/kaisoku/selection.rb +51 -0
- data/lib/kaisoku/selector.rb +173 -0
- data/lib/kaisoku/snapshot/inline.rb +51 -0
- data/lib/kaisoku/snapshot/rspec.rb +22 -0
- data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
- data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
- data/lib/kaisoku/test_map/health.rb +24 -0
- data/lib/kaisoku/test_map/hot_files.rb +32 -0
- data/lib/kaisoku/test_map/schema.rb +81 -0
- data/lib/kaisoku/test_map/snapshot.rb +53 -0
- data/lib/kaisoku/test_map/stats.rb +19 -0
- data/lib/kaisoku/test_map.rb +170 -0
- data/lib/kaisoku/version.rb +5 -0
- data/lib/kaisoku/watch_session.rb +35 -0
- data/lib/kaisoku/watcher.rb +63 -0
- data/lib/kaisoku/worker.rb +46 -0
- data/lib/kaisoku.rb +67 -0
- metadata +186 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: edd8cb6fea57c23e069b7547ff435d61dad7b2e990fc6ab6993bd26b269d31c1
|
|
4
|
+
data.tar.gz: bd1b9a13f9e99b880bcf9637b9c158242d1d501ba137e0ab40da6a98ca9b2949
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d498ab221286f31761d145022fea6b74be59c2ae594718ec369080715303d24c45ab0098c6c9e3655524802d133a18d4ba1ef400231f7d19b64a23492850fd68
|
|
7
|
+
data.tar.gz: a8499104174f91185d344f5c60ac0aaf7a6dc106125b29f3aeb0df54087dd457c386b3c2661b19822b0cd90a122abd40a78fc7241f079cc3118b21dc4c278eb6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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,133 @@
|
|
|
1
|
+
# Kaisoku
|
|
2
|
+
|
|
3
|
+
Kaisoku is a research-backed Ruby test feedback engine for safe regression test
|
|
4
|
+
selection. It records dynamic dependencies per test entity, selects the tests
|
|
5
|
+
affected by a change, and falls back to a full run when it cannot preserve
|
|
6
|
+
safety.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Install the gem and add it to your application's Gemfile by executing:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bundle add kaisoku
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
If bundler is not being used to manage dependencies, install the gem by
|
|
17
|
+
executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install kaisoku
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Build the initial dependency map:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
kaisoku map build
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run tests affected by changed files:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
kaisoku run app/models/user.rb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run all tests or previous failures:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
kaisoku run --all
|
|
41
|
+
kaisoku run --failed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Start watch mode:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
kaisoku watch app lib spec
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Inspect map health and preload state:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
kaisoku doctor --preload
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Export and import dependency maps for CI or cache reuse:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
kaisoku map export > .kaisoku/map.json
|
|
60
|
+
kaisoku map import .kaisoku/map.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Evaluate selection quality:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
kaisoku eval --total 100 --selected 12 --ci
|
|
67
|
+
kaisoku eval --commits 20 --json-report .kaisoku/eval-report.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Unsafe predictive mode is explicit:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
kaisoku run --predictive
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Supported Frameworks
|
|
77
|
+
|
|
78
|
+
- RSpec
|
|
79
|
+
- minitest
|
|
80
|
+
- test-unit
|
|
81
|
+
- Cucumber
|
|
82
|
+
|
|
83
|
+
Select an adapter with `--adapter`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
kaisoku run lib/user.rb --adapter minitest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Features
|
|
90
|
+
|
|
91
|
+
- Dynamic file-level dependency maps stored in SQLite
|
|
92
|
+
- Smart Ruby checksums that ignore comments while preserving semantic magic comments
|
|
93
|
+
- Safe fallback rules for unknown files, boot-impact files, Ruby/Gemfile changes, and stale maps
|
|
94
|
+
- Fork-based parallel scheduling and failure-first prioritization
|
|
95
|
+
- Watch mode, daemon RPC, console/TUI/JSON/JUnit reporters, and map export/import
|
|
96
|
+
- Hot-file diagnostics and method-level HyRTS policy
|
|
97
|
+
- Evaluation commands for selection metrics, JSON reports, CI history, commit replay, and mutation seeding
|
|
98
|
+
- Preload integrity tracking, Rails-specific hooks, and inline snapshot matcher support
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
After checking out the repo, run:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bundle install
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run the test suite:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bundle exec rake
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Run the executable from the checkout:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bundle exec ruby -Ilib exe/kaisoku --help
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Build the gem locally:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
gem build kaisoku.gemspec
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Contributing
|
|
127
|
+
|
|
128
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
129
|
+
https://github.com/ydah/kaisoku.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
The gem is available as open source under the terms of the MIT License.
|
data/Rakefile
ADDED
data/exe/kaisoku
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class Adapter
|
|
5
|
+
attr_reader :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(configuration: Configuration.new)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def discover(_paths = nil)
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(_entities, reporter:, &_lifecycle)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def entity_granularity
|
|
20
|
+
:group
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def before_fork(_context); end
|
|
24
|
+
|
|
25
|
+
def after_fork(_worker_index); end
|
|
26
|
+
|
|
27
|
+
def supports_dry_run?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def ruby_test_files(paths, default_dir:, suffix:)
|
|
34
|
+
targets = Array(paths).compact
|
|
35
|
+
targets = [default_dir] if targets.empty?
|
|
36
|
+
|
|
37
|
+
targets.flat_map do |path|
|
|
38
|
+
absolute = configuration.absolute_path(path)
|
|
39
|
+
|
|
40
|
+
if File.directory?(absolute)
|
|
41
|
+
Dir.glob(File.join(absolute, '**', "*#{suffix}")).select { |file| File.file?(file) }
|
|
42
|
+
elsif File.file?(absolute) && absolute.end_with?(suffix)
|
|
43
|
+
[absolute]
|
|
44
|
+
else
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
end.uniq.sort
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def file_entity(file, adapter:)
|
|
51
|
+
relative = configuration.relative_path(file)
|
|
52
|
+
Entity.new(
|
|
53
|
+
adapter: adapter,
|
|
54
|
+
identifier: "#{relative}[file]",
|
|
55
|
+
file: relative,
|
|
56
|
+
line: 1
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module Adapters
|
|
62
|
+
module Factory
|
|
63
|
+
module_function
|
|
64
|
+
|
|
65
|
+
def build(name, configuration:)
|
|
66
|
+
case name.to_s
|
|
67
|
+
when 'rspec'
|
|
68
|
+
RSpecAdapter.new(configuration: configuration)
|
|
69
|
+
when 'minitest'
|
|
70
|
+
MinitestAdapter.new(configuration: configuration)
|
|
71
|
+
when 'test-unit'
|
|
72
|
+
TestUnitAdapter.new(configuration: configuration)
|
|
73
|
+
when 'cucumber'
|
|
74
|
+
CucumberAdapter.new(configuration: configuration)
|
|
75
|
+
else
|
|
76
|
+
raise Error, "unknown adapter: #{name}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Adapters
|
|
7
|
+
class CucumberAdapter < Adapter
|
|
8
|
+
ADAPTER_NAME = 'cucumber'
|
|
9
|
+
|
|
10
|
+
def discover(paths = nil)
|
|
11
|
+
feature_files(paths).flat_map { |file| scenarios_in(file) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(entities, reporter:, &lifecycle)
|
|
15
|
+
lifecycle ||= proc {}
|
|
16
|
+
|
|
17
|
+
entities.each do |entity|
|
|
18
|
+
started_at = monotonic_milliseconds
|
|
19
|
+
lifecycle.call(:entity_started, entity, {})
|
|
20
|
+
ok = run_cucumber(execution_target(entity))
|
|
21
|
+
lifecycle.call(:entity_finished, entity, result_payload(ok, started_at))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def entity_granularity
|
|
26
|
+
:scenario
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def feature_files(paths)
|
|
32
|
+
targets = Array(paths).compact
|
|
33
|
+
targets = ['features'] if targets.empty?
|
|
34
|
+
targets.flat_map { |path| feature_files_for(path) }.uniq.sort
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def feature_files_for(path)
|
|
38
|
+
absolute = configuration.absolute_path(path)
|
|
39
|
+
return Dir.glob(File.join(absolute, '**', '*.feature')) if File.directory?(absolute)
|
|
40
|
+
return [absolute] if File.file?(absolute) && absolute.end_with?('.feature')
|
|
41
|
+
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scenarios_in(file)
|
|
46
|
+
relative = configuration.relative_path(file)
|
|
47
|
+
scenarios = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
|
|
48
|
+
scenario_entity(relative, line, number)
|
|
49
|
+
end
|
|
50
|
+
scenarios.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : scenarios
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def scenario_entity(relative, line, number)
|
|
54
|
+
match = line.match(/^\s*Scenario(?: Outline)?:\s*(.+)$/)
|
|
55
|
+
return nil unless match
|
|
56
|
+
|
|
57
|
+
Entity.new(adapter: ADAPTER_NAME, identifier: "#{relative}[#{match[1]}]", file: relative, line: number)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def execution_target(entity)
|
|
61
|
+
"#{configuration.absolute_path(entity.file)}:#{entity.line || 1}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_cucumber(target)
|
|
65
|
+
return system('cucumber', target) unless cucumber_cli_available?
|
|
66
|
+
|
|
67
|
+
output = StringIO.new
|
|
68
|
+
error = StringIO.new
|
|
69
|
+
status = ::Cucumber::Cli::Main.execute([target], output, error)
|
|
70
|
+
status.respond_to?(:zero?) ? status.zero? : status == true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cucumber_cli_available?
|
|
74
|
+
return true if defined?(::Cucumber::Cli::Main)
|
|
75
|
+
|
|
76
|
+
require 'cucumber/cli/main'
|
|
77
|
+
true
|
|
78
|
+
rescue LoadError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def result_payload(ok, started_at)
|
|
83
|
+
{ status: ok ? 'passed' : 'failed', exit_status: ok ? 0 : 1, duration_ms: monotonic_milliseconds - started_at }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def monotonic_milliseconds
|
|
87
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
module Adapters
|
|
8
|
+
class MinitestAdapter < Adapter
|
|
9
|
+
ADAPTER_NAME = 'minitest'
|
|
10
|
+
|
|
11
|
+
def discover(paths = nil)
|
|
12
|
+
ruby_test_files(paths, default_dir: 'test', suffix: '_test.rb').flat_map { |file| classes_in(file) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(entities, reporter:, &lifecycle)
|
|
16
|
+
lifecycle ||= proc {}
|
|
17
|
+
|
|
18
|
+
entities.each do |entity|
|
|
19
|
+
started_at = monotonic_milliseconds
|
|
20
|
+
lifecycle.call(:entity_started, entity, {})
|
|
21
|
+
result = run_entity(entity)
|
|
22
|
+
lifecycle.call(:entity_finished, entity, result_payload(result, started_at))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def entity_granularity
|
|
27
|
+
:class
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def supports_dry_run?
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def classes_in(file)
|
|
37
|
+
relative = configuration.relative_path(file)
|
|
38
|
+
classes = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
|
|
39
|
+
class_entity(relative, line, number)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
classes.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : classes
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def class_entity(relative, line, number)
|
|
46
|
+
match = line.match(/^\s*class\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*<\s*(?:Minitest::Test|ActiveSupport::TestCase)/)
|
|
47
|
+
return nil unless match
|
|
48
|
+
|
|
49
|
+
Entity.new(
|
|
50
|
+
adapter: ADAPTER_NAME,
|
|
51
|
+
identifier: "#{relative}[#{match[1]}]",
|
|
52
|
+
file: relative,
|
|
53
|
+
line: number
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run_entity(entity)
|
|
58
|
+
require 'minitest'
|
|
59
|
+
Minitest.seed ||= srand % 0xFFFF
|
|
60
|
+
before = Minitest::Runnable.runnables.dup
|
|
61
|
+
load configuration.absolute_path(entity.file)
|
|
62
|
+
classes = selected_classes(before, entity)
|
|
63
|
+
reporter = Minitest::CompositeReporter.new(Minitest::SummaryReporter.new(StringIO.new, {}))
|
|
64
|
+
reporter.start
|
|
65
|
+
classes.each do |klass|
|
|
66
|
+
klass.runnable_methods.each { |method_name| klass.run(klass, method_name, reporter) }
|
|
67
|
+
end
|
|
68
|
+
reporter.report
|
|
69
|
+
reporter.passed?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def class_name(entity)
|
|
73
|
+
entity.identifier[/\[(.+)\]\z/, 1]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def selected_classes(before, entity)
|
|
77
|
+
candidates = Minitest::Runnable.runnables - before
|
|
78
|
+
return candidates unless class_name(entity)
|
|
79
|
+
|
|
80
|
+
candidates.select { |klass| klass.name == class_name(entity) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def result_payload(ok, started_at)
|
|
84
|
+
{
|
|
85
|
+
status: ok ? 'passed' : 'failed',
|
|
86
|
+
exit_status: ok ? 0 : 1,
|
|
87
|
+
duration_ms: monotonic_milliseconds - started_at
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def monotonic_milliseconds
|
|
92
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
module Adapters
|
|
8
|
+
class RSpecAdapter < Adapter
|
|
9
|
+
ADAPTER_NAME = 'rspec'
|
|
10
|
+
|
|
11
|
+
def discover(paths = nil)
|
|
12
|
+
ruby_test_files(paths, default_dir: 'spec', suffix: '_spec.rb').flat_map { |file| groups_in(file) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(entities, reporter:, &lifecycle)
|
|
16
|
+
require 'rspec/core'
|
|
17
|
+
|
|
18
|
+
lifecycle ||= proc {}
|
|
19
|
+
|
|
20
|
+
entities.each do |entity|
|
|
21
|
+
started_at = monotonic_milliseconds
|
|
22
|
+
lifecycle.call(:entity_started, entity, {})
|
|
23
|
+
status_code = ::RSpec::Core::Runner.run([execution_target(entity)], $stderr, $stdout)
|
|
24
|
+
lifecycle.call(:entity_finished, entity, result_payload(status_code, started_at))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def entity_granularity
|
|
29
|
+
:group
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def supports_dry_run?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def groups_in(file)
|
|
39
|
+
dry_run = dry_run_examples_in(file)
|
|
40
|
+
return dry_run unless dry_run.empty?
|
|
41
|
+
|
|
42
|
+
relative = configuration.relative_path(file)
|
|
43
|
+
groups = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
|
|
44
|
+
group_entity(relative, line, number)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
groups.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : groups
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def group_entity(relative, line, number)
|
|
51
|
+
match = line.match(/^\s*(?:RSpec\.)?(describe|context)\s+(.+?)(?:\s+do|\s+\{|\z)/)
|
|
52
|
+
return nil unless match
|
|
53
|
+
|
|
54
|
+
name = match[2].gsub(/\s+/, ' ').strip
|
|
55
|
+
Entity.new(
|
|
56
|
+
adapter: ADAPTER_NAME,
|
|
57
|
+
identifier: "#{relative}[#{name}]",
|
|
58
|
+
file: relative,
|
|
59
|
+
line: number
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dry_run_examples_in(file)
|
|
64
|
+
return [] unless ENV['KAISOKU_RSPEC_DRY_RUN'] == '1'
|
|
65
|
+
|
|
66
|
+
require 'rspec/core'
|
|
67
|
+
output = StringIO.new
|
|
68
|
+
error = StringIO.new
|
|
69
|
+
status = ::RSpec::Core::Runner.run([configuration.absolute_path(file), '--dry-run', '--format', 'json'], error,
|
|
70
|
+
output)
|
|
71
|
+
return [] unless status.zero?
|
|
72
|
+
|
|
73
|
+
examples_from_json(file, output.string)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def examples_from_json(file, payload)
|
|
79
|
+
relative = configuration.relative_path(file)
|
|
80
|
+
::JSON.parse(payload).fetch('examples', []).map do |example|
|
|
81
|
+
Entity.new(
|
|
82
|
+
adapter: ADAPTER_NAME,
|
|
83
|
+
identifier: "#{relative}[#{example.fetch('full_description', example['id'])}]",
|
|
84
|
+
file: relative,
|
|
85
|
+
line: example['line_number']
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
rescue ::JSON::ParserError
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def execution_target(entity)
|
|
93
|
+
return configuration.absolute_path(entity.file) unless entity.line
|
|
94
|
+
|
|
95
|
+
"#{configuration.absolute_path(entity.file)}:#{entity.line}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def result_payload(status_code, started_at)
|
|
99
|
+
{
|
|
100
|
+
status: status_code.zero? ? 'passed' : 'failed',
|
|
101
|
+
exit_status: status_code,
|
|
102
|
+
duration_ms: monotonic_milliseconds - started_at
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def monotonic_milliseconds
|
|
107
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Adapters
|
|
5
|
+
class TestUnitAdapter < MinitestAdapter
|
|
6
|
+
ADAPTER_NAME = 'test-unit'
|
|
7
|
+
|
|
8
|
+
def discover(paths = nil)
|
|
9
|
+
ruby_test_files(paths, default_dir: 'test', suffix: '_test.rb').flat_map { |file| classes_in(file) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def entity_granularity
|
|
13
|
+
:class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def classes_in(file)
|
|
19
|
+
relative = configuration.relative_path(file)
|
|
20
|
+
classes = File.readlines(file, chomp: true).filter_map.with_index(1) do |line, number|
|
|
21
|
+
class_entity(relative, line, number)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
classes.empty? ? [file_entity(file, adapter: ADAPTER_NAME)] : classes
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def class_entity(relative, line, number)
|
|
28
|
+
match = line.match(/^\s*class\s+([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*<\s*(?:Test::Unit::TestCase|Minitest::Test|ActiveSupport::TestCase)/)
|
|
29
|
+
return nil unless match
|
|
30
|
+
|
|
31
|
+
Entity.new(adapter: ADAPTER_NAME, identifier: "#{relative}[#{match[1]}]", file: relative, line: number)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'prism'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
class Checksum
|
|
8
|
+
RUBY_EXTENSION = '.rb'
|
|
9
|
+
SKIPPED_RUBY_TOKENS = %i[COMMENT IGNORED_NEWLINE NEWLINE].freeze
|
|
10
|
+
|
|
11
|
+
def digest(path)
|
|
12
|
+
return nil unless File.file?(path)
|
|
13
|
+
|
|
14
|
+
if File.extname(path) == RUBY_EXTENSION
|
|
15
|
+
ruby_digest(path)
|
|
16
|
+
else
|
|
17
|
+
raw_digest(path)
|
|
18
|
+
end
|
|
19
|
+
rescue StandardError
|
|
20
|
+
raw_digest(path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def ruby_digest(path)
|
|
26
|
+
result = Prism.lex_file(path)
|
|
27
|
+
raise Error, "Prism failed to lex #{path}" if result.failure?
|
|
28
|
+
|
|
29
|
+
normalized = result.value.filter_map do |token, _state|
|
|
30
|
+
normalize_token(token)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Digest::SHA256.hexdigest(normalized.join("\n"))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_token(token)
|
|
37
|
+
return normalize_magic_comment(token.value) if token.type == :COMMENT && magic_comment?(token.value)
|
|
38
|
+
return nil if SKIPPED_RUBY_TOKENS.include?(token.type)
|
|
39
|
+
|
|
40
|
+
"#{token.type}:#{token.value}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def magic_comment?(value)
|
|
44
|
+
value.match?(/\A#.*\b(frozen_string_literal|encoding|coding):/i)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_magic_comment(value)
|
|
48
|
+
"COMMENT:#{value.strip}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def raw_digest(path)
|
|
52
|
+
Digest::SHA256.file(path).hexdigest
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|