coatepec 0.7.0 → 0.9.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/CHANGELOG.md +140 -0
  4. data/README.md +153 -50
  5. data/ROADMAP.md +31 -64
  6. data/lib/coatepec/introspection/bounded_options.rb +22 -0
  7. data/lib/coatepec/introspection/controller.rb +19 -28
  8. data/lib/coatepec/introspection/model.rb +33 -43
  9. data/lib/coatepec/introspection/model_resolver.rb +32 -0
  10. data/lib/coatepec/introspection/route_entries.rb +77 -0
  11. data/lib/coatepec/introspection/routes.rb +70 -20
  12. data/lib/coatepec/mcp/defaults.rb +25 -0
  13. data/lib/coatepec/mcp/response.rb +16 -9
  14. data/lib/coatepec/mcp/tools.rb +100 -93
  15. data/lib/coatepec/mcp.rb +3 -2
  16. data/lib/coatepec/project.rb +11 -1
  17. data/lib/coatepec/project_config.rb +40 -1
  18. data/lib/coatepec/spec/failure_collapser.rb +111 -0
  19. data/lib/coatepec/spec/flaky_checker.rb +3 -1
  20. data/lib/coatepec/spec/fork_strategy.rb +5 -12
  21. data/lib/coatepec/spec/guarded_fork_strategy.rb +20 -18
  22. data/lib/coatepec/spec/path_policy.rb +45 -12
  23. data/lib/coatepec/spec/process_strategy.rb +28 -17
  24. data/lib/coatepec/spec/result.rb +61 -19
  25. data/lib/coatepec/spec/rspec_adapter.rb +58 -0
  26. data/lib/coatepec/spec/runner.rb +33 -27
  27. data/lib/coatepec/spec/spawn_strategy.rb +9 -8
  28. data/lib/coatepec/test_unit/adapter.rb +132 -0
  29. data/lib/coatepec/test_unit/child_entry.rb +33 -0
  30. data/lib/coatepec/test_unit/json_reporter.rb +107 -0
  31. data/lib/coatepec/test_unit/line_filtering.rb +46 -0
  32. data/lib/coatepec/version.rb +1 -1
  33. data/lib/coatepec/worker/rails_runtime.rb +10 -1
  34. data/lib/coatepec/worker/server.rb +9 -2
  35. data/lib/coatepec/worker_manager.rb +8 -6
  36. data/lib/coatepec.rb +5 -0
  37. metadata +17 -5
@@ -3,23 +3,32 @@
3
3
  module Coatepec
4
4
  module Spec
5
5
  # Validates rails_spec_run's `paths` selectors against the allowed spec
6
- # roots before any RSpec process is started, rejecting absolute paths,
7
- # `..` traversal, symlink escapes, non-`_spec.rb` files, and oversized
8
- # selector lists.
6
+ # and test roots before any test process is started, rejecting absolute
7
+ # paths, `..` traversal, symlink escapes, files that are neither
8
+ # `_spec.rb` under a spec root nor `_test.rb` under a test root, and
9
+ # oversized selector lists. Also classifies the whole list as RSpec or
10
+ # Minitest from the selectors' shape: a list may not mix the two.
9
11
  class PathPolicy
10
12
  MAX_SELECTORS = 100
11
13
 
14
+ KINDS = {
15
+ rspec: { roots: :spec_root_candidates, suffix: "_spec.rb" },
16
+ minitest: { roots: :test_root_candidates, suffix: "_test.rb" }
17
+ }.freeze
18
+
12
19
  def initialize(project)
13
20
  @project = project
14
21
  end
15
22
 
23
+ # Returns { selectors: [...], framework: :rspec | :minitest }.
16
24
  def validate!(selectors)
17
25
  raise Coatepec::Error.new(:invalid_spec_path, "No spec paths given") if selectors.nil? || selectors.empty?
18
26
  if selectors.size > MAX_SELECTORS
19
27
  raise Coatepec::Error.new(:invalid_spec_path, "At most #{MAX_SELECTORS} spec paths are allowed")
20
28
  end
21
29
 
22
- selectors.map { |selector| validate_one(selector) }
30
+ classified = selectors.map { |selector| validate_one(selector) }
31
+ { selectors: classified.map { |c| c[:selector] }, framework: single_framework!(classified) }
23
32
  end
24
33
 
25
34
  private
@@ -31,9 +40,24 @@ module Coatepec
31
40
 
32
41
  real_path = resolve_real_path!(selector, path_part)
33
42
  reject_escape!(selector, real_path)
34
- reject_wrong_kind!(selector, real_path)
43
+ framework = framework_for!(selector, real_path)
44
+
45
+ { selector: line_part ? "#{path_part}:#{line_part}" : path_part, framework: framework }
46
+ end
47
+
48
+ def single_framework!(classified)
49
+ frameworks = classified.map { |c| c[:framework] }.uniq
50
+ return frameworks.first if frameworks.size == 1
35
51
 
36
- line_part ? "#{path_part}:#{line_part}" : path_part
52
+ raise Coatepec::Error.new(:mixed_test_frameworks, mixed_frameworks_message(classified))
53
+ end
54
+
55
+ # Names the minority selectors: the ones an agent most likely added by
56
+ # mistake to an otherwise single-framework call.
57
+ def mixed_frameworks_message(classified)
58
+ majority = classified.group_by { |c| c[:framework] }.max_by { |_, list| list.size }.first
59
+ odd = classified.reject { |c| c[:framework] == majority }.map { |c| c[:selector] }
60
+ "A single call may not mix RSpec and Minitest selectors; these do not match the others: #{odd.join(", ")}"
37
61
  end
38
62
 
39
63
  def resolve_real_path!(selector, path_part)
@@ -74,15 +98,24 @@ module Coatepec
74
98
  raise Coatepec::Error.new(:invalid_spec_path, "Spec path escapes the project root: #{selector}")
75
99
  end
76
100
 
77
- def reject_wrong_kind!(selector, real_path)
78
- return if under_allowed_root?(real_path) && (File.directory?(real_path) || real_path.end_with?("_spec.rb"))
101
+ # A directory under a root is that root's kind; a file must also carry
102
+ # the kind's suffix. The two root sets never overlap (spec/ vs test/),
103
+ # so the iteration order does not matter.
104
+ def framework_for!(selector, real_path)
105
+ KINDS.each do |framework, kind|
106
+ next unless under_any_root?(real_path, @project.public_send(kind[:roots]))
107
+ return framework if File.directory?(real_path) || real_path.end_with?(kind[:suffix])
108
+ end
79
109
 
80
- raise Coatepec::Error.new(:invalid_spec_path,
81
- "Spec path is not an allowed spec root or _spec.rb file: #{selector}")
110
+ raise Coatepec::Error.new(
111
+ :invalid_spec_path,
112
+ "Spec path is not an allowed spec/test root, a _spec.rb file under spec/, " \
113
+ "or a _test.rb file under test/: #{selector}"
114
+ )
82
115
  end
83
116
 
84
- def under_allowed_root?(real_path)
85
- @project.spec_root_candidates.any? do |candidate|
117
+ def under_any_root?(real_path, roots)
118
+ roots.any? do |candidate|
86
119
  real_candidate = File.realpath(candidate)
87
120
  real_path == real_candidate || real_path.start_with?("#{real_candidate}/")
88
121
  end
@@ -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 RSpec run: spawns
8
- # (via a subclass's #start), reaps with a timeout budget (TERM then KILL
9
- # on overrun), and hands the captured output/JSON to Result. Subclasses
10
- # (ForkStrategy, SpawnStrategy) only implement how the child is started.
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,22 @@ 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 })
32
+ .merge(execution_mode: execution_mode)
27
33
  end
28
34
 
29
35
  private
30
36
 
31
- # Subclasses start a process and return its pid; RSpec's own output
32
- # must be wired to out_w/err_w.
33
- def start(_full_args, _out_w, _err_w)
37
+ # Every result says how it ran, so an absent key can never be mistaken for "the guard fell back".
38
+ def execution_mode
39
+ raise NotImplementedError, "#{self.class} must implement #execution_mode"
40
+ end
41
+
42
+ # Subclasses start a process and return its pid; the test framework's
43
+ # own output must be wired to out_w/err_w. json_path is where the
44
+ # adapter's structured per-example output must land.
45
+ def start(_full_args, _out_w, _err_w, _json_path)
34
46
  raise NotImplementedError, "#{self.class} must implement #start"
35
47
  end
36
48
 
@@ -39,20 +51,19 @@ module Coatepec
39
51
  # released have to be freed here. The original exception still reaches
40
52
  # the caller -- only GuardedForkStrategy intercepts it to fall back.
41
53
  def start_or_release(args, out_w, err_w, json_path, read_ends)
42
- start(args + json_format_args(json_path), out_w, err_w)
54
+ start(args + @adapter.json_args(json_path), out_w, err_w, json_path)
43
55
  rescue StandardError
44
56
  ([out_w, err_w] + read_ends).each { |io| io.close unless io.closed? }
45
57
  File.delete(json_path) if File.exist?(json_path)
46
58
  raise
47
59
  end
48
60
 
49
- def json_format_args(json_path)
50
- ["--format", "progress", "--format", "json", "--out", json_path]
51
- end
52
-
53
- def reap(pid, out_r, err_r, timeout_seconds, json_path)
61
+ # result_options are Result.build's include_* keywords, bundled so the positional list stays short.
62
+ def reap(pid, pipes, timeout_seconds, json_path, result_options)
63
+ out_r, err_r = pipes
54
64
  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)
65
+ result = Result.build(pid: pid, status: status, out_r: out_r, err_r: err_r, json_path: json_path,
66
+ **result_options)
56
67
  [out_r, err_r].each(&:close)
57
68
  result
58
69
  ensure
@@ -4,45 +4,79 @@ require "json"
4
4
 
5
5
  module Coatepec
6
6
  module Spec
7
- # Turns a finished RSpec child process's exit status, captured
8
- # stdout/stderr (each capped at MAX_OUTPUT_BYTES), and RSpec's own JSON
9
- # formatter output into the flat result hash rails_spec_run returns.
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
- stdout_result = read_bounded(out_r)
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&.fetch("examples", []) || []).first(500).map { |e| example_fields(e) }
28
+ examples: selected_examples(summary, include_passing).map { |e| example_fields(e) }
23
29
  )
24
30
  end
25
31
 
26
- # rubocop:disable Metrics/MethodLength -- one flat hash literal mapping
27
- # Process::Status/captured-output fields to the result payload's own
28
- # field names; splitting it would scatter that 1:1 mapping across
29
- # methods for no readability gain.
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: status.exited? && status.exitstatus.zero? ? "passed" : "failed",
63
+ status: passed?(status) ? "passed" : "failed",
33
64
  exit_code: status.exitstatus,
34
- child_pid: pid,
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
- # rubocop:enable Metrics/MethodLength
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: example["full_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
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Coatepec
4
4
  module Spec
5
- # Validates a `rails_spec_run` request's paths, builds the RSpec CLI
6
- # args, and delegates to the platform-appropriate process strategy
7
- # (fork on Linux, spawn on macOS, or a guarded fork on macOS when the
8
- # project opts in via .coatepec.yml).
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, and a
8
+ # guarded fork on macOS unless the project opts out via .coatepec.yml,
9
+ # which selects a fresh spawn per call).
9
10
  class Runner
10
11
  DEFAULT_TIMEOUT = 120
11
12
 
@@ -16,16 +17,37 @@ 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
- require_rspec!
21
- selectors = @path_policy.validate!(paths)
22
- args = build_args(selectors, example, seed, fail_fast)
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).run(args, timeout_seconds)
27
+ result = strategy_class
28
+ .new(@project_root, adapter: adapter, project: @project, rails_runtime: @rails_runtime)
29
+ .run(args, timeout_seconds, include_passing: include_passing, include_stdout: include_stdout)
30
+ @rails_runtime&.record_execution_mode(result[:execution_mode])
31
+ result
32
+ end
33
+
34
+ # Reported by rails_runtime_status so a caller can see which path a run will take.
35
+ def strategy_name
36
+ { ForkStrategy => "fork", GuardedForkStrategy => "guarded_fork", SpawnStrategy => "spawn" }
37
+ .fetch(strategy_class)
25
38
  end
26
39
 
27
40
  private
28
41
 
42
+ # Selector shape decides the framework (see PathPolicy); the adapter
43
+ # decides everything framework-specific after that.
44
+ def adapter_for(framework)
45
+ case framework
46
+ when :minitest then TestUnit::Adapter.new(@project_root)
47
+ else RSpecAdapter.new(@project_root)
48
+ end
49
+ end
50
+
29
51
  def strategy_class
30
52
  case RbConfig::CONFIG["host_os"]
31
53
  when /linux/ then ForkStrategy
@@ -36,27 +58,11 @@ module Coatepec
36
58
  end
37
59
  end
38
60
 
39
- # Reading @project.config here means an invalid .coatepec.yml only
40
- # raises :invalid_config on macOS -- the Linux branch never touches it.
41
- # Accepted asymmetry: the file exists to configure this branch.
61
+ # Forking is the default; only an explicit `macos_fork: false` spawns. Only the macOS
62
+ # branch reads the file here; the MCP layer reads it on every call for `defaults`.
42
63
  def macos_strategy_class
43
64
  @project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
44
65
  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
66
  end
61
67
  end
62
68
  end
@@ -2,17 +2,18 @@
2
2
 
3
3
  module Coatepec
4
4
  module Spec
5
- # Runs RSpec in a freshly `Process.spawn`ed `bundle exec rspec` (macOS
6
- # and any platform without a working fork): slower per run since Rails
7
- # boots from scratch, but avoids fork-safety pitfalls.
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
- Process.spawn(
13
- { "RAILS_ENV" => "test" }, "bundle", "exec", "rspec", *full_args,
14
- chdir: @project_root, out: out_w, err: err_w, pgroup: true
15
- )
12
+ def execution_mode = "spawn"
13
+
14
+ def start(full_args, out_w, err_w, json_path)
15
+ env, argv = @adapter.spawn_command(full_args, json_path)
16
+ Process.spawn(env, *argv, chdir: @project_root, out: out_w, err: err_w, pgroup: true)
16
17
  end
17
18
  end
18
19
  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)