rspec-tracer 1.2.3 → 2.0.0.pre.2

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. data/lib/rspec_tracer/runner.rb +0 -278
data/lib/rspec_tracer.rb CHANGED
@@ -10,84 +10,109 @@ require 'json'
10
10
  require 'pathname'
11
11
  require 'set'
12
12
 
13
- require_relative 'rspec_tracer/coverage_merger'
14
- require_relative 'rspec_tracer/coverage_reporter'
15
- require_relative 'rspec_tracer/coverage_writer'
16
13
  require_relative 'rspec_tracer/defaults'
14
+ require_relative 'rspec_tracer/engine'
17
15
  require_relative 'rspec_tracer/example'
18
- require_relative 'rspec_tracer/html_reporter/reporter'
16
+ # Reporters must load before load_config so the Configuration DSL's
17
+ # `add_reporter` can validate symbol names against
18
+ # `Reporters::Registry::BUILT_INS` when a user `.rspec-tracer` calls
19
+ # it at configure time.
20
+ require_relative 'rspec_tracer/reporters/base'
21
+ require_relative 'rspec_tracer/reporters/payload_builder'
22
+ require_relative 'rspec_tracer/reporters/coverage_json_reporter'
23
+ require_relative 'rspec_tracer/reporters/json_reporter'
24
+ require_relative 'rspec_tracer/reporters/terminal_reporter'
25
+ require_relative 'rspec_tracer/reporters/html_reporter'
26
+ require_relative 'rspec_tracer/reporters/registry'
19
27
  require_relative 'rspec_tracer/load_config'
20
- require_relative 'rspec_tracer/remote_cache/cache'
21
- require_relative 'rspec_tracer/report_generator'
22
- require_relative 'rspec_tracer/report_merger'
23
- require_relative 'rspec_tracer/report_writer'
24
- require_relative 'rspec_tracer/rspec_reporter'
25
- require_relative 'rspec_tracer/rspec_runner'
26
- require_relative 'rspec_tracer/ruby_coverage'
27
- require_relative 'rspec_tracer/runner'
28
+ # RemoteCache is loaded lazily from its Rakefile shim (user-driven),
29
+ # not at gem-load time. The user-facing tasks `rspec_tracer:remote_cache:*`
30
+ # pull in `lib/rspec_tracer/remote_cache.rb` when the user's Rakefile
31
+ # loads the shim. Test-suite runs that never invoke a cache task pay
32
+ # zero load cost for aws/git subshell code.
33
+ require_relative 'rspec_tracer/rspec/installation'
34
+ require_relative 'rspec_tracer/rspec/parallel_tests'
28
35
  require_relative 'rspec_tracer/source_file'
29
36
  require_relative 'rspec_tracer/time_formatter'
30
37
  require_relative 'rspec_tracer/version'
31
38
 
39
+ # Top-level entry point. Drives the lifecycle:
40
+ #
41
+ # RSpecTracer.start
42
+ # -> RSpec::Installation.install! (prepend RunnerHook + ReporterHook)
43
+ # -> setup_coverage (::Coverage.start unless SimpleCov owns it)
44
+ # -> setup_rails (detect ::Rails::VERSION)
45
+ # -> Engine.new.setup (observers + cache load + filter decisions)
46
+ #
47
+ # at_exit_behavior (installed via `at_exit` elsewhere in the boot
48
+ # flow) runs the finalize stack: Engine#finalize writes the 13-file
49
+ # snapshot via Storage::JsonBackend, Reporters::CoverageJsonReporter
50
+ # writes coverage.json (single owner, replacing the 1.x
51
+ # CoverageReporter + CoverageWriter pair retired in 2.0),
52
+ # ParallelTests#finalize! merges per-worker caches on the last worker.
32
53
  module RSpecTracer
33
- # Filesystem barrier markers, layered on top of parallel_tests's
34
- # pid-file wait to defend against the GHA-observed race where the
35
- # gem's `wait_for_other_processes_to_finish` returns while a sibling
36
- # worker hasn't fully flushed its `parallel_tests_N/` dir yet. Each
37
- # worker writes BOOT at setup-time and DONE as the first step of its
38
- # at_exit tasks; the elected worker waits for every booted peer's
39
- # DONE marker (deadline-bounded) before proceeding to merge + purge.
40
- PARALLEL_TESTS_BOOT_MARKER_FILENAME = '.rspec_tracer_boot'
41
- PARALLEL_TESTS_DONE_MARKER_FILENAME = '.rspec_tracer_done'
42
- PARALLEL_TESTS_PEER_DONE_DEADLINE_SECONDS = 5
43
-
44
54
  class << self
55
+ # Internal attribute.
56
+ # @api private
45
57
  attr_accessor :running, :pid, :no_examples, :duplicate_examples
46
58
 
59
+ # Boot the tracer. Idempotent — safe to call multiple times in a
60
+ # single process (subsequent calls return without re-installing
61
+ # hooks). Drives the lifecycle:
62
+ #
63
+ # * Installs the RSpec runner / reporter prepend chain.
64
+ # * Starts `::Coverage` unless SimpleCov already owns it.
65
+ # * Detects Rails (memoized in `RSpecTracer.rails?`).
66
+ # * Builds the {RSpecTracer::Engine} and installs observers.
67
+ #
68
+ # Must be called BEFORE any application code loads so the boot
69
+ # set captured by `Coverage.peek_result` is empty. With SimpleCov,
70
+ # call `SimpleCov.start` first; rspec-tracer warns at boot when
71
+ # SimpleCov is loaded but not started.
72
+ #
73
+ # @return [void]
47
74
  def start
75
+ return if defined?(@started) && @started
76
+
48
77
  RSpecTracer.running = false
49
78
  RSpecTracer.pid = Process.pid
50
-
51
- return if RUBY_ENGINE == 'jruby' && !valid_jruby_opts?
79
+ @run_started_at = ::Time.now.utc
80
+ @run_monotonic_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
81
+ @started = true
52
82
 
53
83
  RSpecTracer.logger.debug "Started RSpec tracer (pid: #{RSpecTracer.pid})"
54
84
 
55
- parallel_tests_setup
85
+ warn_on_simplecov_load_order_mistake
86
+
87
+ @parallel_tests = RSpecTracer::RSpec::ParallelTests.active?
88
+ RSpecTracer::RSpec::ParallelTests.setup! if parallel_tests?
56
89
  initial_setup
57
90
  end
58
91
 
59
- # rubocop:disable Metrics/AbcSize
60
- def filter_examples
61
- groups = Set.new
62
- to_run = Hash.new { |hash, group| hash[group] = [] }
63
-
64
- RSpec.world.filtered_examples.each_pair do |example_group, examples|
65
- examples.each do |example|
66
- tracer_example = RSpecTracer::Example.from(example)
67
- example_id = tracer_example[:example_id]
68
- example.metadata[:rspec_tracer_example_id] = example_id
69
-
70
- if runner.run_example?(example_id)
71
- run_reason = runner.run_example_reason(example_id)
72
- tracer_example[:run_reason] = run_reason
73
- example.metadata[:description] = "#{example.description} (#{run_reason})"
74
-
75
- to_run[example_group] << example
76
- groups << example.example_group.parent_groups.last
77
-
78
- runner.register_example(tracer_example)
79
- else
80
- runner.on_example_skipped(example_id)
81
- end
82
- end
83
- end
84
-
85
- runner.deregister_duplicate_examples
92
+ # SimpleCov load-order is part of the documented contract -
93
+ # SimpleCov.start MUST run before RSpecTracer.start when both are
94
+ # used together (see README §SimpleCov interop). When the user
95
+ # has SimpleCov loaded but not started, we'd silently call
96
+ # ::Coverage.start ourselves and SimpleCov's later setup would
97
+ # bolt onto a Coverage already in flight, with the user's
98
+ # add_filter calls applied after rspec-tracer started consuming
99
+ # data. Surface the load-order mistake at start time so the user
100
+ # gets a one-line warning instead of mysteriously-broken
101
+ # coverage output.
102
+ def warn_on_simplecov_load_order_mistake
103
+ return unless defined?(::SimpleCov)
104
+ return if ::SimpleCov.respond_to?(:running) && ::SimpleCov.running
86
105
 
87
- [to_run, groups.to_a]
106
+ RSpecTracer.logger.warn(
107
+ 'SimpleCov is loaded but not started. ' \
108
+ 'Call SimpleCov.start before RSpecTracer.start so the ' \
109
+ 'tracer respects SimpleCov\'s filter chain. See README ' \
110
+ 'section "Working with SimpleCov".'
111
+ )
88
112
  end
89
- # rubocop:enable Metrics/AbcSize
90
113
 
114
+ # Internal method on the tracer pipeline.
115
+ # @api private
91
116
  def at_exit_behavior
92
117
  return unless RSpecTracer.pid == Process.pid && RSpecTracer.running
93
118
 
@@ -95,164 +120,69 @@ module RSpecTracer
95
120
 
96
121
  run_exit_tasks
97
122
  ensure
98
- FileUtils.rm_f(RSpecTracer.lock_file) if parallel_tests_last_process?
123
+ if RSpecTracer::RSpec::ParallelTests.active? &&
124
+ RSpecTracer::RSpec::ParallelTests.last_process?
125
+ RSpecTracer::RSpec::ParallelTests.remove_lock_file!
126
+ end
99
127
 
100
128
  RSpecTracer.running = false
101
129
  end
102
130
 
103
- def start_example_trace
104
- trace_point.enable
105
- end
106
-
107
- def stop_example_trace(example_id)
108
- trace_point.disable
109
-
110
- @examples_traced_files[example_id] = @traced_files
111
- @traced_files = Set.new
112
- end
113
-
114
- def runner
115
- @runner if defined?(@runner)
116
- end
117
-
118
- def coverage_reporter
119
- @coverage_reporter if defined?(@coverage_reporter)
120
- end
121
-
122
- def report_writer
123
- @report_writer if defined?(@report_writer)
124
- end
125
-
126
- def coverage_merger
127
- @coverage_merger if defined?(@coverage_merger)
128
- end
129
-
130
- def report_merger
131
- @report_merger if defined?(@report_merger)
132
- end
133
-
134
- def trace_point
135
- @trace_point if defined?(@trace_point)
136
- end
137
-
138
- def traced_files
139
- @traced_files if defined?(@traced_files)
140
- end
141
-
142
- def examples_traced_files
143
- @examples_traced_files if defined?(@examples_traced_files)
131
+ # The current {RSpecTracer::Engine} instance, or nil if
132
+ # {.start} hasn't been called yet.
133
+ #
134
+ # @return [RSpecTracer::Engine, nil]
135
+ def engine
136
+ @engine if defined?(@engine)
144
137
  end
145
138
 
139
+ # True if SimpleCov was loaded AND running at the time
140
+ # {.start} was invoked. Determines whether rspec-tracer
141
+ # owns `::Coverage.start` itself (false) or defers to
142
+ # SimpleCov's coverage lifecycle (true).
143
+ #
144
+ # @return [Boolean]
146
145
  def simplecov?
147
146
  defined?(@simplecov) && @simplecov == true
148
147
  end
149
148
 
149
+ # True if `parallel_tests` is in use (detected via
150
+ # `ParallelTests.active?` at {.start} time). Affects
151
+ # cache + report directory scoping (per-worker dirs)
152
+ # and finalize-time merging.
153
+ #
154
+ # @return [Boolean]
150
155
  def parallel_tests?
151
156
  defined?(@parallel_tests) && @parallel_tests == true
152
157
  end
153
158
 
154
- private
155
-
156
- def valid_jruby_opts?
157
- require 'jruby'
158
-
159
- return true if Java::OrgJruby::RubyInstanceConfig.FULL_TRACE_ENABLED &&
160
- JRuby.runtime.object_space_enabled?
161
-
162
- RSpecTracer.logger.warn <<-WARN.strip.gsub(/\s+/, ' ')
163
- RSpec Tracer is not running as it requires debug and object space enabled. Use
164
- command line options "--debug" and "-X+O" or set the "debug.fullTrace=true" and
165
- "objectspace.enabled=true" options in your .jrubyrc file. You can also use
166
- JRUBY_OPTS="--debug -X+O".
167
- WARN
168
-
169
- false
159
+ # True if Rails is loaded in this process (detected via
160
+ # `defined?(::Rails::VERSION)` at {.start} time). Memoized;
161
+ # subsequent Rails activations within the same run are not
162
+ # re-detected. Drives the auto-installation of Rails-side
163
+ # observers (template + AR notification subscribers).
164
+ #
165
+ # @return [Boolean]
166
+ def rails?
167
+ defined?(@rails) && @rails == true
170
168
  end
171
169
 
172
- def initial_setup
173
- unless setup_rspec?
174
- RSpecTracer.logger.error 'Could not find a running RSpec process'
170
+ private
175
171
 
176
- return
177
- end
172
+ # Internal method on the tracer pipeline.
173
+ # @api private
174
+ def initial_setup
175
+ RSpecTracer::RSpec::Installation.install!
178
176
 
179
177
  setup_coverage
180
- setup_trace_point
181
-
182
- @runner = RSpecTracer::Runner.new
183
- @coverage_reporter = RSpecTracer::CoverageReporter.new
184
- @report_writer = RSpecTracer::ReportWriter.new(RSpecTracer.cache_path, @runner.reporter)
185
- end
186
-
187
- def parallel_tests_setup
188
- @parallel_tests = !(ENV.fetch('TEST_ENV_NUMBER', nil) && ENV.fetch('PARALLEL_TEST_GROUPS', nil)).nil?
189
-
190
- return unless parallel_tests?
191
-
192
- require 'parallel_tests' unless defined?(ParallelTests)
193
-
194
- @coverage_merger = RSpecTracer::CoverageMerger.new
195
- @report_merger = RSpecTracer::ReportMerger.new
196
- rescue LoadError => e
197
- RSpecTracer.logger.error "Failed to load parallel tests (Error: #{e.message})"
198
- ensure
199
- track_parallel_tests_test_env_number
200
- parallel_tests_touch_boot!
201
- end
202
-
203
- # Per-worker boot marker. Source-of-truth for "this worker booted
204
- # past `RSpecTracer.start`", consumed by the elected worker's
205
- # finalize-time peer enumeration. Idempotent; failures are warned
206
- # and absorbed (boot-marker write must never block test execution).
207
- def parallel_tests_touch_boot!
208
- return unless parallel_tests?
209
-
210
- FileUtils.mkdir_p(RSpecTracer.cache_path)
211
- File.write(
212
- File.join(RSpecTracer.cache_path, PARALLEL_TESTS_BOOT_MARKER_FILENAME),
213
- JSON.generate(
214
- pid: Process.pid,
215
- test_env_number: ENV.fetch('TEST_ENV_NUMBER', ''),
216
- started_at: Time.now.utc.iso8601
217
- )
218
- )
219
- rescue StandardError => e
220
- RSpecTracer.logger.warn(
221
- "RSpec tracer: failed to write boot marker (#{e.class}: #{e.message})"
222
- )
223
- end
224
-
225
- def track_parallel_tests_test_env_number
226
- return unless parallel_tests?
227
-
228
- File.open(RSpecTracer.lock_file, File::RDWR | File::CREAT, 0o644) do |f|
229
- f.flock(File::LOCK_EX)
178
+ setup_rails
230
179
 
231
- test_num = [f.read.to_i, ENV['TEST_ENV_NUMBER'].to_i].max
232
-
233
- f.rewind
234
- f.write("#{test_num}\n")
235
- f.flush
236
- f.truncate(f.pos)
237
- end
238
- end
239
-
240
- def setup_rspec?
241
- runners = ObjectSpace.each_object(::RSpec::Core::Runner) do |runner|
242
- runner_clazz = runner.singleton_class
243
- clazz = RSpecTracer::RSpecRunner
244
-
245
- runner_clazz.prepend(clazz) unless runner_clazz.ancestors.include?(clazz)
246
-
247
- reporter_clazz = runner.configuration.reporter.singleton_class
248
- clazz = RSpecTracer::RSpecReporter
249
-
250
- reporter_clazz.prepend(clazz) unless reporter_clazz.ancestors.include?(clazz)
251
- end
252
-
253
- runners.positive?
180
+ @engine = RSpecTracer::Engine.new(configuration: RSpecTracer)
181
+ @engine.setup
254
182
  end
255
183
 
184
+ # Internal method on the tracer pipeline.
185
+ # @api private
256
186
  def setup_coverage
257
187
  @simplecov = defined?(SimpleCov) && SimpleCov.running
258
188
 
@@ -260,331 +190,141 @@ module RSpecTracer
260
190
 
261
191
  require 'coverage'
262
192
 
263
- ::Coverage.start
264
- end
193
+ return if ::Coverage.respond_to?(:running?) && ::Coverage.running?
265
194
 
266
- def setup_trace_point
267
- @traced_files = Set.new
268
- @examples_traced_files = {}
195
+ ::Coverage.start(**coverage_modes_for_start)
196
+ rescue RuntimeError
197
+ # ::Coverage.start raises if already started on some Rubies
198
+ # without a running? predicate; safe to ignore (matches
199
+ # Engine#ensure_coverage_started behavior).
200
+ nil
201
+ end
269
202
 
270
- @trace_point = TracePoint.new(:call) do |tp|
271
- RSpecTracer.traced_files << tp.path if tp.path.start_with?(RSpecTracer.root)
272
- end
203
+ # Detects Rails by the presence of `::Rails::VERSION`. Users who
204
+ # require `rspec_tracer/rails` transitively load the Railtie (when
205
+ # Rails is also present); this method only sets the flag consumed
206
+ # by `RSpecTracer.rails?`. Safe when Rails is absent - the
207
+ # `defined?` guard returns nil, flag stays false.
208
+ def setup_rails
209
+ @rails = defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
273
210
  end
274
211
 
212
+ # Internal method on the tracer pipeline.
213
+ # @api private
275
214
  def run_exit_tasks
276
215
  if RSpecTracer.no_examples
277
216
  RSpecTracer.logger.info 'Skipped reports generation since all examples were filtered out'
278
217
  else
279
- generate_reports
218
+ snapshot = run_finalize
219
+ # Under parallel_tests, defer reporter emission until
220
+ # last-process finalize merges per-worker snapshots into the
221
+ # top-level cache. Each worker still persists its per-worker
222
+ # snapshot for the merge to consume. Earlier behavior had every
223
+ # worker emit reporters into rspec_tracer_report/parallel_tests_N/
224
+ # and purge_worker_dirs! removed those dirs - leaving the user
225
+ # with no usable terminal/JSON/HTML output. Now reporters fire
226
+ # ONCE at the merged top-level location.
227
+ emit_reporters(snapshot) if snapshot && !parallel_tests?
280
228
  end
281
229
 
282
- simplecov? ? run_simplecov_exit_task : run_coverage_exit_task
283
-
284
- run_parallel_tests_exit_tasks
285
- end
286
-
287
- def generate_reports
288
- RSpecTracer.logger.debug "RSpec tracer is generating reports (pid: #{RSpecTracer.pid})"
289
-
290
- process_dependency
291
- process_coverage
230
+ emit_coverage_json
292
231
 
293
- RSpecTracer::ReportGenerator.new(runner.reporter, runner.cache).generate_report
294
- report_writer.write_report
295
- RSpecTracer::HTMLReporter::Reporter.new(RSpecTracer.report_path, runner.reporter).generate_report
232
+ RSpecTracer::RSpec::ParallelTests.finalize! if parallel_tests?
296
233
  end
297
234
 
298
- def process_dependency
235
+ # Engine-owned finalize path. Writes the 15-file JSON cache via
236
+ # Storage::JsonBackend. Per-example coverage deltas live on the
237
+ # Engine; 2.0 retired the CoverageReporter mid-flow piece (the
238
+ # legacy `coverage_reporter.generate_final_examples_coverage +
239
+ # merge_coverage(engine.merge_skipped_coverage(...))` is now folded
240
+ # into Reporters::CoverageJsonReporter#generate, which fires from
241
+ # `emit_coverage_json` after this method returns).
242
+ def run_finalize
299
243
  starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
300
244
 
301
- runner.register_interrupted_examples
302
- runner.register_deleted_examples
303
- runner.register_dependency(coverage_reporter.examples_coverage)
304
- runner.register_traced_dependency(@examples_traced_files)
245
+ snapshot = engine.finalize
305
246
 
306
247
  ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
307
248
  elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
308
249
 
309
- RSpecTracer.logger.debug "RSpec tracer processed dependency (took #{elapsed})"
310
- end
311
-
312
- def process_coverage
313
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
314
-
315
- coverage_reporter.generate_final_examples_coverage
316
- coverage_reporter.merge_coverage(runner.generate_missed_coverage)
317
- runner.register_examples_coverage(coverage_reporter.examples_coverage)
318
-
319
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
320
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
321
-
322
- RSpecTracer.logger.debug "RSpec tracer processed coverage (took #{elapsed})"
323
- end
324
-
325
- def run_simplecov_exit_task
326
- coverage_clazz = ::Coverage.singleton_class
327
- clazz = RSpecTracer::RubyCoverage
328
- coverage_clazz.prepend(clazz) unless coverage_clazz.ancestors.include?(clazz)
329
-
330
- RSpecTracer.logger.debug 'SimpleCov will now generate coverage report (<3 RSpec tracer)'
331
-
332
- coverage_reporter.record_coverage if RSpecTracer.no_examples
333
- end
334
-
335
- def run_coverage_exit_task
336
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
337
-
338
- coverage_reporter.record_coverage if RSpecTracer.no_examples
339
- coverage_reporter.generate_final_coverage
340
-
341
- file_name = File.join(RSpecTracer.coverage_path, 'coverage.json')
342
- coverage_writer = RSpecTracer::CoverageWriter.new(file_name, coverage_reporter)
343
-
344
- coverage_writer.write_report
345
-
346
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
347
-
348
- coverage_writer.print_stats(ending - starting)
349
- end
350
-
351
- def run_parallel_tests_exit_tasks
352
- # Every worker — elected or not — drops its `.done` marker as the
353
- # first thing in finalize so the elected worker's
354
- # `parallel_tests_wait_for_peer_done_markers!` can observe it.
355
- # Non-elected workers stop here; the elected worker proceeds to
356
- # the merge + purge sequence (gated by `parallel_tests_executed?`,
357
- # which now layers the peer-done barrier on top of the existing
358
- # pid-file wait).
359
- parallel_tests_touch_done!
360
-
361
- return unless parallel_tests_executed?
362
-
363
- merge_parallel_tests_reports
364
- write_parallel_tests_merged_report
365
- merge_parallel_tests_coverage_reports
366
- write_parallel_tests_coverage_report
367
- purge_parallel_tests_reports
368
- end
369
-
370
- # Per-worker done marker. Written by every worker (elected or not)
371
- # as the first step of `run_parallel_tests_exit_tasks`. Pairs with
372
- # the boot marker for the elected worker's peer-done barrier:
373
- # presence of `.done` means "this worker has signalled completion
374
- # of its own writes"; absence (with `.boot` present) means "still
375
- # mid-flush or crashed". Idempotent; failures are warned + absorbed.
376
- def parallel_tests_touch_done!
377
- return unless parallel_tests?
378
-
379
- FileUtils.mkdir_p(RSpecTracer.cache_path)
380
- File.write(
381
- File.join(RSpecTracer.cache_path, PARALLEL_TESTS_DONE_MARKER_FILENAME),
382
- Time.now.utc.iso8601
250
+ RSpecTracer.logger.debug "RSpec tracer persisted cache (took #{elapsed})"
251
+ snapshot
252
+ rescue StandardError => e
253
+ # Graceful-degradation contract per ARCHITECTURE.md
254
+ # section Cache corruption recovery: never propagate storage errors
255
+ # into the user's test suite. Read-only cache_path, disk-full
256
+ # mid-write, permission flips between runs - log and skip
257
+ # report emission. The caller (run_exit_tasks) checks for nil
258
+ # before calling emit_reporters; coverage / parallel_tests
259
+ # finalize paths run independently downstream.
260
+ RSpecTracer.logger.warn(
261
+ "rspec-tracer: cache persistence failed (#{e.class}: #{e.message}); " \
262
+ 'skipping report generation. Verify cache_path is writable.'
263
+ )
264
+ nil
265
+ end
266
+
267
+ # Fire the configured reporters against the persisted
268
+ # Snapshot. Fires per-worker under parallel_tests (same cadence as
269
+ # coverage.json emission); each worker produces its own report.json
270
+ # under its per-worker report_dir. The Registry rescues every
271
+ # reporter individually, so a buggy reporter warns and continues -
272
+ # never propagates a non-zero exit into the user's test suite
273
+ # (graceful degradation contract, same as Storage backends).
274
+ def emit_reporters(snapshot)
275
+ RSpecTracer::Reporters::Registry.emit_all(
276
+ configuration: RSpecTracer,
277
+ snapshot: snapshot,
278
+ report_dir: RSpecTracer.report_path,
279
+ run_metadata: build_run_metadata
383
280
  )
384
281
  rescue StandardError => e
385
282
  RSpecTracer.logger.warn(
386
- "RSpec tracer: failed to write done marker (#{e.class}: #{e.message})"
283
+ "rspec-tracer: reporter pipeline failed (#{e.class}: #{e.message})"
387
284
  )
388
285
  end
389
286
 
390
- def merge_parallel_tests_reports
391
- return unless parallel_tests_executed?
392
-
393
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
394
- reports_dir = []
395
-
396
- parallel_tests_peer_dirs(File.dirname(RSpecTracer.cache_path)).each do |cache_dir|
397
- run_id = JSON.parse(File.read(File.join(cache_dir, 'last_run.json'), encoding: 'UTF-8'))['run_id']
398
-
399
- reports_dir << File.join(cache_dir, run_id)
400
- end
401
-
402
- report_merger.merge(reports_dir)
403
-
404
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
405
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
406
-
407
- RSpecTracer.logger.debug "RSpec tracer merged parallel tests reports (took #{elapsed})"
408
- end
409
-
410
- def write_parallel_tests_merged_report
411
- return unless parallel_tests_executed?
412
-
413
- report_dir = File.dirname(RSpecTracer.cache_path)
414
-
415
- RSpecTracer::ReportWriter.new(report_dir, report_merger).write_report
416
-
417
- report_dir = File.dirname(RSpecTracer.report_path)
418
-
419
- RSpecTracer::HTMLReporter::Reporter.new(report_dir, report_merger).generate_report
420
- end
421
-
422
- def merge_parallel_tests_coverage_reports
423
- return unless parallel_tests_executed? && !simplecov?
424
-
425
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
426
-
427
- reports_dir = parallel_tests_peer_dirs(File.dirname(RSpecTracer.coverage_path))
428
-
429
- coverage_merger.merge(reports_dir)
430
-
431
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
432
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
433
-
434
- RSpecTracer.logger.debug "RSpec tracer merged parallel tests coverage reports (took #{elapsed})"
435
- end
436
-
437
- def write_parallel_tests_coverage_report
438
- return unless parallel_tests_executed? && !simplecov?
439
-
440
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
441
-
442
- coverage_path = File.dirname(RSpecTracer.coverage_path)
443
- file_name = File.join(coverage_path, 'coverage.json')
444
- coverage_writer = RSpecTracer::CoverageWriter.new(file_name, coverage_merger)
445
-
446
- coverage_writer.write_report
447
-
448
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
449
-
450
- coverage_writer.print_stats(ending - starting)
451
- end
452
-
453
- def purge_parallel_tests_reports
454
- return unless parallel_tests_executed?
455
-
456
- [RSpecTracer.cache_path, RSpecTracer.coverage_path, RSpecTracer.report_path].each do |path|
457
- parallel_tests_peer_dirs(File.dirname(path)).each do |worker_dir|
458
- FileUtils.rm_rf(worker_dir)
459
- end
460
- end
461
- end
462
-
463
- # Returns every `parallel_tests_*` subdirectory directly under
464
- # `base_path`. Used by the parallel_tests merge + purge paths.
465
- #
466
- # Earlier patches iterated `1..ENV['PARALLEL_TEST_GROUPS'].to_i`
467
- # to construct dir names, but parallel_tests's own runner sets
468
- # PARALLEL_TEST_GROUPS to the user-requested process count
469
- # (`Parallel.processor_count` by default), NOT the actual worker
470
- # count. When num_processes < spawned_worker_count, the upper
471
- # bound was too small: peer caches with TEST_ENV_NUMBER above the
472
- # bound were silently dropped from the merge AND left behind by
473
- # the purge. PR #101's commit message documented this gem
474
- # behaviour for `last_process?` detection but did not extend the
475
- # fix to the iteration call-sites; this method closes that gap.
476
- # Globbing the actual filesystem state is robust to the env
477
- # discrepancy regardless of how the gem partitions specs.
478
- def parallel_tests_peer_dirs(base_path)
479
- Dir.glob(File.join(base_path, 'parallel_tests_*')).select do |path|
480
- File.directory?(path)
481
- end
482
- end
483
-
484
- def parallel_tests_executed?
485
- return false unless parallel_tests? && parallel_tests_last_process?
486
-
487
- ParallelTests.wait_for_other_processes_to_finish
488
-
489
- # Belt-and-suspenders barrier: pid-file said everyone's done, but
490
- # the gem's `wait_for_other_processes_to_finish` has been observed
491
- # on GHA Linux x86_64 to return while a sibling's `parallel_tests_N/`
492
- # is still mid-flush. Cross-check via the `.boot`/`.done` filesystem
493
- # markers before declaring the peer set stable. Idempotent: once
494
- # all peers have flushed, subsequent calls just glob, find nothing
495
- # missing, and return.
496
- parallel_tests_wait_for_peer_done_markers!
497
-
498
- true
499
- end
500
-
501
- # Block until every peer that wrote `.boot` has also written `.done`,
502
- # or the deadline elapses. Polled at 50ms — fine enough to close the
503
- # typical "barrier returned a tick early" case within a poll or two,
504
- # coarse enough not to dominate CPU.
505
- #
506
- # On timeout we log a warn and proceed: a peer that never wrote
507
- # `.done` either crashed (then its dir is orphan content; the
508
- # subsequent purge cleans it) or is genuinely hung (the elected
509
- # can't fix that — we choose merge correctness over indefinite wait).
510
- def parallel_tests_wait_for_peer_done_markers!
511
- base_dir = File.dirname(RSpecTracer.cache_path)
512
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PARALLEL_TESTS_PEER_DONE_DEADLINE_SECONDS
513
-
514
- loop do
515
- missing = parallel_tests_peer_dirs_missing_done(base_dir)
516
- return if missing.empty?
517
-
518
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
519
- RSpecTracer.logger.warn(
520
- 'RSpec tracer: peers booted without finishing within ' \
521
- "#{PARALLEL_TESTS_PEER_DONE_DEADLINE_SECONDS}s: #{missing.inspect}; " \
522
- 'proceeding (peer dirs will be purged regardless of completion state)'
523
- )
524
- return
525
- end
526
-
527
- sleep 0.05
528
- end
529
- end
530
-
531
- # Set difference of `.boot`-bearing peer dirs and `.done`-bearing
532
- # peer dirs under `base_dir`. A returned entry means "this peer
533
- # registered but has not signalled completion yet" — either still
534
- # mid-flush or crashed.
535
- def parallel_tests_peer_dirs_missing_done(base_dir)
536
- boot_dirs = parallel_tests_peer_dirs_with_marker(base_dir, PARALLEL_TESTS_BOOT_MARKER_FILENAME)
537
- done_dirs = parallel_tests_peer_dirs_with_marker(base_dir, PARALLEL_TESTS_DONE_MARKER_FILENAME)
538
- boot_dirs - done_dirs
539
- end
540
-
541
- def parallel_tests_peer_dirs_with_marker(base_dir, marker_filename)
542
- Dir.glob(File.join(base_dir, 'parallel_tests_*', marker_filename)).map do |path|
543
- File.dirname(path)
544
- end
545
- end
546
-
547
- # Elects the worker that performs the per-run merge. Delegates to
548
- # `::ParallelTests.first_process?`, which returns true iff
549
- # `TEST_ENV_NUMBER.to_i <= 1` — i.e. for exactly one worker
550
- # (TEST_ENV_NUMBER == '' or '1'), regardless of how many workers
551
- # were actually spawned vs. how many CPUs the runner reports.
552
- #
553
- # Two previously attempted approaches do NOT work here:
554
- #
555
- # 1. The lock-file scheme below (each worker writing its
556
- # TEST_ENV_NUMBER to `rspec_tracer.lock` via
557
- # `track_parallel_tests_test_env_number`; last_process picked
558
- # the max) deadlocked under slow CI: worker 1 could finish
559
- # its examples before worker 2 even loaded spec_helper,
560
- # observe itself as the max, and enter
561
- # `::ParallelTests.wait_for_other_processes_to_finish`
562
- # concurrently with worker 2's own self-election — both
563
- # workers then spun on each other's pid.
564
- #
565
- # 2. `::ParallelTests.last_process?` compares TEST_ENV_NUMBER
566
- # against PARALLEL_TEST_GROUPS, which parallel_rspec sets to
567
- # the CPU-based *intended* process count — NOT the actual
568
- # worker count. When spec files < CPU count (common), no
569
- # TEST_ENV_NUMBER ever matches PARALLEL_TEST_GROUPS and the
570
- # merge is silently skipped.
571
- #
572
- # `first_process?` avoids both: set by the parent at spawn,
573
- # immutable thereafter, and identifies exactly one worker
574
- # regardless of CPU count. The elected worker still calls
575
- # `wait_for_other_processes_to_finish` before merging so peer
576
- # caches are guaranteed on disk.
577
- #
578
- # `track_parallel_tests_test_env_number` and the lock-file
579
- # cleanup in `at_exit_behavior` are retained for backward
580
- # compatibility with users who observe `rspec_tracer.lock` /
581
- # set `RSPEC_TRACER_LOCK_FILE`; the file is still written and
582
- # removed but is no longer consulted.
583
- def parallel_tests_last_process?
584
- return false unless parallel_tests?
585
- return false unless defined?(::ParallelTests)
586
-
587
- ::ParallelTests.first_process?
287
+ # Internal method on the tracer pipeline.
288
+ # @api private
289
+ def build_run_metadata
290
+ {
291
+ pid: RSpecTracer.pid,
292
+ run_time: run_elapsed_seconds,
293
+ started_at: defined?(@run_started_at) ? @run_started_at : nil,
294
+ cache_path: RSpecTracer.cache_path,
295
+ parallel_tests: RSpecTracer.parallel_tests?,
296
+ rails: RSpecTracer.rails?
297
+ }
298
+ end
299
+
300
+ # Internal method on the tracer pipeline.
301
+ # @api private
302
+ def run_elapsed_seconds
303
+ return nil unless defined?(@run_monotonic_start) && @run_monotonic_start
304
+
305
+ (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @run_monotonic_start).round(4)
306
+ end
307
+
308
+ # Dedicated coverage.json firing path, parallel to the
309
+ # report-reporters Registry pipeline. Fires unconditionally - even
310
+ # when no examples ran (matches 1.x where coverage.json gets
311
+ # written with whatever boot-time peek_result returned + filter +
312
+ # stub). The emitter handles SimpleCov interop internally
313
+ # (installs `::Coverage.singleton_class.prepend` shim instead of
314
+ # writing coverage.json when SimpleCov is loaded).
315
+ def emit_coverage_json
316
+ return unless engine
317
+
318
+ RSpecTracer::Reporters::CoverageJsonReporter.new(
319
+ snapshot: nil,
320
+ report_dir: RSpecTracer.report_path,
321
+ run_metadata: build_run_metadata,
322
+ logger: RSpecTracer.logger
323
+ ).generate
324
+ rescue StandardError => e
325
+ RSpecTracer.logger.warn(
326
+ "rspec-tracer: coverage.json emit failed (#{e.class}: #{e.message})"
327
+ )
588
328
  end
589
329
  end
590
330
  end