klenod-test 0.0.5
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/README.md +98 -0
- data/lib/klenod/test/cli.rb +146 -0
- data/lib/klenod/test/config.rb +98 -0
- data/lib/klenod/test/coverage.rb +112 -0
- data/lib/klenod/test/plugin.rb +43 -0
- data/lib/klenod/test/runner.rb +166 -0
- data/lib/klenod/test/suite.rb +94 -0
- data/lib/klenod/test/version.rb +7 -0
- data/lib/klenod/test.rb +13 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 469fd8edab3de5dd48965dbb967cde185bc2e0b90f4081b9c7581adff7ee8287
|
|
4
|
+
data.tar.gz: e5f5c39e24cb625f210d083741749218f16dca6603901f5aa71dc3aeb8303ff1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4de48c343c46d765d52b0883c18b82d8599bd5a8d4eb3cad2068a01752fe4a3ed950c6491f96bcf8fe6c33a3bbf109f7475a4fb2e54b6b2ec710f1fad0f38db6
|
|
7
|
+
data.tar.gz: 3ab7371fe5dd99eb681ead786a718c8b07bdd5febd073900d0763babc8a249233680ce61b5778327acfba7d836c44938ab233ea4791bd3a184cab4f4279140b6
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# klenod-test
|
|
2
|
+
|
|
3
|
+
`klenod-test` discovers and runs application tests without choosing a test
|
|
4
|
+
framework. Add its plugin to the application's build configuration:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
plugins [
|
|
8
|
+
Klenod::Test::Plugin.new,
|
|
9
|
+
Klenod::Build::Plugins::RubyPlugin.new
|
|
10
|
+
]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The plugin finds `*.test.rb` files in deterministic order and prevents
|
|
14
|
+
application modules and other tests from importing them. Tests can import normal
|
|
15
|
+
application modules. `Klenod::Test::Suite` indexes each test's eager and lazy
|
|
16
|
+
dependency closure without evaluating application code.
|
|
17
|
+
|
|
18
|
+
The runner can run the full suite once or watch the module graph and rerun only
|
|
19
|
+
tests related to a change. Install the `klenod` meta-gem and run it from an
|
|
20
|
+
application directory:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bundle exec klenod test --run
|
|
24
|
+
bundle exec klenod test --watch
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Without an option, the command watches unless `CI` is set.
|
|
28
|
+
|
|
29
|
+
The command searches the current directory and its parents for
|
|
30
|
+
`klenod.test.rb`. The file supplies the application-specific context and test
|
|
31
|
+
framework adapter:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
context do
|
|
35
|
+
path = File.expand_path("klenod.config.rb", __dir__)
|
|
36
|
+
Klenod::Build::ConfigLoader.load(path).context
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
execute do |context, test_paths|
|
|
40
|
+
# Register and run the selected test modules, then return an exit status.
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
format_error do |error, context|
|
|
44
|
+
# Optionally format collection or evaluation errors.
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The runner does not choose a testing framework. Applications provide a context
|
|
49
|
+
factory and an execution callback. The same runner is also available as a Ruby
|
|
50
|
+
API:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
runner = Klenod::Test::Runner.new(
|
|
54
|
+
context: -> { build_config.context },
|
|
55
|
+
execute: ->(context, test_paths) { run_tests(context, test_paths) },
|
|
56
|
+
watch: true
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
exit runner.call
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each batch runs in a fresh worker process. The execution callback receives that
|
|
63
|
+
worker's context and sorted source-relative test paths, then returns an integer
|
|
64
|
+
exit status. By default, the runner starts the current Ruby program with
|
|
65
|
+
`--worker -- <test paths>`. A runner created by that program recognizes those
|
|
66
|
+
arguments automatically and executes the callback instead of starting another
|
|
67
|
+
worker. Pass `worker_command` and `worker_paths` explicitly when embedding the
|
|
68
|
+
runner in a command with its own argument handling.
|
|
69
|
+
|
|
70
|
+
## Coverage
|
|
71
|
+
|
|
72
|
+
Run the complete test suite once under Covered:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bundle exec klenod coverage
|
|
76
|
+
bundle exec klenod coverage --report partial --minimum 90
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Coverage defaults to the brief report with no required minimum. Configure both
|
|
80
|
+
defaults in `klenod.test.rb`:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
coverage report: :brief, minimum: 90
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Command-line values override the configuration. Reports may be `brief`,
|
|
87
|
+
`partial`, `full`, `markdown`, or `quiet`. The minimum is an overall percentage
|
|
88
|
+
from 0 through 100; falling below it returns a failing status.
|
|
89
|
+
|
|
90
|
+
Coverage includes evaluated application Ruby and source-mapped modules. Klenod
|
|
91
|
+
maps generated execution lines back to the original Haml, Markdown, or other
|
|
92
|
+
plugin source. It excludes test modules, gem and virtual modules, and generated
|
|
93
|
+
wrappers for data files. Source files which no test evaluates are not synthesized
|
|
94
|
+
into the report.
|
|
95
|
+
|
|
96
|
+
Collection wraps the application's existing `execute` callback, so it does not
|
|
97
|
+
depend on Minitest, RSpec, or another test framework. Frameworks that start
|
|
98
|
+
independent subprocesses need their own subprocess coverage integration.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
require "klenod/build/cli"
|
|
6
|
+
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "coverage"
|
|
9
|
+
require_relative "runner"
|
|
10
|
+
|
|
11
|
+
module Klenod
|
|
12
|
+
module Test
|
|
13
|
+
module CLI
|
|
14
|
+
class Command < Samovar::Command
|
|
15
|
+
self.description = "Run and watch application tests."
|
|
16
|
+
|
|
17
|
+
options do
|
|
18
|
+
option "--run", "Run all tests once."
|
|
19
|
+
option "--watch", "Run all tests, then watch for changes."
|
|
20
|
+
option "--worker", "Run selected test files in a worker process."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
split :test_paths, "Test paths passed to the worker."
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
config_path = ConfigLoader.find
|
|
27
|
+
unless config_path
|
|
28
|
+
output.puts "Could not find klenod.test.rb"
|
|
29
|
+
return 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
config = ConfigLoader.load(config_path)
|
|
33
|
+
Dir.chdir(config.base_dir) do
|
|
34
|
+
Klenod::Test::Runner.new(
|
|
35
|
+
context: config.context,
|
|
36
|
+
execute: config.execute,
|
|
37
|
+
**runner_options,
|
|
38
|
+
worker_command: worker_command,
|
|
39
|
+
output:,
|
|
40
|
+
format_error: config.format_error
|
|
41
|
+
).call
|
|
42
|
+
end
|
|
43
|
+
rescue ConfigError => error
|
|
44
|
+
output.puts error.message
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def runner_options
|
|
51
|
+
selected = [@options[:run] && false, @options[:watch] && true].compact
|
|
52
|
+
raise ConfigError, "Choose either --run or --watch" if selected.length > 1
|
|
53
|
+
|
|
54
|
+
paths = Array(@test_paths)
|
|
55
|
+
if @options[:worker]
|
|
56
|
+
raise ConfigError, "--worker cannot be combined with --run or --watch" unless selected.empty?
|
|
57
|
+
|
|
58
|
+
{worker_paths: paths}
|
|
59
|
+
elsif paths.empty?
|
|
60
|
+
selected.empty? ? {} : {watch: selected.first}
|
|
61
|
+
else
|
|
62
|
+
raise ConfigError, "Test paths are only accepted with --worker"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def worker_command
|
|
67
|
+
[RbConfig.ruby, Gem.bin_path("klenod", "klenod"), "test"]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class CoverageCommand < Samovar::Command
|
|
72
|
+
self.description = "Run the full application test suite with coverage."
|
|
73
|
+
|
|
74
|
+
options do
|
|
75
|
+
option "--report <name>", "Coverage report: brief, partial, full, markdown, or quiet."
|
|
76
|
+
option "--minimum <percent>", "Fail below this overall coverage percentage."
|
|
77
|
+
option "--worker", "Run selected test files in a coverage worker process."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
split :test_paths, "Test paths passed to the worker."
|
|
81
|
+
|
|
82
|
+
def call
|
|
83
|
+
config_path = ConfigLoader.find
|
|
84
|
+
unless config_path
|
|
85
|
+
output.puts "Could not find klenod.test.rb"
|
|
86
|
+
return 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
config = ConfigLoader.load(config_path)
|
|
90
|
+
coverage_config = coverage_config(config.coverage)
|
|
91
|
+
Dir.chdir(config.base_dir) do
|
|
92
|
+
Klenod::Test::Runner.new(
|
|
93
|
+
context: config.context,
|
|
94
|
+
execute: coverage_execute(config, coverage_config),
|
|
95
|
+
watch: false,
|
|
96
|
+
worker_paths: worker_paths,
|
|
97
|
+
spawn_empty: true,
|
|
98
|
+
worker_command: worker_command,
|
|
99
|
+
output:,
|
|
100
|
+
format_error: config.format_error
|
|
101
|
+
).call
|
|
102
|
+
end
|
|
103
|
+
rescue ConfigError => error
|
|
104
|
+
output.puts error.message
|
|
105
|
+
1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def coverage_config(config)
|
|
111
|
+
CoverageConfig.build(
|
|
112
|
+
report: @options[:report] || config.report,
|
|
113
|
+
minimum: @options[:minimum] || config.minimum
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def coverage_execute(config, coverage_config)
|
|
118
|
+
lambda do |context, test_paths|
|
|
119
|
+
plugin = context.graph.plugins.find { it.is_a?(Klenod::Test::Plugin) }
|
|
120
|
+
raise ArgumentError, "The Klenod context must include Klenod::Test::Plugin" unless plugin
|
|
121
|
+
|
|
122
|
+
CoverageRunner.new(context:, plugin:, config: coverage_config, output:).call do
|
|
123
|
+
config.execute.call(context, test_paths)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def worker_paths
|
|
129
|
+
paths = Array(@test_paths)
|
|
130
|
+
return paths if @options[:worker]
|
|
131
|
+
|
|
132
|
+
raise ConfigError, "Test paths are only accepted with --worker" unless paths.empty?
|
|
133
|
+
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def worker_command
|
|
138
|
+
command = [RbConfig.ruby, Gem.bin_path("klenod", "klenod"), "coverage"]
|
|
139
|
+
command.concat(["--report", @options[:report]]) if @options[:report]
|
|
140
|
+
command.concat(["--minimum", @options[:minimum]]) if @options[:minimum]
|
|
141
|
+
command
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module Test
|
|
5
|
+
class ConfigError < ArgumentError; end
|
|
6
|
+
|
|
7
|
+
COVERAGE_REPORTS = %i[brief partial full markdown quiet].freeze
|
|
8
|
+
|
|
9
|
+
CoverageConfig = Data.define(:report, :minimum) do
|
|
10
|
+
def self.build(report: :brief, minimum: nil)
|
|
11
|
+
report = report.to_s.downcase.to_sym
|
|
12
|
+
unless COVERAGE_REPORTS.include?(report)
|
|
13
|
+
raise ConfigError, "Unknown coverage report #{report.inspect}; choose one of: #{COVERAGE_REPORTS.join(", ")}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
new(report, normalize_minimum(minimum))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.normalize_minimum(minimum)
|
|
20
|
+
return unless minimum
|
|
21
|
+
|
|
22
|
+
value = begin
|
|
23
|
+
Float(minimum)
|
|
24
|
+
rescue ArgumentError, TypeError
|
|
25
|
+
raise ConfigError, "Coverage minimum must be a number between 0 and 100"
|
|
26
|
+
end
|
|
27
|
+
return value if value.between?(0, 100)
|
|
28
|
+
|
|
29
|
+
raise ConfigError, "Coverage minimum must be between 0 and 100"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Config = Data.define(:base_dir, :context, :execute, :format_error, :coverage)
|
|
34
|
+
|
|
35
|
+
class ConfigBuilder
|
|
36
|
+
def initialize(path)
|
|
37
|
+
@path = File.expand_path(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def context(&block)
|
|
41
|
+
@context = block
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute(&block)
|
|
45
|
+
@execute = block
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def format_error(&block)
|
|
49
|
+
@format_error = block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def coverage(report: :brief, minimum: nil)
|
|
53
|
+
@coverage = CoverageConfig.build(report:, minimum:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def config
|
|
57
|
+
missing = []
|
|
58
|
+
missing << "context" unless @context
|
|
59
|
+
missing << "execute" unless @execute
|
|
60
|
+
raise ConfigError, "#{@path}: missing #{missing.join(" and ")}" unless missing.empty?
|
|
61
|
+
|
|
62
|
+
Config.new(
|
|
63
|
+
base_dir: File.dirname(@path),
|
|
64
|
+
context: @context,
|
|
65
|
+
execute: @execute,
|
|
66
|
+
format_error: @format_error,
|
|
67
|
+
coverage: @coverage || CoverageConfig.build
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module ConfigLoader
|
|
73
|
+
CONFIG_FILE = "klenod.test.rb"
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
def find(start_dir = Dir.pwd)
|
|
78
|
+
dir = File.expand_path(start_dir)
|
|
79
|
+
|
|
80
|
+
loop do
|
|
81
|
+
path = File.join(dir, CONFIG_FILE)
|
|
82
|
+
return path if File.file?(path)
|
|
83
|
+
|
|
84
|
+
parent = File.dirname(dir)
|
|
85
|
+
return nil if parent == dir
|
|
86
|
+
|
|
87
|
+
dir = parent
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def load(path)
|
|
92
|
+
builder = ConfigBuilder.new(path)
|
|
93
|
+
builder.instance_eval(File.read(path), path, 1)
|
|
94
|
+
builder.config
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "covered"
|
|
4
|
+
require "covered/markdown_summary"
|
|
5
|
+
require "covered/statistics"
|
|
6
|
+
|
|
7
|
+
require_relative "config"
|
|
8
|
+
|
|
9
|
+
module Klenod
|
|
10
|
+
module Test
|
|
11
|
+
class CoverageResult < Covered::Wrapper
|
|
12
|
+
def initialize(output, context:, plugin:)
|
|
13
|
+
super(output)
|
|
14
|
+
@modules_by_path = context.graph.mods.to_h do |module_id, mod|
|
|
15
|
+
[File.expand_path(mod.eval_path), [module_id, mod]]
|
|
16
|
+
end
|
|
17
|
+
@plugin = plugin
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each
|
|
21
|
+
return enum_for unless block_given?
|
|
22
|
+
|
|
23
|
+
super do |coverage|
|
|
24
|
+
mapped = map(coverage)
|
|
25
|
+
yield mapped if mapped
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :modules_by_path, :plugin
|
|
32
|
+
|
|
33
|
+
def map(coverage)
|
|
34
|
+
module_id, mod = modules_by_path[File.expand_path(coverage.path)]
|
|
35
|
+
return unless module_id&.scheme == :app
|
|
36
|
+
return if plugin.test_module_id?(module_id)
|
|
37
|
+
|
|
38
|
+
if mod.source_map
|
|
39
|
+
map_source(coverage, mod.source_map)
|
|
40
|
+
elsif module_id.extname == ".rb"
|
|
41
|
+
coverage
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def map_source(coverage, source_map)
|
|
46
|
+
counts = Array.new(source_map.input.lines.count + 1)
|
|
47
|
+
input_line = nil
|
|
48
|
+
|
|
49
|
+
coverage.counts.each_with_index do |count, output_line|
|
|
50
|
+
input_line = source_map.marks_by_output_line[output_line]&.line || input_line
|
|
51
|
+
next if count.nil?
|
|
52
|
+
next unless input_line
|
|
53
|
+
|
|
54
|
+
counts[input_line] = [counts[input_line] || 0, count].max
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return unless counts.any? { |count| !count.nil? }
|
|
58
|
+
|
|
59
|
+
Covered::Coverage.new(Covered::Source.for(coverage.path), counts)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class CoverageRunner
|
|
64
|
+
REPORTS = {
|
|
65
|
+
brief: Covered::BriefSummary,
|
|
66
|
+
partial: Covered::PartialSummary,
|
|
67
|
+
full: Covered::FullSummary,
|
|
68
|
+
markdown: Covered::MarkdownSummary,
|
|
69
|
+
quiet: Covered::Quiet
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
def initialize(context:, plugin:, config:, output: $stdout)
|
|
73
|
+
@context = context
|
|
74
|
+
@plugin = plugin
|
|
75
|
+
@config = config
|
|
76
|
+
@output = output
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def call
|
|
80
|
+
policy = Covered::Policy.new
|
|
81
|
+
policy.root(context.graph.source_dir.to_s)
|
|
82
|
+
policy.start
|
|
83
|
+
status = begin
|
|
84
|
+
Integer(yield)
|
|
85
|
+
ensure
|
|
86
|
+
policy.finish
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = CoverageResult.new(policy, context:, plugin:)
|
|
90
|
+
REPORTS.fetch(config.report).new.call(result, output)
|
|
91
|
+
validate_minimum(result, status)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
attr_reader :context, :plugin, :config, :output
|
|
97
|
+
|
|
98
|
+
def validate_minimum(result, status)
|
|
99
|
+
return status unless config.minimum
|
|
100
|
+
|
|
101
|
+
statistics = Covered::Statistics.new
|
|
102
|
+
result.each { |coverage| statistics << coverage }
|
|
103
|
+
statistics.validate!(config.minimum / 100.0)
|
|
104
|
+
status
|
|
105
|
+
rescue Covered::CoverageError => error
|
|
106
|
+
output.puts
|
|
107
|
+
output.puts error.message
|
|
108
|
+
status.zero? ? 1 : status
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "klenod/build/errors"
|
|
4
|
+
require "klenod/build/module_id"
|
|
5
|
+
require "klenod/build/plugin"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module Test
|
|
9
|
+
class ImportError < Klenod::Build::ResolveError; end
|
|
10
|
+
|
|
11
|
+
class Plugin < Klenod::Build::Plugin
|
|
12
|
+
DEFAULT_PATTERN = "**/*.test.rb"
|
|
13
|
+
|
|
14
|
+
def initialize(pattern: DEFAULT_PATTERN)
|
|
15
|
+
@pattern = pattern.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :pattern
|
|
19
|
+
|
|
20
|
+
def discover(source_dir:)
|
|
21
|
+
root = Pathname.new(source_dir).expand_path
|
|
22
|
+
|
|
23
|
+
root
|
|
24
|
+
.glob(pattern)
|
|
25
|
+
.select(&:file?)
|
|
26
|
+
.map { |path| Klenod::Build::ModuleId.new(path.relative_path_from(root).to_s.tr("\\", "/"), nil) }
|
|
27
|
+
.sort_by(&:to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_module_id?(module_id)
|
|
31
|
+
module_id.scheme == :app && File.fnmatch?(pattern, module_id.relative_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finalize(module_id, result, resolved_dependencies, _dependency_records, _context)
|
|
35
|
+
imported_test = resolved_dependencies.find { |dependency| test_module_id?(dependency.module_id) }
|
|
36
|
+
return result unless imported_test
|
|
37
|
+
|
|
38
|
+
error = ImportError.new("Test file #{imported_test.module_id.path.inspect} cannot be imported")
|
|
39
|
+
raise error.with_resolution_context(dependency: imported_test.dependency, importer_id: module_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
require "async/process"
|
|
6
|
+
require "klenod/build"
|
|
7
|
+
|
|
8
|
+
require_relative "plugin"
|
|
9
|
+
require_relative "suite"
|
|
10
|
+
|
|
11
|
+
module Klenod
|
|
12
|
+
module Test
|
|
13
|
+
class Runner
|
|
14
|
+
WORKER_ARGUMENT = "--worker"
|
|
15
|
+
|
|
16
|
+
def self.worker_paths_from(arguments)
|
|
17
|
+
arguments = Array(arguments)
|
|
18
|
+
return unless arguments.first == WORKER_ARGUMENT
|
|
19
|
+
raise ArgumentError, "Expected -- after #{WORKER_ARGUMENT}" unless arguments[1] == "--"
|
|
20
|
+
|
|
21
|
+
arguments.drop(2)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(
|
|
25
|
+
context:,
|
|
26
|
+
execute:,
|
|
27
|
+
watch: nil,
|
|
28
|
+
worker_paths: Runner.worker_paths_from(ARGV),
|
|
29
|
+
spawn_empty: false,
|
|
30
|
+
worker_command: [RbConfig.ruby, $PROGRAM_NAME],
|
|
31
|
+
process: Async::Process,
|
|
32
|
+
output: $stdout,
|
|
33
|
+
error_output: $stderr,
|
|
34
|
+
env: ENV,
|
|
35
|
+
format_error: nil
|
|
36
|
+
)
|
|
37
|
+
@context_factory = context
|
|
38
|
+
@execute = execute
|
|
39
|
+
@watch = watch.nil? ? !env.key?("CI") : watch
|
|
40
|
+
@worker_paths = worker_paths&.dup
|
|
41
|
+
@spawn_empty = spawn_empty
|
|
42
|
+
@worker_command = Array(worker_command)
|
|
43
|
+
@process = process
|
|
44
|
+
@output = output
|
|
45
|
+
@error_output = error_output
|
|
46
|
+
@env = env
|
|
47
|
+
@format_error = format_error || ->(error, _context) { error.full_message }
|
|
48
|
+
@last_status = 0
|
|
49
|
+
@worker_mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call
|
|
53
|
+
return run_worker(worker_paths) if worker_paths
|
|
54
|
+
|
|
55
|
+
context = context_factory.call
|
|
56
|
+
plugin = test_plugin(context)
|
|
57
|
+
suite = Klenod::Test::Suite.new(context:, plugin:)
|
|
58
|
+
selection = suite.collect
|
|
59
|
+
return run_in_worker(selection.test_paths) unless watch
|
|
60
|
+
|
|
61
|
+
run_watch(context, suite, selection)
|
|
62
|
+
rescue ArgumentError => error
|
|
63
|
+
error_output.puts error.message
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :context_factory, :execute, :watch, :worker_paths, :worker_command,
|
|
70
|
+
:spawn_empty, :process, :output, :error_output, :env, :format_error, :last_status
|
|
71
|
+
|
|
72
|
+
def run_watch(context, suite, selection)
|
|
73
|
+
watcher = build_watcher(context)
|
|
74
|
+
context.on_update do |event|
|
|
75
|
+
related = suite.update(event)
|
|
76
|
+
log_removed(related.removed_test_paths)
|
|
77
|
+
run_in_worker(related.test_paths) unless related.test_paths.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
watcher.start
|
|
82
|
+
run_in_worker(selection.test_paths)
|
|
83
|
+
wait_for_changes
|
|
84
|
+
rescue Interrupt
|
|
85
|
+
output.puts
|
|
86
|
+
ensure
|
|
87
|
+
watcher.stop
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
last_status
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_in_worker(test_paths)
|
|
94
|
+
@worker_mutex.synchronize do
|
|
95
|
+
clear_screen
|
|
96
|
+
report_test_paths(test_paths)
|
|
97
|
+
@last_status = (test_paths.empty? && !spawn_empty) ? 0 : worker_status(test_paths)
|
|
98
|
+
report_watch_status if watch
|
|
99
|
+
last_status
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def worker_status(test_paths)
|
|
104
|
+
status = process.spawn(*worker_command, WORKER_ARGUMENT, "--", *test_paths)
|
|
105
|
+
status.success? ? 0 : (status.exitstatus || 1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run_worker(test_paths)
|
|
109
|
+
context = context_factory.call
|
|
110
|
+
Integer(execute.call(context, test_paths))
|
|
111
|
+
rescue => error
|
|
112
|
+
error_output.puts format_error.call(error, context)
|
|
113
|
+
1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def test_plugin(context)
|
|
117
|
+
context.graph.plugins.find do |candidate|
|
|
118
|
+
candidate.is_a?(Klenod::Test::Plugin)
|
|
119
|
+
end || raise(ArgumentError, "The Klenod context must include Klenod::Test::Plugin")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_watcher(context)
|
|
123
|
+
Klenod::Build::Watcher.new(source_dir: context.graph.source_dir, context:)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def wait_for_changes
|
|
127
|
+
loop { sleep }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def log_removed(paths)
|
|
131
|
+
paths.each { |path| output.puts "Removed #{path}" }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def clear_screen
|
|
135
|
+
return unless watch && output.respond_to?(:tty?) && output.tty?
|
|
136
|
+
|
|
137
|
+
output.print "\e[2J\e[H"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def report_test_paths(test_paths)
|
|
141
|
+
count = test_paths.length
|
|
142
|
+
output.puts "#{color(:run, " RUN ")} #{count} test #{(count == 1) ? "file" : "files"}"
|
|
143
|
+
output.puts
|
|
144
|
+
test_paths.each { |path| output.puts " #{path}" }
|
|
145
|
+
output.puts
|
|
146
|
+
output.flush
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def report_watch_status
|
|
150
|
+
status = last_status.zero? ? :success : :failure
|
|
151
|
+
label = last_status.zero? ? " PASS " : " FAIL "
|
|
152
|
+
output.puts
|
|
153
|
+
output.puts "#{color(status, label)} Watching for file changes..."
|
|
154
|
+
output.flush
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def color(name, value)
|
|
158
|
+
return value if env.key?("NO_COLOR")
|
|
159
|
+
return value unless output.respond_to?(:tty?) && output.tty?
|
|
160
|
+
|
|
161
|
+
codes = {run: "\e[1;34m", success: "\e[1;32m", failure: "\e[1;31m"}
|
|
162
|
+
"#{codes.fetch(name)}#{value}\e[0m"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module Test
|
|
5
|
+
Selection = Data.define(:test_paths, :removed_test_paths) do
|
|
6
|
+
def empty?
|
|
7
|
+
test_paths.empty? && removed_test_paths.empty?
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Suite
|
|
12
|
+
def initialize(context:, plugin:)
|
|
13
|
+
@context = context
|
|
14
|
+
@plugin = plugin
|
|
15
|
+
@dependency_ids = {}
|
|
16
|
+
@failed_test_ids = Set.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def collect
|
|
20
|
+
test_ids = discover
|
|
21
|
+
test_ids.each { |test_id| index(test_id) }
|
|
22
|
+
selection(test_ids)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update(event)
|
|
26
|
+
previous_test_ids = @dependency_ids.keys.to_set
|
|
27
|
+
current_test_ids = discover.to_set
|
|
28
|
+
removed_test_ids = previous_test_ids - current_test_ids
|
|
29
|
+
added_test_ids = current_test_ids - previous_test_ids
|
|
30
|
+
affected_module_ids = update_module_ids(event)
|
|
31
|
+
affected_test_ids =
|
|
32
|
+
@dependency_ids.filter_map do |test_id, dependency_ids|
|
|
33
|
+
test_id if current_test_ids.include?(test_id) && dependency_ids.intersect?(affected_module_ids)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
test_ids = (added_test_ids + affected_test_ids + @failed_test_ids).select { |test_id| current_test_ids.include?(test_id) }
|
|
37
|
+
removed_test_ids.each { |test_id| remove(test_id) }
|
|
38
|
+
test_ids.each { |test_id| index(test_id) }
|
|
39
|
+
|
|
40
|
+
selection(test_ids, removed_test_ids)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :context, :plugin
|
|
46
|
+
|
|
47
|
+
def discover
|
|
48
|
+
plugin.discover(source_dir: context.graph.source_dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def index(test_id)
|
|
52
|
+
dependency_ids = Set[test_id]
|
|
53
|
+
@dependency_ids[test_id] = dependency_ids
|
|
54
|
+
collect_dependencies(test_id, dependency_ids)
|
|
55
|
+
@failed_test_ids.delete(test_id)
|
|
56
|
+
rescue
|
|
57
|
+
@failed_test_ids << test_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect_dependencies(module_id, dependency_ids)
|
|
61
|
+
record = context.graph.records[module_id] || context.graph.collect_module(module_id)
|
|
62
|
+
|
|
63
|
+
record.resolved_dependencies.each do |dependency|
|
|
64
|
+
dependency_id = dependency.module_id
|
|
65
|
+
next unless dependency_ids.add?(dependency_id)
|
|
66
|
+
|
|
67
|
+
collect_dependencies(dependency_id, dependency_ids)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def remove(test_id)
|
|
72
|
+
@dependency_ids.delete(test_id)
|
|
73
|
+
@failed_test_ids.delete(test_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update_module_ids(event)
|
|
77
|
+
result = event.result
|
|
78
|
+
Set.new(
|
|
79
|
+
result.changed_module_ids +
|
|
80
|
+
result.removed_module_ids +
|
|
81
|
+
result.reloaded_module_ids +
|
|
82
|
+
result.reevaluated_module_ids
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def selection(test_ids, removed_test_ids = [])
|
|
87
|
+
Selection.new(
|
|
88
|
+
test_ids.map(&:relative_path).sort.freeze,
|
|
89
|
+
removed_test_ids.map(&:relative_path).sort.freeze
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/klenod/test.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test/version"
|
|
4
|
+
require_relative "test/config"
|
|
5
|
+
require_relative "test/plugin"
|
|
6
|
+
require_relative "test/suite"
|
|
7
|
+
require_relative "test/runner"
|
|
8
|
+
require_relative "test/coverage"
|
|
9
|
+
|
|
10
|
+
module Klenod
|
|
11
|
+
module Test
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: klenod-test
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.5
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrés Alin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: klenod-build
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.0.5
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.0.5
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async-process
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.4'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.4'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: covered
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.30'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.30'
|
|
54
|
+
description: Framework-independent test selection, watch runs, and coverage for Klenod
|
|
55
|
+
applications.
|
|
56
|
+
email:
|
|
57
|
+
- andreas.alin@gmail.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/klenod/test.rb
|
|
64
|
+
- lib/klenod/test/cli.rb
|
|
65
|
+
- lib/klenod/test/config.rb
|
|
66
|
+
- lib/klenod/test/coverage.rb
|
|
67
|
+
- lib/klenod/test/plugin.rb
|
|
68
|
+
- lib/klenod/test/runner.rb
|
|
69
|
+
- lib/klenod/test/suite.rb
|
|
70
|
+
- lib/klenod/test/version.rb
|
|
71
|
+
homepage: https://github.com/aalin/klenod
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://github.com/aalin/klenod
|
|
76
|
+
source_code_uri: https://github.com/aalin/klenod/tree/main/gems/klenod-test
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: 4.0.6
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 4.0.16
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Test runner for Klenod applications.
|
|
94
|
+
test_files: []
|