coatepec 0.7.0 → 0.8.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 +4 -4
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +111 -0
- data/README.md +107 -31
- data/ROADMAP.md +28 -62
- data/lib/coatepec/introspection/bounded_options.rb +22 -0
- data/lib/coatepec/introspection/controller.rb +19 -28
- data/lib/coatepec/introspection/model.rb +33 -43
- data/lib/coatepec/introspection/model_resolver.rb +32 -0
- data/lib/coatepec/introspection/route_entries.rb +77 -0
- data/lib/coatepec/introspection/routes.rb +70 -20
- data/lib/coatepec/mcp/response.rb +16 -9
- data/lib/coatepec/mcp/tools.rb +85 -90
- data/lib/coatepec/mcp.rb +2 -2
- data/lib/coatepec/project.rb +11 -1
- data/lib/coatepec/spec/failure_collapser.rb +111 -0
- data/lib/coatepec/spec/flaky_checker.rb +3 -1
- data/lib/coatepec/spec/fork_strategy.rb +3 -12
- data/lib/coatepec/spec/guarded_fork_strategy.rb +11 -10
- data/lib/coatepec/spec/path_policy.rb +45 -12
- data/lib/coatepec/spec/process_strategy.rb +22 -17
- data/lib/coatepec/spec/result.rb +61 -19
- data/lib/coatepec/spec/rspec_adapter.rb +58 -0
- data/lib/coatepec/spec/runner.rb +22 -24
- data/lib/coatepec/spec/spawn_strategy.rb +7 -8
- data/lib/coatepec/test_unit/adapter.rb +132 -0
- data/lib/coatepec/test_unit/child_entry.rb +33 -0
- data/lib/coatepec/test_unit/json_reporter.rb +107 -0
- data/lib/coatepec/test_unit/line_filtering.rb +46 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/server.rb +2 -1
- data/lib/coatepec/worker_manager.rb +8 -6
- data/lib/coatepec.rb +5 -0
- metadata +16 -5
|
@@ -4,18 +4,22 @@ require "tempfile"
|
|
|
4
4
|
|
|
5
5
|
module Coatepec
|
|
6
6
|
module Spec
|
|
7
|
-
# Shared child-process lifecycle for running an isolated
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Shared child-process lifecycle for running an isolated test run (RSpec
|
|
8
|
+
# or Minitest, chosen by the injected adapter): spawns (via a subclass's
|
|
9
|
+
# #start), reaps with a timeout budget (TERM then KILL on overrun), and
|
|
10
|
+
# hands the captured output/JSON to Result. Subclasses (ForkStrategy,
|
|
11
|
+
# SpawnStrategy) only implement how the child is started; the adapter
|
|
12
|
+
# supplies the framework-specific CLI args, in-process call, and spawn
|
|
13
|
+
# command line.
|
|
11
14
|
class ProcessStrategy
|
|
12
|
-
def initialize(project_root, project: nil, rails_runtime: nil)
|
|
15
|
+
def initialize(project_root, adapter: nil, project: nil, rails_runtime: nil)
|
|
13
16
|
@project_root = project_root
|
|
17
|
+
@adapter = adapter || RSpecAdapter.new(project_root)
|
|
14
18
|
@project = project
|
|
15
19
|
@rails_runtime = rails_runtime
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
def run(args, timeout_seconds)
|
|
22
|
+
def run(args, timeout_seconds, include_passing: false, include_stdout: "failures")
|
|
19
23
|
out_r, out_w = IO.pipe
|
|
20
24
|
err_r, err_w = IO.pipe
|
|
21
25
|
json_path = Tempfile.create(["coatepec-rspec", ".json"], &:path)
|
|
@@ -23,14 +27,16 @@ module Coatepec
|
|
|
23
27
|
pid = start_or_release(args, out_w, err_w, json_path, [out_r, err_r])
|
|
24
28
|
[out_w, err_w].each(&:close)
|
|
25
29
|
|
|
26
|
-
reap(pid, out_r, err_r, timeout_seconds, json_path
|
|
30
|
+
reap(pid, [out_r, err_r], timeout_seconds, json_path,
|
|
31
|
+
{ include_passing: include_passing, include_stdout: include_stdout })
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
30
35
|
|
|
31
|
-
# Subclasses start a process and return its pid;
|
|
32
|
-
# must be wired to out_w/err_w.
|
|
33
|
-
|
|
36
|
+
# Subclasses start a process and return its pid; the test framework's
|
|
37
|
+
# own output must be wired to out_w/err_w. json_path is where the
|
|
38
|
+
# adapter's structured per-example output must land.
|
|
39
|
+
def start(_full_args, _out_w, _err_w, _json_path)
|
|
34
40
|
raise NotImplementedError, "#{self.class} must implement #start"
|
|
35
41
|
end
|
|
36
42
|
|
|
@@ -39,20 +45,19 @@ module Coatepec
|
|
|
39
45
|
# released have to be freed here. The original exception still reaches
|
|
40
46
|
# the caller -- only GuardedForkStrategy intercepts it to fall back.
|
|
41
47
|
def start_or_release(args, out_w, err_w, json_path, read_ends)
|
|
42
|
-
start(args +
|
|
48
|
+
start(args + @adapter.json_args(json_path), out_w, err_w, json_path)
|
|
43
49
|
rescue StandardError
|
|
44
50
|
([out_w, err_w] + read_ends).each { |io| io.close unless io.closed? }
|
|
45
51
|
File.delete(json_path) if File.exist?(json_path)
|
|
46
52
|
raise
|
|
47
53
|
end
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def reap(pid, out_r, err_r, timeout_seconds, json_path)
|
|
55
|
+
# result_options are Result.build's include_* keywords, bundled so the positional list stays short.
|
|
56
|
+
def reap(pid, pipes, timeout_seconds, json_path, result_options)
|
|
57
|
+
out_r, err_r = pipes
|
|
54
58
|
status = wait_with_timeout(pid, timeout_seconds)
|
|
55
|
-
result = Result.build(pid: pid, status: status, out_r: out_r, err_r: err_r, json_path: json_path
|
|
59
|
+
result = Result.build(pid: pid, status: status, out_r: out_r, err_r: err_r, json_path: json_path,
|
|
60
|
+
**result_options)
|
|
56
61
|
[out_r, err_r].each(&:close)
|
|
57
62
|
result
|
|
58
63
|
ensure
|
data/lib/coatepec/spec/result.rb
CHANGED
|
@@ -4,45 +4,79 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Coatepec
|
|
6
6
|
module Spec
|
|
7
|
-
# Turns a finished
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Turns a finished test child's exit status, captured output (each capped
|
|
8
|
+
# at MAX_OUTPUT_BYTES) and the framework's JSON summary into the flat
|
|
9
|
+
# result hash rails_spec_run returns. Passing examples are omitted unless
|
|
10
|
+
# include_passing; stdout is kept for failing runs unless include_stdout
|
|
11
|
+
# says always or never, and its repeated failure blocks are collapsed by FailureCollapser.
|
|
10
12
|
module Result
|
|
11
13
|
MAX_OUTPUT_BYTES = 256 * 1024
|
|
14
|
+
MAX_EXAMPLES = 500
|
|
15
|
+
STDOUT_MODES = %w[failures always never].freeze
|
|
12
16
|
|
|
13
17
|
module_function
|
|
14
18
|
|
|
15
|
-
def build(pid:, status:, out_r:, err_r:, json_path:)
|
|
16
|
-
|
|
19
|
+
def build(pid:, status:, out_r:, err_r:, json_path:, include_passing: false, include_stdout: "failures")
|
|
20
|
+
validate_stdout_mode!(include_stdout)
|
|
21
|
+
captured = read_bounded(out_r)
|
|
22
|
+
stdout_result = keep_stdout?(include_stdout, status) ? collapse_failures(captured) : captured.merge(text: nil)
|
|
17
23
|
stderr_result = read_bounded(err_r)
|
|
18
24
|
summary = read_summary(json_path)
|
|
19
25
|
|
|
20
26
|
base(pid, status, stdout_result, stderr_result).merge(
|
|
21
27
|
summary: summary && summary_fields(summary),
|
|
22
|
-
examples: (summary
|
|
28
|
+
examples: selected_examples(summary, include_passing).map { |e| example_fields(e) }
|
|
23
29
|
)
|
|
24
30
|
end
|
|
25
31
|
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
# A green run's stdout is progress dots and a summary line the payload already carries as counts.
|
|
33
|
+
def keep_stdout?(mode, status)
|
|
34
|
+
mode == "always" || (mode == "failures" && !passed?(status))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def passed?(status)
|
|
38
|
+
status.exited? && status.exitstatus.zero?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The MCP schema enforces the enum; this guards the worker command against any other caller.
|
|
42
|
+
def validate_stdout_mode!(mode)
|
|
43
|
+
return if STDOUT_MODES.include?(mode)
|
|
44
|
+
|
|
45
|
+
raise Coatepec::Error.new(:invalid_include_stdout,
|
|
46
|
+
"include_stdout must be one of #{STDOUT_MODES.join(", ")}, got #{mode.inspect}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Repeated failure text is the bulk of a failing run's stdout; examples[] already names every failing test.
|
|
50
|
+
def collapse_failures(captured)
|
|
51
|
+
captured.merge(text: FailureCollapser.call(captured[:text]))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Passing examples are dropped before the cap so a large green run never crowds out its failures.
|
|
55
|
+
def selected_examples(summary, include_passing)
|
|
56
|
+
examples = summary&.dig("examples") || []
|
|
57
|
+
examples = examples.reject { |e| e["status"] == "passed" } unless include_passing
|
|
58
|
+
examples.first(MAX_EXAMPLES)
|
|
59
|
+
end
|
|
60
|
+
|
|
30
61
|
def base(pid, status, stdout_result, stderr_result)
|
|
31
62
|
{
|
|
32
|
-
status:
|
|
63
|
+
status: passed?(status) ? "passed" : "failed",
|
|
33
64
|
exit_code: status.exitstatus,
|
|
34
|
-
|
|
35
|
-
signaled: status.signaled?,
|
|
36
|
-
termsig: status.termsig,
|
|
37
|
-
stopsig: status.stopsig,
|
|
38
|
-
coredump: status.respond_to?(:coredump?) ? status.coredump? : false,
|
|
65
|
+
**process_fields(pid, status),
|
|
39
66
|
stdout: stdout_result[:text],
|
|
40
67
|
stdout_truncated: stdout_result[:truncated],
|
|
41
68
|
stderr: stderr_result[:text],
|
|
42
69
|
stderr_truncated: stderr_result[:truncated]
|
|
43
70
|
}
|
|
44
71
|
end
|
|
45
|
-
|
|
72
|
+
|
|
73
|
+
# A normal exit has nothing to say about signals; these five appear only when the child did not exit.
|
|
74
|
+
def process_fields(pid, status)
|
|
75
|
+
return {} if status.exited?
|
|
76
|
+
|
|
77
|
+
{ child_pid: pid, signaled: status.signaled?, termsig: status.termsig, stopsig: status.stopsig,
|
|
78
|
+
coredump: status.respond_to?(:coredump?) ? status.coredump? : false }
|
|
79
|
+
end
|
|
46
80
|
|
|
47
81
|
def read_summary(json_path)
|
|
48
82
|
return nil unless File.exist?(json_path) && !File.empty?(json_path)
|
|
@@ -50,22 +84,30 @@ module Coatepec
|
|
|
50
84
|
JSON.parse(File.read(json_path))
|
|
51
85
|
end
|
|
52
86
|
|
|
87
|
+
# error_count and assertion_count exist only for Minitest; RSpec's JSON has neither, so they read as nil.
|
|
53
88
|
def summary_fields(summary)
|
|
54
89
|
{
|
|
55
90
|
example_count: summary.dig("summary", "example_count"),
|
|
56
91
|
failure_count: summary.dig("summary", "failure_count"),
|
|
92
|
+
error_count: summary.dig("summary", "error_count"),
|
|
93
|
+
pending_count: summary.dig("summary", "pending_count"),
|
|
94
|
+
assertion_count: summary.dig("summary", "assertion_count"),
|
|
57
95
|
duration: summary.dig("summary", "duration")
|
|
58
96
|
}
|
|
59
97
|
end
|
|
60
98
|
|
|
99
|
+
# description is Minitest's id by construction and RSpec's full_description; only emit it when it adds something.
|
|
61
100
|
def example_fields(example)
|
|
62
|
-
|
|
101
|
+
description = example["full_description"]
|
|
102
|
+
fields = {
|
|
63
103
|
id: example["id"],
|
|
64
|
-
description:
|
|
104
|
+
description: description,
|
|
65
105
|
status: example["status"],
|
|
66
106
|
file_path: example["file_path"],
|
|
67
107
|
line_number: example["line_number"]
|
|
68
108
|
}
|
|
109
|
+
fields.delete(:description) if description.nil? || description == example["id"]
|
|
110
|
+
fields
|
|
69
111
|
end
|
|
70
112
|
|
|
71
113
|
def read_bounded(io)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module Spec
|
|
5
|
+
# The RSpec half of the framework adapter contract the process
|
|
6
|
+
# strategies run against: CLI args, the JSON formatter wiring, the
|
|
7
|
+
# in-process call a forked child makes, and the spawn command line.
|
|
8
|
+
# Every method here is a verbatim extraction of what Runner/
|
|
9
|
+
# ProcessStrategy/ForkStrategy/SpawnStrategy did inline before
|
|
10
|
+
# TestUnit::Adapter needed the same seams.
|
|
11
|
+
class RSpecAdapter
|
|
12
|
+
def initialize(project_root)
|
|
13
|
+
@project_root = project_root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def framework
|
|
17
|
+
:rspec
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def require_framework!
|
|
21
|
+
require "rspec/core"
|
|
22
|
+
rescue LoadError
|
|
23
|
+
raise Coatepec::Error.new(:unsupported_test_framework, "rspec-rails must be in the application's test group")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_args(selectors, example, seed, fail_fast)
|
|
27
|
+
args = selectors.dup
|
|
28
|
+
args += ["-e", example] if example
|
|
29
|
+
args += ["--seed", seed.to_s] if seed
|
|
30
|
+
args << "--fail-fast" if fail_fast
|
|
31
|
+
|
|
32
|
+
args
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def json_args(json_path)
|
|
36
|
+
["--format", "progress", "--format", "json", "--out", json_path]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Runs inside the forked child. RSpec freezes its own "load started
|
|
40
|
+
# at" timestamp once, at the moment rspec/core.rb is first required --
|
|
41
|
+
# in this architecture, that's when the long-lived warm worker booted,
|
|
42
|
+
# not when THIS run started. Every forked child inherits that frozen
|
|
43
|
+
# timestamp via copy-on-write, so RSpec's own "(files took N seconds
|
|
44
|
+
# to load)" reporting would otherwise measure "time since the worker
|
|
45
|
+
# booted" and grow across every run for as long as the worker stays
|
|
46
|
+
# warm. Reset it fresh before each run.
|
|
47
|
+
def run_in_process(full_args, _json_path)
|
|
48
|
+
RSpec.configuration.start_time = RSpec::Core::Time.now
|
|
49
|
+
|
|
50
|
+
RSpec::Core::Runner.run(full_args, $stderr, $stdout)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def spawn_command(full_args, _json_path)
|
|
54
|
+
[{ "RAILS_ENV" => "test" }, ["bundle", "exec", "rspec", *full_args]]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/coatepec/spec/runner.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Coatepec
|
|
4
4
|
module Spec
|
|
5
|
-
# Validates a `rails_spec_run` request's paths,
|
|
6
|
-
#
|
|
7
|
-
# (fork on Linux, spawn on
|
|
8
|
-
# project opts in via
|
|
5
|
+
# Validates a `rails_spec_run` request's paths, picks the RSpec or
|
|
6
|
+
# Minitest adapter from their shape, builds the CLI args, and delegates
|
|
7
|
+
# to the platform-appropriate process strategy (fork on Linux, spawn on
|
|
8
|
+
# macOS, or a guarded fork on macOS when the project opts in via
|
|
9
|
+
# .coatepec.yml).
|
|
9
10
|
class Runner
|
|
10
11
|
DEFAULT_TIMEOUT = 120
|
|
11
12
|
|
|
@@ -16,16 +17,28 @@ module Coatepec
|
|
|
16
17
|
@rails_runtime = rails_runtime
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def run(paths:, example: nil, seed: nil, fail_fast: false, timeout_seconds: DEFAULT_TIMEOUT
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
def run(paths:, example: nil, seed: nil, fail_fast: false, timeout_seconds: DEFAULT_TIMEOUT,
|
|
21
|
+
include_passing: false, include_stdout: "failures")
|
|
22
|
+
validated = @path_policy.validate!(paths)
|
|
23
|
+
adapter = adapter_for(validated[:framework])
|
|
24
|
+
adapter.require_framework!
|
|
25
|
+
args = adapter.build_args(validated[:selectors], example, seed, fail_fast)
|
|
23
26
|
|
|
24
|
-
strategy_class.new(@project_root, project: @project, rails_runtime: @rails_runtime)
|
|
27
|
+
strategy_class.new(@project_root, adapter: adapter, project: @project, rails_runtime: @rails_runtime)
|
|
28
|
+
.run(args, timeout_seconds, include_passing: include_passing, include_stdout: include_stdout)
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
private
|
|
28
32
|
|
|
33
|
+
# Selector shape decides the framework (see PathPolicy); the adapter
|
|
34
|
+
# decides everything framework-specific after that.
|
|
35
|
+
def adapter_for(framework)
|
|
36
|
+
case framework
|
|
37
|
+
when :minitest then TestUnit::Adapter.new(@project_root)
|
|
38
|
+
else RSpecAdapter.new(@project_root)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def strategy_class
|
|
30
43
|
case RbConfig::CONFIG["host_os"]
|
|
31
44
|
when /linux/ then ForkStrategy
|
|
@@ -42,21 +55,6 @@ module Coatepec
|
|
|
42
55
|
def macos_strategy_class
|
|
43
56
|
@project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
|
|
44
57
|
end
|
|
45
|
-
|
|
46
|
-
def require_rspec!
|
|
47
|
-
require "rspec/core"
|
|
48
|
-
rescue LoadError
|
|
49
|
-
raise Coatepec::Error.new(:unsupported_test_framework, "rspec-rails must be in the application's test group")
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def build_args(selectors, example, seed, fail_fast)
|
|
53
|
-
args = selectors.dup
|
|
54
|
-
args += ["-e", example] if example
|
|
55
|
-
args += ["--seed", seed.to_s] if seed
|
|
56
|
-
args << "--fail-fast" if fail_fast
|
|
57
|
-
|
|
58
|
-
args
|
|
59
|
-
end
|
|
60
58
|
end
|
|
61
59
|
end
|
|
62
60
|
end
|
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Coatepec
|
|
4
4
|
module Spec
|
|
5
|
-
# Runs
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Runs the test framework in a freshly `Process.spawn`ed process --
|
|
6
|
+
# `bundle exec rspec`, or the Minitest child entry -- (macOS and any
|
|
7
|
+
# platform without a working fork): slower per run since Rails boots
|
|
8
|
+
# from scratch, but avoids fork-safety pitfalls.
|
|
8
9
|
class SpawnStrategy < ProcessStrategy
|
|
9
10
|
private
|
|
10
11
|
|
|
11
|
-
def start(full_args, out_w, err_w)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
chdir: @project_root, out: out_w, err: err_w, pgroup: true
|
|
15
|
-
)
|
|
12
|
+
def start(full_args, out_w, err_w, json_path)
|
|
13
|
+
env, argv = @adapter.spawn_command(full_args, json_path)
|
|
14
|
+
Process.spawn(env, *argv, chdir: @project_root, out: out_w, err: err_w, pgroup: true)
|
|
16
15
|
end
|
|
17
16
|
end
|
|
18
17
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coatepec
|
|
4
|
+
module TestUnit
|
|
5
|
+
# The Minitest half of the framework adapter contract (see
|
|
6
|
+
# Spec::RSpecAdapter for the RSpec half and the method-by-method
|
|
7
|
+
# contract). Everything Rails-/Minitest-specific about running a
|
|
8
|
+
# `test/` selection lives here; the process strategies stay
|
|
9
|
+
# framework-agnostic.
|
|
10
|
+
#
|
|
11
|
+
# The parent (warm worker) only ever calls require_framework!,
|
|
12
|
+
# build_args, json_args and spawn_command. run_in_process runs in the
|
|
13
|
+
# child -- a fork of the worker on Linux, or the fresh process
|
|
14
|
+
# child_entry.rb starts on macOS -- and is the single place Minitest is
|
|
15
|
+
# configured, so fork and spawn behave identically.
|
|
16
|
+
class Adapter
|
|
17
|
+
CHILD_ENTRY = File.expand_path("child_entry.rb", __dir__)
|
|
18
|
+
JSON_PATH_ENV = "COATEPEC_MINITEST_JSON"
|
|
19
|
+
|
|
20
|
+
def initialize(project_root)
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def framework
|
|
25
|
+
:minitest
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Only `minitest` itself here, never rails/test_help: that file runs
|
|
29
|
+
# ActiveRecord::Migration.maintain_test_schema! and installs fixture
|
|
30
|
+
# hooks at require time, which belongs in the child (mirroring how the
|
|
31
|
+
# RSpec path requires rspec/core in the parent and lets rails_helper
|
|
32
|
+
# load rspec/rails in the child). Practically unreachable -- activesupport
|
|
33
|
+
# depends on minitest -- but kept as the symmetric guard.
|
|
34
|
+
def require_framework!
|
|
35
|
+
require "minitest"
|
|
36
|
+
rescue LoadError
|
|
37
|
+
raise Coatepec::Error.new(:unsupported_test_framework, "minitest must be in the application's bundle")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Selectors first, then flags: run_in_process relies on that order to
|
|
41
|
+
# hand Rails::TestUnit::Runner.load_tests the selectors alone.
|
|
42
|
+
def build_args(selectors, example, seed, fail_fast)
|
|
43
|
+
args = selectors.dup
|
|
44
|
+
args += [name_filter_flag, "/#{Regexp.escape(example)}/"] if example
|
|
45
|
+
args += ["--seed", seed.to_s] if seed
|
|
46
|
+
args << "--fail-fast" if fail_fast
|
|
47
|
+
|
|
48
|
+
args
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Minitest's option parser raises on unknown flags, so the JSON path
|
|
52
|
+
# reaches the reporter in-process (fork) or via the environment (spawn).
|
|
53
|
+
def json_args(_json_path)
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def spawn_command(full_args, json_path)
|
|
58
|
+
[{ "RAILS_ENV" => "test", JSON_PATH_ENV => json_path }, ["bundle", "exec", "ruby", CHILD_ENTRY, *full_args]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs in the child. Rails is already booted (config/environment) by
|
|
62
|
+
# the caller. Returns the process exit status.
|
|
63
|
+
def run_in_process(full_args, json_path)
|
|
64
|
+
prepare_child_environment
|
|
65
|
+
configure_minitest!(json_path)
|
|
66
|
+
|
|
67
|
+
::Rails::TestUnit::Runner.load_tests(selectors_from(full_args))
|
|
68
|
+
::Minitest.run(full_args) ? 0 : 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Minitest 6 renamed -n/--name (options[:filter]) to -i/--include
|
|
74
|
+
# (options[:include]); Rails' own plugin shims -n on 6 with a warning.
|
|
75
|
+
def name_filter_flag
|
|
76
|
+
::Minitest::VERSION.start_with?("6") ? "-i" : "-n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Every validated selector starts with its root directory name, never
|
|
80
|
+
# with "-", so the flags boundary is exact.
|
|
81
|
+
def selectors_from(full_args)
|
|
82
|
+
full_args.take_while { |arg| !arg.start_with?("-") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# PARALLEL_WORKERS must be set before any test file loads: a generated
|
|
86
|
+
# test_helper.rb calls `parallelize(workers: :number_of_processors)`
|
|
87
|
+
# at class-body time and reads the variable right then. Without it, a
|
|
88
|
+
# selection above the parallelization threshold (50 by default) forks
|
|
89
|
+
# a worker tree, each wanting its own database, under this run's one
|
|
90
|
+
# timeout budget. The load-path entry is what `rails test` itself adds
|
|
91
|
+
# (Rails::Command::TestCommand#perform) so `require "test_helper"`
|
|
92
|
+
# resolves.
|
|
93
|
+
def prepare_child_environment
|
|
94
|
+
ENV["PARALLEL_WORKERS"] = "1"
|
|
95
|
+
test_dir = File.join(@project_root, "test")
|
|
96
|
+
$LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# rails/test_help pulls in active_support/testing/autorun, which on
|
|
100
|
+
# Minitest 6 already does `Minitest.load :rails`; the guarded call here
|
|
101
|
+
# covers a test_help that does not, without registering Rails' plugin
|
|
102
|
+
# twice (init_plugins would then run plugin_rails_init twice). Minitest
|
|
103
|
+
# 5's #run globs installed gems' plugins itself, so nothing is needed.
|
|
104
|
+
def configure_minitest!(json_path)
|
|
105
|
+
require "rails/test_help"
|
|
106
|
+
::Minitest.load(:rails) if ::Minitest.respond_to?(:load) && !::Minitest.extensions.include?("rails")
|
|
107
|
+
require_relative "line_filtering"
|
|
108
|
+
LineFiltering.install!
|
|
109
|
+
register_reporter(json_path)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Minitest's plugin hook: Minitest.run builds its reporter, then calls
|
|
113
|
+
# plugin_<name>_init for every registered extension with the reporter
|
|
114
|
+
# exposed as Minitest.reporter. Rails' own init only swaps the
|
|
115
|
+
# Summary/Progress reporters, so ours survives it.
|
|
116
|
+
#
|
|
117
|
+
# Ours goes to the *front* of the composite, not the end: Rails'
|
|
118
|
+
# TestUnitReporter implements --fail-fast by raising Interrupt from
|
|
119
|
+
# inside #record, and CompositeReporter delivers #record in list order,
|
|
120
|
+
# so a reporter behind it never sees the very failure that aborted the
|
|
121
|
+
# run -- the one result a fail-fast caller most wants reported.
|
|
122
|
+
def register_reporter(json_path)
|
|
123
|
+
require_relative "json_reporter"
|
|
124
|
+
reporter = JsonReporter.new(json_path, @project_root)
|
|
125
|
+
::Minitest.extensions << "coatepec" unless ::Minitest.extensions.include?("coatepec")
|
|
126
|
+
::Minitest.singleton_class.define_method(:plugin_coatepec_init) do |_options|
|
|
127
|
+
::Minitest.reporter.reporters.unshift(reporter)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point for Coatepec::Spec::SpawnStrategy when the selection is
|
|
4
|
+
# Minitest: `bundle exec ruby <this file> <selectors and flags>`, run from
|
|
5
|
+
# the target app's root with COATEPEC_MINITEST_JSON pointing at the file
|
|
6
|
+
# the JSON reporter must write. Boots the app exactly as the warm worker
|
|
7
|
+
# would, then hands off to the same TestUnit::Adapter#run_in_process a
|
|
8
|
+
# forked child uses, so fork and spawn configure Minitest identically.
|
|
9
|
+
#
|
|
10
|
+
# `bundle exec` already prepends -rbundler/setup via RUBYOPT; the explicit
|
|
11
|
+
# require keeps this script correct if it is ever run without it, and
|
|
12
|
+
# mirrors exe/coatepec-worker's ordering rationale (default gems must be
|
|
13
|
+
# pinned by Bundler before anything else can auto-activate them).
|
|
14
|
+
require "bundler/setup"
|
|
15
|
+
|
|
16
|
+
$LOAD_PATH.unshift(File.expand_path("../..", __dir__))
|
|
17
|
+
require "coatepec/errors"
|
|
18
|
+
require "coatepec/test_unit/adapter"
|
|
19
|
+
|
|
20
|
+
project_root = Dir.pwd
|
|
21
|
+
require File.join(project_root, "config/environment")
|
|
22
|
+
|
|
23
|
+
json_path = ENV.fetch(Coatepec::TestUnit::Adapter::JSON_PATH_ENV)
|
|
24
|
+
status = Coatepec::TestUnit::Adapter.new(project_root).run_in_process(ARGV, json_path)
|
|
25
|
+
$stdout.flush
|
|
26
|
+
$stderr.flush
|
|
27
|
+
# rails/test_help requires active_support/testing/autorun, which arms
|
|
28
|
+
# Minitest.autorun's at_exit hook. A plain `exit` would therefore run the
|
|
29
|
+
# whole suite a second time -- from the now option-stripped ARGV, so without
|
|
30
|
+
# the seed/filter/fail-fast -- and overwrite the JSON report with that second
|
|
31
|
+
# run's results. exit! skips at_exit handlers, exactly as ForkStrategy's
|
|
32
|
+
# child does.
|
|
33
|
+
Kernel.exit!(status)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "minitest"
|
|
5
|
+
|
|
6
|
+
module Coatepec
|
|
7
|
+
module TestUnit
|
|
8
|
+
# A Minitest reporter that writes the run's per-test results to a JSON
|
|
9
|
+
# file in a superset of the shape RSpec's own `--format json` produces, as far
|
|
10
|
+
# as Coatepec::Spec::Result reads it -- so Result and FlakyChecker need
|
|
11
|
+
# no Minitest-specific code at all. Registered in the child by
|
|
12
|
+
# TestUnit::Adapter#run_in_process; never loaded in the warm worker.
|
|
13
|
+
class JsonReporter < ::Minitest::AbstractReporter
|
|
14
|
+
def initialize(json_path, project_root)
|
|
15
|
+
super()
|
|
16
|
+
@json_path = json_path
|
|
17
|
+
@project_root = File.join(File.expand_path(project_root), "")
|
|
18
|
+
@examples = []
|
|
19
|
+
@assertions = 0
|
|
20
|
+
@errors = 0
|
|
21
|
+
@started_at = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A run begins: forget anything recorded before, so a reporter that
|
|
25
|
+
# outlives one Minitest.run never carries results into the next.
|
|
26
|
+
def start
|
|
27
|
+
@examples = []
|
|
28
|
+
@assertions = 0
|
|
29
|
+
@errors = 0
|
|
30
|
+
@started_at = ::Minitest.clock_time
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Minitest counts an assertion as it starts, so a failed assert still counts one; an error is a raise.
|
|
34
|
+
def record(result)
|
|
35
|
+
@examples << example_for(result)
|
|
36
|
+
@assertions += result.assertions
|
|
37
|
+
@errors += 1 if result.error?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Written to a sibling and renamed so a child killed mid-write leaves
|
|
41
|
+
# either a complete document or nothing; Result.read_summary already
|
|
42
|
+
# treats a missing/empty file as "no summary".
|
|
43
|
+
def report
|
|
44
|
+
tmp_path = "#{@json_path}.tmp"
|
|
45
|
+
File.write(tmp_path, JSON.generate(document))
|
|
46
|
+
File.rename(tmp_path, @json_path)
|
|
47
|
+
ensure
|
|
48
|
+
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# This reporter observes; it must never flip the run's exit status.
|
|
52
|
+
def passed?
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def document
|
|
59
|
+
{ summary: summary_counts, examples: @examples }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# assertion_count and error_count are running totals; the rest are derived from the recorded examples.
|
|
63
|
+
def summary_counts
|
|
64
|
+
{
|
|
65
|
+
example_count: @examples.size,
|
|
66
|
+
failure_count: @examples.count { |e| e[:status] == "failed" },
|
|
67
|
+
error_count: @errors,
|
|
68
|
+
pending_count: @examples.count { |e| e[:status] == "pending" },
|
|
69
|
+
assertion_count: @assertions,
|
|
70
|
+
duration: @started_at ? ::Minitest.clock_time - @started_at : 0.0
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def example_for(result)
|
|
75
|
+
id = "#{result.klass}##{result.name}"
|
|
76
|
+
file, line = result.source_location
|
|
77
|
+
{
|
|
78
|
+
id: id,
|
|
79
|
+
full_description: id,
|
|
80
|
+
status: status_for(result),
|
|
81
|
+
file_path: relative_path(file),
|
|
82
|
+
line_number: line
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RSpec's vocabulary: a skip is "pending" (so FlakyChecker's passed/failed
|
|
87
|
+
# counts stay meaningful), an error is "failed" -- summary.error_count keeps the distinction.
|
|
88
|
+
def status_for(result)
|
|
89
|
+
return "pending" if result.skipped?
|
|
90
|
+
return "passed" if result.passed?
|
|
91
|
+
|
|
92
|
+
"failed"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Never emit the absolute path: same leak rails_controller avoids with
|
|
96
|
+
# Proc#to_s. Matches RSpec's "./spec/..." form for the same file.
|
|
97
|
+
def relative_path(file)
|
|
98
|
+
return nil if file.nil? || file == "unknown"
|
|
99
|
+
|
|
100
|
+
expanded = File.expand_path(file)
|
|
101
|
+
return "./#{expanded.delete_prefix(@project_root)}" if expanded.start_with?(@project_root)
|
|
102
|
+
|
|
103
|
+
File.basename(expanded)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|