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.
@@ -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,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; RSpec's own output
32
- # must be wired to out_w/err_w.
33
- def start(_full_args, _out_w, _err_w)
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 + json_format_args(json_path), out_w, err_w)
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
- 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)
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
@@ -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, 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
- 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
+ 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 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 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