rspec-tracer 1.2.2 → 2.0.0.pre.1

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 +197 -45
  3. data/README.md +439 -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 +98 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +103 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +275 -0
  9. data/lib/rspec_tracer/cli/explain.rb +148 -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 +1100 -3
  13. data/lib/rspec_tracer/engine.rb +1076 -0
  14. data/lib/rspec_tracer/example.rb +21 -6
  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 +397 -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 +178 -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 +68 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +866 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +43 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +90 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +127 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +686 -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 +232 -381
  104. metadata +93 -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,73 +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
54
  class << self
55
+ # Internal attribute.
56
+ # @api private
34
57
  attr_accessor :running, :pid, :no_examples, :duplicate_examples
35
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]
36
74
  def start
75
+ return if defined?(@started) && @started
76
+
37
77
  RSpecTracer.running = false
38
78
  RSpecTracer.pid = Process.pid
39
-
40
- 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
41
82
 
42
83
  RSpecTracer.logger.debug "Started RSpec tracer (pid: #{RSpecTracer.pid})"
43
84
 
44
- parallel_tests_setup
45
- initial_setup
46
- end
47
-
48
- # rubocop:disable Metrics/AbcSize
49
- def filter_examples
50
- groups = Set.new
51
- to_run = Hash.new { |hash, group| hash[group] = [] }
52
-
53
- RSpec.world.filtered_examples.each_pair do |example_group, examples|
54
- examples.each do |example|
55
- tracer_example = RSpecTracer::Example.from(example)
56
- example_id = tracer_example[:example_id]
57
- example.metadata[:rspec_tracer_example_id] = example_id
58
-
59
- if runner.run_example?(example_id)
60
- run_reason = runner.run_example_reason(example_id)
61
- tracer_example[:run_reason] = run_reason
62
- example.metadata[:description] = "#{example.description} (#{run_reason})"
63
-
64
- to_run[example_group] << example
65
- groups << example.example_group.parent_groups.last
66
-
67
- runner.register_example(tracer_example)
68
- else
69
- runner.on_example_skipped(example_id)
70
- end
71
- end
72
- end
73
-
74
- runner.deregister_duplicate_examples
85
+ warn_on_simplecov_load_order_mistake
75
86
 
76
- [to_run, groups.to_a]
87
+ @parallel_tests = RSpecTracer::RSpec::ParallelTests.active?
88
+ RSpecTracer::RSpec::ParallelTests.setup! if parallel_tests?
89
+ initial_setup
77
90
  end
78
- # rubocop:enable Metrics/AbcSize
79
91
 
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
105
+
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
+ )
112
+ end
113
+
114
+ # Internal method on the tracer pipeline.
115
+ # @api private
80
116
  def at_exit_behavior
81
117
  return unless RSpecTracer.pid == Process.pid && RSpecTracer.running
82
118
 
@@ -84,141 +120,69 @@ module RSpecTracer
84
120
 
85
121
  run_exit_tasks
86
122
  ensure
87
- 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
88
127
 
89
128
  RSpecTracer.running = false
90
129
  end
91
130
 
92
- def start_example_trace
93
- trace_point.enable
94
- end
95
-
96
- def stop_example_trace(example_id)
97
- trace_point.disable
98
-
99
- @examples_traced_files[example_id] = @traced_files
100
- @traced_files = Set.new
101
- end
102
-
103
- def runner
104
- @runner if defined?(@runner)
105
- end
106
-
107
- def coverage_reporter
108
- @coverage_reporter if defined?(@coverage_reporter)
109
- end
110
-
111
- def report_writer
112
- @report_writer if defined?(@report_writer)
113
- end
114
-
115
- def coverage_merger
116
- @coverage_merger if defined?(@coverage_merger)
117
- end
118
-
119
- def report_merger
120
- @report_merger if defined?(@report_merger)
121
- end
122
-
123
- def trace_point
124
- @trace_point if defined?(@trace_point)
125
- end
126
-
127
- def traced_files
128
- @traced_files if defined?(@traced_files)
129
- end
130
-
131
- def examples_traced_files
132
- @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)
133
137
  end
134
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]
135
145
  def simplecov?
136
146
  defined?(@simplecov) && @simplecov == true
137
147
  end
138
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]
139
155
  def parallel_tests?
140
156
  defined?(@parallel_tests) && @parallel_tests == true
141
157
  end
142
158
 
143
- private
144
-
145
- def valid_jruby_opts?
146
- require 'jruby'
147
-
148
- return true if Java::OrgJruby::RubyInstanceConfig.FULL_TRACE_ENABLED &&
149
- JRuby.runtime.object_space_enabled?
150
-
151
- RSpecTracer.logger.warn <<-WARN.strip.gsub(/\s+/, ' ')
152
- RSpec Tracer is not running as it requires debug and object space enabled. Use
153
- command line options "--debug" and "-X+O" or set the "debug.fullTrace=true" and
154
- "objectspace.enabled=true" options in your .jrubyrc file. You can also use
155
- JRUBY_OPTS="--debug -X+O".
156
- WARN
157
-
158
- 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
159
168
  end
160
169
 
161
- def initial_setup
162
- unless setup_rspec?
163
- RSpecTracer.logger.error 'Could not find a running RSpec process'
170
+ private
164
171
 
165
- return
166
- end
172
+ # Internal method on the tracer pipeline.
173
+ # @api private
174
+ def initial_setup
175
+ RSpecTracer::RSpec::Installation.install!
167
176
 
168
177
  setup_coverage
169
- setup_trace_point
178
+ setup_rails
170
179
 
171
- @runner = RSpecTracer::Runner.new
172
- @coverage_reporter = RSpecTracer::CoverageReporter.new
173
- @report_writer = RSpecTracer::ReportWriter.new(RSpecTracer.cache_path, @runner.reporter)
174
- end
175
-
176
- def parallel_tests_setup
177
- @parallel_tests = !(ENV.fetch('TEST_ENV_NUMBER', nil) && ENV.fetch('PARALLEL_TEST_GROUPS', nil)).nil?
178
-
179
- return unless parallel_tests?
180
-
181
- require 'parallel_tests' unless defined?(ParallelTests)
182
-
183
- @coverage_merger = RSpecTracer::CoverageMerger.new
184
- @report_merger = RSpecTracer::ReportMerger.new
185
- rescue LoadError => e
186
- RSpecTracer.logger.error "Failed to load parallel tests (Error: #{e.message})"
187
- ensure
188
- track_parallel_tests_test_env_number
189
- end
190
-
191
- def track_parallel_tests_test_env_number
192
- return unless parallel_tests?
193
-
194
- File.open(RSpecTracer.lock_file, File::RDWR | File::CREAT, 0o644) do |f|
195
- f.flock(File::LOCK_EX)
196
-
197
- test_num = [f.read.to_i, ENV['TEST_ENV_NUMBER'].to_i].max
198
-
199
- f.rewind
200
- f.write("#{test_num}\n")
201
- f.flush
202
- f.truncate(f.pos)
203
- end
204
- end
205
-
206
- def setup_rspec?
207
- runners = ObjectSpace.each_object(::RSpec::Core::Runner) do |runner|
208
- runner_clazz = runner.singleton_class
209
- clazz = RSpecTracer::RSpecRunner
210
-
211
- runner_clazz.prepend(clazz) unless runner_clazz.ancestors.include?(clazz)
212
-
213
- reporter_clazz = runner.configuration.reporter.singleton_class
214
- clazz = RSpecTracer::RSpecReporter
215
-
216
- reporter_clazz.prepend(clazz) unless reporter_clazz.ancestors.include?(clazz)
217
- end
218
-
219
- runners.positive?
180
+ @engine = RSpecTracer::Engine.new(configuration: RSpecTracer)
181
+ @engine.setup
220
182
  end
221
183
 
184
+ # Internal method on the tracer pipeline.
185
+ # @api private
222
186
  def setup_coverage
223
187
  @simplecov = defined?(SimpleCov) && SimpleCov.running
224
188
 
@@ -229,244 +193,131 @@ module RSpecTracer
229
193
  ::Coverage.start
230
194
  end
231
195
 
232
- def setup_trace_point
233
- @traced_files = Set.new
234
- @examples_traced_files = {}
235
-
236
- @trace_point = TracePoint.new(:call) do |tp|
237
- RSpecTracer.traced_files << tp.path if tp.path.start_with?(RSpecTracer.root)
238
- end
196
+ # Detects Rails by the presence of `::Rails::VERSION`. Users who
197
+ # require `rspec_tracer/rails` transitively load the Railtie (when
198
+ # Rails is also present); this method only sets the flag consumed
199
+ # by `RSpecTracer.rails?`. Safe when Rails is absent - the
200
+ # `defined?` guard returns nil, flag stays false.
201
+ def setup_rails
202
+ @rails = defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
239
203
  end
240
204
 
205
+ # Internal method on the tracer pipeline.
206
+ # @api private
241
207
  def run_exit_tasks
242
208
  if RSpecTracer.no_examples
243
209
  RSpecTracer.logger.info 'Skipped reports generation since all examples were filtered out'
244
210
  else
245
- generate_reports
211
+ snapshot = run_finalize
212
+ # Under parallel_tests, defer reporter emission until
213
+ # last-process finalize merges per-worker snapshots into the
214
+ # top-level cache. Each worker still persists its per-worker
215
+ # snapshot for the merge to consume. Earlier behavior had every
216
+ # worker emit reporters into rspec_tracer_report/parallel_tests_N/
217
+ # and purge_worker_dirs! removed those dirs - leaving the user
218
+ # with no usable terminal/JSON/HTML output. Now reporters fire
219
+ # ONCE at the merged top-level location.
220
+ emit_reporters(snapshot) if snapshot && !parallel_tests?
246
221
  end
247
222
 
248
- simplecov? ? run_simplecov_exit_task : run_coverage_exit_task
249
-
250
- run_parallel_tests_exit_tasks
251
- end
252
-
253
- def generate_reports
254
- RSpecTracer.logger.debug "RSpec tracer is generating reports (pid: #{RSpecTracer.pid})"
255
-
256
- process_dependency
257
- process_coverage
223
+ emit_coverage_json
258
224
 
259
- RSpecTracer::ReportGenerator.new(runner.reporter, runner.cache).generate_report
260
- report_writer.write_report
261
- RSpecTracer::HTMLReporter::Reporter.new(RSpecTracer.report_path, runner.reporter).generate_report
225
+ RSpecTracer::RSpec::ParallelTests.finalize! if parallel_tests?
262
226
  end
263
227
 
264
- def process_dependency
228
+ # Engine-owned finalize path. Writes the 15-file JSON cache via
229
+ # Storage::JsonBackend. Per-example coverage deltas live on the
230
+ # Engine; 2.0 retired the CoverageReporter mid-flow piece (the
231
+ # legacy `coverage_reporter.generate_final_examples_coverage +
232
+ # merge_coverage(engine.merge_skipped_coverage(...))` is now folded
233
+ # into Reporters::CoverageJsonReporter#generate, which fires from
234
+ # `emit_coverage_json` after this method returns).
235
+ def run_finalize
265
236
  starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
266
237
 
267
- runner.register_interrupted_examples
268
- runner.register_deleted_examples
269
- runner.register_dependency(coverage_reporter.examples_coverage)
270
- runner.register_traced_dependency(@examples_traced_files)
238
+ snapshot = engine.finalize
271
239
 
272
240
  ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
273
241
  elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
274
242
 
275
- RSpecTracer.logger.debug "RSpec tracer processed dependency (took #{elapsed})"
276
- end
277
-
278
- def process_coverage
279
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
280
-
281
- coverage_reporter.generate_final_examples_coverage
282
- coverage_reporter.merge_coverage(runner.generate_missed_coverage)
283
- runner.register_examples_coverage(coverage_reporter.examples_coverage)
284
-
285
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
286
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
287
-
288
- RSpecTracer.logger.debug "RSpec tracer processed coverage (took #{elapsed})"
289
- end
290
-
291
- def run_simplecov_exit_task
292
- coverage_clazz = ::Coverage.singleton_class
293
- clazz = RSpecTracer::RubyCoverage
294
- coverage_clazz.prepend(clazz) unless coverage_clazz.ancestors.include?(clazz)
295
-
296
- RSpecTracer.logger.debug 'SimpleCov will now generate coverage report (<3 RSpec tracer)'
297
-
298
- coverage_reporter.record_coverage if RSpecTracer.no_examples
299
- end
300
-
301
- def run_coverage_exit_task
302
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
303
-
304
- coverage_reporter.record_coverage if RSpecTracer.no_examples
305
- coverage_reporter.generate_final_coverage
306
-
307
- file_name = File.join(RSpecTracer.coverage_path, 'coverage.json')
308
- coverage_writer = RSpecTracer::CoverageWriter.new(file_name, coverage_reporter)
309
-
310
- coverage_writer.write_report
311
-
312
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
313
-
314
- coverage_writer.print_stats(ending - starting)
315
- end
316
-
317
- def run_parallel_tests_exit_tasks
318
- return unless parallel_tests_executed?
319
-
320
- merge_parallel_tests_reports
321
- write_parallel_tests_merged_report
322
- merge_parallel_tests_coverage_reports
323
- write_parallel_tests_coverage_report
324
- purge_parallel_tests_reports
325
- end
326
-
327
- def merge_parallel_tests_reports
328
- return unless parallel_tests_executed?
329
-
330
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
331
- reports_dir = []
332
-
333
- parallel_tests_peer_dirs(File.dirname(RSpecTracer.cache_path)).each do |cache_dir|
334
- run_id = JSON.parse(File.read(File.join(cache_dir, 'last_run.json'), encoding: 'UTF-8'))['run_id']
335
-
336
- reports_dir << File.join(cache_dir, run_id)
337
- end
338
-
339
- report_merger.merge(reports_dir)
340
-
341
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
342
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
343
-
344
- RSpecTracer.logger.debug "RSpec tracer merged parallel tests reports (took #{elapsed})"
345
- end
346
-
347
- def write_parallel_tests_merged_report
348
- return unless parallel_tests_executed?
349
-
350
- report_dir = File.dirname(RSpecTracer.cache_path)
351
-
352
- RSpecTracer::ReportWriter.new(report_dir, report_merger).write_report
353
-
354
- report_dir = File.dirname(RSpecTracer.report_path)
355
-
356
- RSpecTracer::HTMLReporter::Reporter.new(report_dir, report_merger).generate_report
357
- end
358
-
359
- def merge_parallel_tests_coverage_reports
360
- return unless parallel_tests_executed? && !simplecov?
361
-
362
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
363
-
364
- reports_dir = parallel_tests_peer_dirs(File.dirname(RSpecTracer.coverage_path))
365
-
366
- coverage_merger.merge(reports_dir)
367
-
368
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
369
- elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
370
-
371
- RSpecTracer.logger.debug "RSpec tracer merged parallel tests coverage reports (took #{elapsed})"
372
- end
373
-
374
- def write_parallel_tests_coverage_report
375
- return unless parallel_tests_executed? && !simplecov?
376
-
377
- starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
378
-
379
- coverage_path = File.dirname(RSpecTracer.coverage_path)
380
- file_name = File.join(coverage_path, 'coverage.json')
381
- coverage_writer = RSpecTracer::CoverageWriter.new(file_name, coverage_merger)
382
-
383
- coverage_writer.write_report
384
-
385
- ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
386
-
387
- coverage_writer.print_stats(ending - starting)
388
- end
389
-
390
- def purge_parallel_tests_reports
391
- return unless parallel_tests_executed?
392
-
393
- [RSpecTracer.cache_path, RSpecTracer.coverage_path, RSpecTracer.report_path].each do |path|
394
- parallel_tests_peer_dirs(File.dirname(path)).each do |worker_dir|
395
- FileUtils.rm_rf(worker_dir)
396
- end
397
- end
398
- end
399
-
400
- # Returns every `parallel_tests_*` subdirectory directly under
401
- # `base_path`. Used by the parallel_tests merge + purge paths.
402
- #
403
- # Earlier patches iterated `1..ENV['PARALLEL_TEST_GROUPS'].to_i`
404
- # to construct dir names, but parallel_tests's own runner sets
405
- # PARALLEL_TEST_GROUPS to the user-requested process count
406
- # (`Parallel.processor_count` by default), NOT the actual worker
407
- # count. When num_processes < spawned_worker_count, the upper
408
- # bound was too small: peer caches with TEST_ENV_NUMBER above the
409
- # bound were silently dropped from the merge AND left behind by
410
- # the purge. PR #101's commit message documented this gem
411
- # behaviour for `last_process?` detection but did not extend the
412
- # fix to the iteration call-sites; this method closes that gap.
413
- # Globbing the actual filesystem state is robust to the env
414
- # discrepancy regardless of how the gem partitions specs.
415
- def parallel_tests_peer_dirs(base_path)
416
- Dir.glob(File.join(base_path, 'parallel_tests_*')).select do |path|
417
- File.directory?(path)
418
- end
419
- end
420
-
421
- def parallel_tests_executed?
422
- return false unless parallel_tests? && parallel_tests_last_process?
423
-
424
- ParallelTests.wait_for_other_processes_to_finish
425
-
426
- true
427
- end
428
-
429
- # Elects the worker that performs the per-run merge. Delegates to
430
- # `::ParallelTests.first_process?`, which returns true iff
431
- # `TEST_ENV_NUMBER.to_i <= 1` — i.e. for exactly one worker
432
- # (TEST_ENV_NUMBER == '' or '1'), regardless of how many workers
433
- # were actually spawned vs. how many CPUs the runner reports.
434
- #
435
- # Two previously attempted approaches do NOT work here:
436
- #
437
- # 1. The lock-file scheme below (each worker writing its
438
- # TEST_ENV_NUMBER to `rspec_tracer.lock` via
439
- # `track_parallel_tests_test_env_number`; last_process picked
440
- # the max) deadlocked under slow CI: worker 1 could finish
441
- # its examples before worker 2 even loaded spec_helper,
442
- # observe itself as the max, and enter
443
- # `::ParallelTests.wait_for_other_processes_to_finish`
444
- # concurrently with worker 2's own self-election — both
445
- # workers then spun on each other's pid.
446
- #
447
- # 2. `::ParallelTests.last_process?` compares TEST_ENV_NUMBER
448
- # against PARALLEL_TEST_GROUPS, which parallel_rspec sets to
449
- # the CPU-based *intended* process count — NOT the actual
450
- # worker count. When spec files < CPU count (common), no
451
- # TEST_ENV_NUMBER ever matches PARALLEL_TEST_GROUPS and the
452
- # merge is silently skipped.
453
- #
454
- # `first_process?` avoids both: set by the parent at spawn,
455
- # immutable thereafter, and identifies exactly one worker
456
- # regardless of CPU count. The elected worker still calls
457
- # `wait_for_other_processes_to_finish` before merging so peer
458
- # caches are guaranteed on disk.
459
- #
460
- # `track_parallel_tests_test_env_number` and the lock-file
461
- # cleanup in `at_exit_behavior` are retained for backward
462
- # compatibility with users who observe `rspec_tracer.lock` /
463
- # set `RSPEC_TRACER_LOCK_FILE`; the file is still written and
464
- # removed but is no longer consulted.
465
- def parallel_tests_last_process?
466
- return false unless parallel_tests?
467
- return false unless defined?(::ParallelTests)
468
-
469
- ::ParallelTests.first_process?
243
+ RSpecTracer.logger.debug "RSpec tracer persisted cache (took #{elapsed})"
244
+ snapshot
245
+ rescue StandardError => e
246
+ # Graceful-degradation contract per ARCHITECTURE.md
247
+ # section Cache corruption recovery: never propagate storage errors
248
+ # into the user's test suite. Read-only cache_path, disk-full
249
+ # mid-write, permission flips between runs - log and skip
250
+ # report emission. The caller (run_exit_tasks) checks for nil
251
+ # before calling emit_reporters; coverage / parallel_tests
252
+ # finalize paths run independently downstream.
253
+ RSpecTracer.logger.warn(
254
+ "rspec-tracer: cache persistence failed (#{e.class}: #{e.message}); " \
255
+ 'skipping report generation. Verify cache_path is writable.'
256
+ )
257
+ nil
258
+ end
259
+
260
+ # Fire the configured reporters against the persisted
261
+ # Snapshot. Fires per-worker under parallel_tests (same cadence as
262
+ # coverage.json emission); each worker produces its own report.json
263
+ # under its per-worker report_dir. The Registry rescues every
264
+ # reporter individually, so a buggy reporter warns and continues -
265
+ # never propagates a non-zero exit into the user's test suite
266
+ # (graceful degradation contract, same as Storage backends).
267
+ def emit_reporters(snapshot)
268
+ RSpecTracer::Reporters::Registry.emit_all(
269
+ configuration: RSpecTracer,
270
+ snapshot: snapshot,
271
+ report_dir: RSpecTracer.report_path,
272
+ run_metadata: build_run_metadata
273
+ )
274
+ rescue StandardError => e
275
+ RSpecTracer.logger.warn(
276
+ "rspec-tracer: reporter pipeline failed (#{e.class}: #{e.message})"
277
+ )
278
+ end
279
+
280
+ # Internal method on the tracer pipeline.
281
+ # @api private
282
+ def build_run_metadata
283
+ {
284
+ pid: RSpecTracer.pid,
285
+ run_time: run_elapsed_seconds,
286
+ started_at: defined?(@run_started_at) ? @run_started_at : nil,
287
+ cache_path: RSpecTracer.cache_path,
288
+ parallel_tests: RSpecTracer.parallel_tests?,
289
+ rails: RSpecTracer.rails?
290
+ }
291
+ end
292
+
293
+ # Internal method on the tracer pipeline.
294
+ # @api private
295
+ def run_elapsed_seconds
296
+ return nil unless defined?(@run_monotonic_start) && @run_monotonic_start
297
+
298
+ (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @run_monotonic_start).round(4)
299
+ end
300
+
301
+ # Dedicated coverage.json firing path, parallel to the
302
+ # report-reporters Registry pipeline. Fires unconditionally - even
303
+ # when no examples ran (matches 1.x where coverage.json gets
304
+ # written with whatever boot-time peek_result returned + filter +
305
+ # stub). The emitter handles SimpleCov interop internally
306
+ # (installs `::Coverage.singleton_class.prepend` shim instead of
307
+ # writing coverage.json when SimpleCov is loaded).
308
+ def emit_coverage_json
309
+ return unless engine
310
+
311
+ RSpecTracer::Reporters::CoverageJsonReporter.new(
312
+ snapshot: nil,
313
+ report_dir: RSpecTracer.report_path,
314
+ run_metadata: build_run_metadata,
315
+ logger: RSpecTracer.logger
316
+ ).generate
317
+ rescue StandardError => e
318
+ RSpecTracer.logger.warn(
319
+ "rspec-tracer: coverage.json emit failed (#{e.class}: #{e.message})"
320
+ )
470
321
  end
471
322
  end
472
323
  end