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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Rails
|
|
5
|
+
class Integration
|
|
6
|
+
DB_PATTERNS = %w[db/schema.rb db/structure.sql db/migrate/**].freeze
|
|
7
|
+
ROUTE_PATTERNS = %w[config/routes.rb config/routes/**].freeze
|
|
8
|
+
DB_DEPENDENCY_PREFIXES = %w[app/models db/ config/database].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(configuration: Configuration.new)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rails_project?
|
|
15
|
+
File.file?(@configuration.absolute_path('config/application.rb'))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def boot_impact_file?(path)
|
|
19
|
+
relative = @configuration.relative_path(path)
|
|
20
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def database_change?(path)
|
|
24
|
+
relative = @configuration.relative_path(path)
|
|
25
|
+
DB_PATTERNS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def route_change?(path)
|
|
29
|
+
relative = @configuration.relative_path(path)
|
|
30
|
+
ROUTE_PATTERNS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def db_dependency_prefixes
|
|
34
|
+
DB_DEPENDENCY_PREFIXES
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def database_name(base_name, worker_index)
|
|
38
|
+
return base_name if worker_index.to_i <= 0
|
|
39
|
+
|
|
40
|
+
"#{base_name}_#{worker_index}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wrap_reloader(&)
|
|
44
|
+
return yield unless defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
45
|
+
|
|
46
|
+
::Rails.application.reloader.wrap(&)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def patterns
|
|
52
|
+
DB_PATTERNS + ROUTE_PATTERNS
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Reporters
|
|
5
|
+
class Console
|
|
6
|
+
def initialize(output: $stdout)
|
|
7
|
+
@output = output
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def selection(selection, selected_count:, total_count:)
|
|
11
|
+
safety = selection.safe? ? 'safe' : 'unsafe'
|
|
12
|
+
fallback = selection.fallback? ? ' fallback' : ''
|
|
13
|
+
@output.puts "selected #{selected_count}/#{total_count} test entities (reason: #{selection.reason}) [#{safety}#{fallback}]"
|
|
14
|
+
selection.warnings.each { |warning| @output.puts "warning: #{warning}" }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def results(results)
|
|
18
|
+
failed = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
|
|
19
|
+
total = results.length
|
|
20
|
+
@output.puts "finished #{total} test entities, #{failed} failed"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Reporters
|
|
5
|
+
module Factory
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build(format, output: $stdout)
|
|
9
|
+
case format.to_s
|
|
10
|
+
when 'console'
|
|
11
|
+
Console.new(output: output)
|
|
12
|
+
when 'json'
|
|
13
|
+
JSON.new(output: output)
|
|
14
|
+
when 'junit'
|
|
15
|
+
JUnit.new(output: output)
|
|
16
|
+
when 'tui'
|
|
17
|
+
TUI.new(output: output)
|
|
18
|
+
else
|
|
19
|
+
raise Error, "unknown reporter: #{format}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Reporters
|
|
7
|
+
class JSON
|
|
8
|
+
def initialize(output: $stdout)
|
|
9
|
+
@output = output
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def selection(selection, selected_count:, total_count:)
|
|
13
|
+
@selection = {
|
|
14
|
+
selected: selected_count,
|
|
15
|
+
total: total_count,
|
|
16
|
+
reason: selection.reason,
|
|
17
|
+
safe: selection.safe?,
|
|
18
|
+
fallback: selection.fallback?,
|
|
19
|
+
warnings: selection.warnings
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def results(results)
|
|
24
|
+
@output.puts ::JSON.pretty_generate(selection: @selection, results: results)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Reporters
|
|
7
|
+
class JUnit
|
|
8
|
+
def initialize(output: $stdout)
|
|
9
|
+
@output = output
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def selection(_selection, selected_count:, total_count:)
|
|
13
|
+
@selected_count = selected_count
|
|
14
|
+
@total_count = total_count
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def results(results)
|
|
18
|
+
failures = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
|
|
19
|
+
@output.puts %(<?xml version="1.0" encoding="UTF-8"?>)
|
|
20
|
+
@output.puts %(<testsuite name="kaisoku" tests="#{@selected_count || results.length}" failures="#{failures}" skipped="#{skipped_count}">)
|
|
21
|
+
results.each { |id, payload| testcase(id, payload) }
|
|
22
|
+
@output.puts '</testsuite>'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def testcase(id, payload)
|
|
28
|
+
duration = (payload[:duration_ms] || 0).to_f / 1000.0
|
|
29
|
+
@output.puts %(<testcase classname="kaisoku" name="#{escape(id)}" time="#{format('%.3f', duration)}">)
|
|
30
|
+
failure(payload) if payload[:status] == 'failed' || payload[:error]
|
|
31
|
+
@output.puts '</testcase>'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failure(payload)
|
|
35
|
+
message = payload[:error] || payload[:status]
|
|
36
|
+
@output.puts %(<failure message="#{escape(message)}">#{escape(message)}</failure>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def skipped_count
|
|
40
|
+
return 0 unless @total_count && @selected_count
|
|
41
|
+
|
|
42
|
+
@total_count - @selected_count
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def escape(value)
|
|
46
|
+
CGI.escapeHTML(value.to_s)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Reporters
|
|
5
|
+
class TUI < Console
|
|
6
|
+
def selection(selection, selected_count:, total_count:)
|
|
7
|
+
clear
|
|
8
|
+
@started_at = Time.now
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def results(results)
|
|
13
|
+
failed = results.values.count { |payload| payload[:status] == 'failed' || payload[:error] }
|
|
14
|
+
elapsed = Time.now - (@started_at || Time.now)
|
|
15
|
+
@output.puts "status: #{failed.zero? ? 'passed' : 'failed'}"
|
|
16
|
+
@output.puts "elapsed: #{format('%.2fs', elapsed)}"
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def clear
|
|
23
|
+
return unless @output.tty?
|
|
24
|
+
|
|
25
|
+
@output.print "\e[2J\e[H"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'rails/integration'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class Runner
|
|
7
|
+
def initialize(adapter:, test_map:, collector: CoverageCollector.new, reporter: nil, rails_integration: nil)
|
|
8
|
+
@adapter = adapter
|
|
9
|
+
@test_map = test_map
|
|
10
|
+
@collector = collector
|
|
11
|
+
@reporter = reporter
|
|
12
|
+
@rails_integration = rails_integration || Rails::Integration.new(configuration: adapter.configuration)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(entities)
|
|
16
|
+
results = []
|
|
17
|
+
|
|
18
|
+
@rails_integration.wrap_reloader do
|
|
19
|
+
@adapter.run(entities, reporter: @reporter) do |event, entity, payload = {}|
|
|
20
|
+
case event
|
|
21
|
+
when :entity_started
|
|
22
|
+
@collector.start_entity
|
|
23
|
+
when :entity_finished
|
|
24
|
+
collection = @collector.finish_entity
|
|
25
|
+
result = EntityResult.new(
|
|
26
|
+
entity: entity,
|
|
27
|
+
payload: payload,
|
|
28
|
+
dependencies: collection.dependencies,
|
|
29
|
+
collection_skipped: collection.skipped?
|
|
30
|
+
)
|
|
31
|
+
persist_result(result)
|
|
32
|
+
results << result
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
results.to_h do |result|
|
|
38
|
+
[result.entity.id, result.payload]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def persist_result(result)
|
|
45
|
+
Scheduler::ResultPersister.new(test_map: @test_map).persist([result])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class RuntimeContext
|
|
5
|
+
attr_reader :options
|
|
6
|
+
|
|
7
|
+
def initialize(options)
|
|
8
|
+
@options = options
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new(
|
|
13
|
+
project_root: options[:project_root],
|
|
14
|
+
map_path: options[:map_path],
|
|
15
|
+
adapter: options[:adapter],
|
|
16
|
+
method_level_threshold: options[:method_level_threshold] || 20
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_map
|
|
21
|
+
@test_map ||= TestMap.new(configuration: configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def adapter
|
|
25
|
+
@adapter ||= Adapters::Factory.build(options[:adapter], configuration: configuration)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'etc'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Scheduler
|
|
7
|
+
class ForkPool
|
|
8
|
+
def initialize(configuration:, workers: Etc.nprocessors)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
@workers = [workers.to_i, 1].max
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(entities)
|
|
14
|
+
pending = Array(entities).dup
|
|
15
|
+
running = {}
|
|
16
|
+
results = []
|
|
17
|
+
before_fork
|
|
18
|
+
|
|
19
|
+
until pending.empty? && running.empty?
|
|
20
|
+
start_available_workers(pending, running)
|
|
21
|
+
pid, status = Process.wait2
|
|
22
|
+
reader, entity, index = running.delete(pid)
|
|
23
|
+
results << read_result(reader, entity, status)
|
|
24
|
+
@available_indexes << index
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
results
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def start_available_workers(pending, running)
|
|
33
|
+
while running.length < @workers && !pending.empty?
|
|
34
|
+
entity = pending.shift
|
|
35
|
+
reader, writer = IO.pipe
|
|
36
|
+
index = @available_indexes.shift || running.length
|
|
37
|
+
pid = fork_worker(entity, writer, index)
|
|
38
|
+
writer.close
|
|
39
|
+
running[pid] = [reader, entity, index]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fork_worker(entity, writer, index)
|
|
44
|
+
fork do
|
|
45
|
+
begin
|
|
46
|
+
reader = nil
|
|
47
|
+
run_child(entity, writer, index)
|
|
48
|
+
ensure
|
|
49
|
+
reader&.close
|
|
50
|
+
writer.close
|
|
51
|
+
end
|
|
52
|
+
exit! 0
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_child(entity, writer, index)
|
|
57
|
+
configure_worker_environment(index)
|
|
58
|
+
configuration = Configuration.new(
|
|
59
|
+
project_root: @configuration.project_root,
|
|
60
|
+
map_path: @configuration.map_path,
|
|
61
|
+
adapter: @configuration.adapter
|
|
62
|
+
)
|
|
63
|
+
adapter = Adapters::Factory.build(configuration.adapter, configuration: configuration)
|
|
64
|
+
adapter.after_fork(index)
|
|
65
|
+
collector = CoverageCollector.new(configuration: configuration)
|
|
66
|
+
result = Worker.new(adapter: adapter, collector: collector).run(entity)
|
|
67
|
+
Marshal.dump(result, writer)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def before_fork
|
|
71
|
+
@available_indexes = (0...@workers).to_a
|
|
72
|
+
adapter = Adapters::Factory.build(@configuration.adapter, configuration: @configuration)
|
|
73
|
+
adapter.before_fork(workers: @workers)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def configure_worker_environment(index)
|
|
77
|
+
ENV['KAISOKU_WORKER_INDEX'] = index.to_s
|
|
78
|
+
ENV['TEST_ENV_NUMBER'] = index.zero? ? '' : (index + 1).to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_result(reader, entity, status)
|
|
82
|
+
result = Marshal.load(reader)
|
|
83
|
+
return result if status.success?
|
|
84
|
+
|
|
85
|
+
EntityResult.new(
|
|
86
|
+
entity: entity,
|
|
87
|
+
payload: { status: 'failed', duration_ms: 0 },
|
|
88
|
+
dependencies: result.dependencies,
|
|
89
|
+
collection_skipped: true,
|
|
90
|
+
error: "worker exited with status #{status.exitstatus}"
|
|
91
|
+
)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
EntityResult.new(
|
|
94
|
+
entity: entity,
|
|
95
|
+
payload: { status: 'failed', duration_ms: 0 },
|
|
96
|
+
dependencies: {},
|
|
97
|
+
collection_skipped: true,
|
|
98
|
+
error: "#{e.class}: #{e.message}"
|
|
99
|
+
)
|
|
100
|
+
ensure
|
|
101
|
+
reader.close
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Scheduler
|
|
5
|
+
class ResultPersister
|
|
6
|
+
def initialize(test_map:)
|
|
7
|
+
@test_map = test_map
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def persist(results)
|
|
11
|
+
results.each_with_object({}) do |result, payloads|
|
|
12
|
+
runtime_entity = runtime_entity(result)
|
|
13
|
+
@test_map.upsert_entity(runtime_entity)
|
|
14
|
+
persist_dependencies(runtime_entity, result)
|
|
15
|
+
payloads[runtime_entity.id] = result.payload.merge(error: result.error).compact
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def runtime_entity(result)
|
|
22
|
+
result.entity.with_runtime(
|
|
23
|
+
avg_duration_ms: result.payload[:duration_ms],
|
|
24
|
+
last_status: result.status == 'passed' ? 'passed' : 'failed',
|
|
25
|
+
last_failed_at: result.failed? ? Time.now.to_i : result.entity.last_failed_at,
|
|
26
|
+
failure_count: failure_count(result)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def failure_count(result)
|
|
31
|
+
return result.entity.failure_count unless result.failed?
|
|
32
|
+
|
|
33
|
+
result.entity.failure_count.to_i + 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def persist_dependencies(entity, result)
|
|
37
|
+
return if result.collection_skipped
|
|
38
|
+
|
|
39
|
+
@test_map.replace_dependencies(entity, result.dependencies)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
Selection = Struct.new(
|
|
5
|
+
:entities,
|
|
6
|
+
:safe,
|
|
7
|
+
:fallback,
|
|
8
|
+
:invalidate_map,
|
|
9
|
+
:reason,
|
|
10
|
+
:warnings,
|
|
11
|
+
:ignored_files,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def initialize(
|
|
15
|
+
reason:, entities: [],
|
|
16
|
+
safe: true,
|
|
17
|
+
fallback: false,
|
|
18
|
+
invalidate_map: false,
|
|
19
|
+
warnings: [],
|
|
20
|
+
ignored_files: []
|
|
21
|
+
)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.none(ignored_files: [], reason: 'checksums unchanged')
|
|
26
|
+
new(entities: [], reason: reason, ignored_files: ignored_files)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.all(entities:, reason:, invalidate_map: false, warnings: [])
|
|
30
|
+
new(
|
|
31
|
+
entities: entities,
|
|
32
|
+
fallback: true,
|
|
33
|
+
invalidate_map: invalidate_map,
|
|
34
|
+
reason: reason,
|
|
35
|
+
warnings: warnings
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def count
|
|
40
|
+
entities.length
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def safe?
|
|
44
|
+
safe
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fallback?
|
|
48
|
+
fallback
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class Selector
|
|
5
|
+
def initialize(
|
|
6
|
+
test_map:,
|
|
7
|
+
configuration: Configuration.new,
|
|
8
|
+
checksum: Checksum.new,
|
|
9
|
+
static_analyzer: StaticAnalysis::Analyzer.new(configuration: configuration),
|
|
10
|
+
rails_integration: Rails::Integration.new(configuration: configuration),
|
|
11
|
+
hybrid_policy: nil
|
|
12
|
+
)
|
|
13
|
+
@test_map = test_map
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@checksum = checksum
|
|
16
|
+
@static_analyzer = static_analyzer
|
|
17
|
+
@method_map = MethodMap.new(configuration: configuration)
|
|
18
|
+
@rails_integration = rails_integration
|
|
19
|
+
@hybrid_policy = hybrid_policy || HybridPolicy.new(test_map: test_map, configuration: configuration)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def select(changed_paths:, all_entities:)
|
|
23
|
+
entities = Array(all_entities)
|
|
24
|
+
paths = Array(changed_paths).map { |path| @configuration.relative_path(path) }.uniq
|
|
25
|
+
|
|
26
|
+
return Selection.all(entities: entities, reason: 'test map is empty') if @test_map.empty?
|
|
27
|
+
|
|
28
|
+
invalidating = paths.select { |path| @configuration.invalidates_map?(path) }
|
|
29
|
+
unless invalidating.empty?
|
|
30
|
+
return Selection.all(
|
|
31
|
+
entities: entities,
|
|
32
|
+
reason: "map invalidating file changed: #{invalidating.join(', ')}",
|
|
33
|
+
invalidate_map: true
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
boot_files = paths.select { |path| @configuration.boot_impact_file?(path) }
|
|
38
|
+
unless boot_files.empty?
|
|
39
|
+
rails_selection = rails_boot_selection(boot_files, entities)
|
|
40
|
+
return rails_selection if rails_selection
|
|
41
|
+
|
|
42
|
+
return Selection.all(
|
|
43
|
+
entities: entities,
|
|
44
|
+
reason: "boot-impact file changed: #{boot_files.join(', ')}"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
select_from_known_changes(paths, entities)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def select_from_known_changes(paths, all_entities)
|
|
54
|
+
selected = []
|
|
55
|
+
ignored = []
|
|
56
|
+
reasons = []
|
|
57
|
+
|
|
58
|
+
paths.each do |path|
|
|
59
|
+
if @configuration.test_file?(path)
|
|
60
|
+
direct = direct_test_entities(path, all_entities)
|
|
61
|
+
selected.concat(direct)
|
|
62
|
+
reasons << "#{path} directly selected #{direct.length} test entities"
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless @test_map.known_file?(path)
|
|
67
|
+
return Selection.all(
|
|
68
|
+
entities: all_entities,
|
|
69
|
+
reason: "unknown changed file: #{path}",
|
|
70
|
+
warnings: static_warnings(path)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if checksum_unchanged?(path)
|
|
75
|
+
ignored << path
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
affected = affected_entities(path)
|
|
80
|
+
conventional = conventional_test_entities(path, all_entities) - affected
|
|
81
|
+
selected.concat(affected)
|
|
82
|
+
selected.concat(conventional)
|
|
83
|
+
reasons << selection_reason(path, affected, conventional)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unique = selected.uniq
|
|
87
|
+
return Selection.none(ignored_files: ignored) if unique.empty?
|
|
88
|
+
|
|
89
|
+
Selection.new(
|
|
90
|
+
entities: unique,
|
|
91
|
+
reason: reasons.join('; '),
|
|
92
|
+
ignored_files: ignored
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def direct_test_entities(path, all_entities)
|
|
97
|
+
from_map = @test_map.entities_for_test_files([path], adapter: @configuration.adapter)
|
|
98
|
+
return from_map unless from_map.empty?
|
|
99
|
+
|
|
100
|
+
all_entities.select { |entity| entity.adapter == @configuration.adapter && entity.file == path }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checksum_unchanged?(path)
|
|
104
|
+
stored = @test_map.checksums_for_file(path)
|
|
105
|
+
return false if stored.empty?
|
|
106
|
+
|
|
107
|
+
current = @checksum.digest(@configuration.absolute_path(path))
|
|
108
|
+
return false unless current
|
|
109
|
+
|
|
110
|
+
stored.all? { |checksum| checksum == current }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def affected_entities(path)
|
|
114
|
+
method_keys = changed_method_keys(path)
|
|
115
|
+
if !method_keys.empty? && @hybrid_policy.method_level?(path)
|
|
116
|
+
entities = @test_map.entities_for_dependency_keys(method_keys, adapter: @configuration.adapter)
|
|
117
|
+
return entities unless entities.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@test_map.entities_for_changed_files([path], adapter: @configuration.adapter)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def conventional_test_entities(path, all_entities)
|
|
124
|
+
@static_analyzer.conventional_tests_for(path).flat_map do |test_path|
|
|
125
|
+
direct_test_entities(test_path, all_entities)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def selection_reason(path, affected, conventional)
|
|
130
|
+
reason = "#{path} selected #{affected.length} dependent test entities"
|
|
131
|
+
return reason if conventional.empty?
|
|
132
|
+
|
|
133
|
+
"#{reason} and #{conventional.length} conventional test entities"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def changed_method_keys(path)
|
|
137
|
+
stored = @test_map.method_checksums_for_file(path)
|
|
138
|
+
return [] if stored.empty?
|
|
139
|
+
|
|
140
|
+
current = @method_map.checksums(path)
|
|
141
|
+
changed = stored.keys.reject { |key| current[key] == stored[key] }
|
|
142
|
+
return [top_level_key(path)] if changed.empty? && !checksum_unchanged?(path)
|
|
143
|
+
|
|
144
|
+
changed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def top_level_key(path)
|
|
148
|
+
"#{@configuration.relative_path(path)}##{MethodMap::TOP_LEVEL}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def static_warnings(path)
|
|
152
|
+
tests = @static_analyzer.conventional_tests_for(path)
|
|
153
|
+
return [] if tests.empty?
|
|
154
|
+
|
|
155
|
+
["static analysis found conventional tests: #{tests.join(', ')}; falling back to all tests for safety"]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def rails_boot_selection(paths, all_entities)
|
|
159
|
+
return nil unless paths.any? { |path| @rails_integration.database_change?(path) }
|
|
160
|
+
|
|
161
|
+
db_entities = @test_map.entities_with_dependency_prefixes(
|
|
162
|
+
@rails_integration.db_dependency_prefixes,
|
|
163
|
+
adapter: @configuration.adapter
|
|
164
|
+
)
|
|
165
|
+
return nil if db_entities.empty?
|
|
166
|
+
|
|
167
|
+
Selection.new(
|
|
168
|
+
entities: db_entities & all_entities,
|
|
169
|
+
reason: "Rails database-impact file changed: #{paths.join(', ')}"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|