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
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module RSpecTracer
7
+ # Internal RSpec — see {RSpecTracer} for the user-facing surface.
8
+ # @api private
9
+ module RSpec
10
+ # parallel_tests orchestration for the v2 engine.
11
+ #
12
+ # 1.x scattered the parallel-worker glue across `lib/rspec_tracer.rb`
13
+ # (`parallel_tests_setup`, `track_parallel_tests_test_env_number`,
14
+ # `run_parallel_tests_exit_tasks`, `merge_parallel_tests_reports`,
15
+ # `parallel_tests_last_process?`, etc). 2.0 collapses them here and
16
+ # rewires the snapshot merge onto `Storage::JsonBackend#merge_from_peers`
17
+ # so any storage backend (including SQLite) gets the merge for free.
18
+ #
19
+ # Responsibilities:
20
+ # - Detect `TEST_ENV_NUMBER` + `PARALLEL_TEST_GROUPS` env vars.
21
+ # - Maintain the shared `rspec_tracer.lock` file that records the
22
+ # highest TEST_ENV_NUMBER seen (last-process detection).
23
+ # - Decide the narrator: first process by env convention. Log
24
+ # rollup lines only on the narrator unless
25
+ # `RSPEC_TRACER_VERBOSE=true`.
26
+ # - On the last process, merge per-worker snapshots +
27
+ # coverage.json, purge `parallel_tests_N/` directories.
28
+ #
29
+ # Graceful degradation: every merge / cleanup step rescues
30
+ # StandardError and logs - a partial or corrupt peer cache must
31
+ # never propagate a non-zero exit into the user's test run.
32
+ module ParallelTests
33
+ # Internal constant.
34
+ # @api private
35
+ LOCK_ENCODING = 'UTF-8'
36
+
37
+ # Per-worker boot/done breadcrumbs written to each worker's
38
+ # `parallel_tests_N/` cache dir. The elected worker uses these
39
+ # at finalize time to verify every booted peer has reached the
40
+ # end of its at_exit before merge + purge:
41
+ #
42
+ # .boot — written at setup! time (very early, before any
43
+ # cache write). Source-of-truth for "this worker
44
+ # ever booted past RSpecTracer.start".
45
+ # .done — written at finalize entry, AFTER per-worker
46
+ # run_finalize + emit_coverage_json. Must be the
47
+ # last write our code does into parallel_tests_N/
48
+ # on the worker side - the elected reads its
49
+ # presence as "this peer is fully flushed".
50
+ #
51
+ # Verification path: see `wait_for_peer_done_markers!`. Without
52
+ # this the elected trusted only `wait_for_other_processes_to_finish`'s
53
+ # pid-file barrier, which observed evidence on GHA Linux x86_64
54
+ # showed could return before a sibling had flushed - leaving a
55
+ # straggler `parallel_tests_N/` after purge (failing
56
+ # spec/integration/parallel_tests_spec.rb:88 intermittently).
57
+ BOOT_MARKER_FILENAME = '.rspec_tracer_boot'
58
+ # Internal constant.
59
+ # @api private
60
+ DONE_MARKER_FILENAME = '.rspec_tracer_done'
61
+
62
+ # Bound on the elected worker's wait for missing .done markers.
63
+ # 5s comfortably exceeds the at_exit tail of any well-behaved
64
+ # peer; on timeout we log + proceed (graceful degradation: a
65
+ # truly-crashed peer must not pin the elected forever).
66
+ PEER_DONE_DEADLINE_SECONDS = 5
67
+
68
+ # Internal helper for the tracer pipeline.
69
+ # @api private
70
+ def self.active?
71
+ return false if ::ENV.fetch('TEST_ENV_NUMBER', nil).nil?
72
+ return false if ::ENV.fetch('PARALLEL_TEST_GROUPS', nil).nil?
73
+
74
+ true
75
+ end
76
+
77
+ # Narrator = first process. TEST_ENV_NUMBER is either '' or '1'
78
+ # for the first worker under parallel_tests; otherwise '2', '3',
79
+ # etc. When the gem is not running under parallel_tests, the
80
+ # single process is its own narrator.
81
+ def self.narrator?
82
+ return true unless active?
83
+
84
+ value = ::ENV.fetch('TEST_ENV_NUMBER', '').to_s
85
+ value.empty? || value == '1'
86
+ end
87
+
88
+ # Internal helper for the tracer pipeline.
89
+ # @api private
90
+ def self.verbose?
91
+ ::ENV.fetch('RSPEC_TRACER_VERBOSE', nil) == 'true'
92
+ end
93
+
94
+ # True iff this worker should emit rollup log lines. Per-example
95
+ # RSpec output (dots, failures, durations) is unaffected - that's
96
+ # RSpec's own Reporter, not this module.
97
+ def self.log_rollups?
98
+ verbose? || narrator?
99
+ end
100
+
101
+ # Called from RSpecTracer.start when parallel_tests is active.
102
+ # Writes this worker's TEST_ENV_NUMBER into the shared lock file
103
+ # under an exclusive lock so the max-seen value ends up correct
104
+ # regardless of worker boot order, then drops the .boot
105
+ # breadcrumb so the elected worker can enumerate "every peer
106
+ # that booted" at finalize time.
107
+ def self.setup!
108
+ return false unless active?
109
+
110
+ require 'parallel_tests' unless defined?(::ParallelTests)
111
+ track_test_env_number!
112
+ touch_boot!
113
+ true
114
+ rescue LoadError => e
115
+ RSpecTracer.logger.error("Failed to load parallel_tests gem (#{e.class}: #{e.message})")
116
+ false
117
+ end
118
+
119
+ # Write `parallel_tests_N/.rspec_tracer_boot` with this worker's
120
+ # pid + TEST_ENV_NUMBER + timestamp. Source-of-truth for "this
121
+ # worker booted past RSpecTracer.start", consumed by the elected
122
+ # worker's finalize-time peer enumeration. Idempotent: a re-run
123
+ # of setup! overwrites with current values.
124
+ def self.touch_boot!
125
+ ::FileUtils.mkdir_p(RSpecTracer.cache_path)
126
+ ::File.write(
127
+ ::File.join(RSpecTracer.cache_path, BOOT_MARKER_FILENAME),
128
+ ::JSON.generate(
129
+ pid: ::Process.pid,
130
+ test_env_number: ::ENV.fetch('TEST_ENV_NUMBER', ''),
131
+ started_at: ::Time.now.utc.iso8601
132
+ )
133
+ )
134
+ rescue StandardError => e
135
+ RSpecTracer.logger.warn(
136
+ "RSpec tracer: failed to write boot marker (#{e.class}: #{e.message})"
137
+ )
138
+ end
139
+
140
+ # Called from at_exit after the per-worker snapshot has been
141
+ # persisted. Every worker drops its `.done` marker as the very
142
+ # first step here so the elected worker's verification loop can
143
+ # observe it; non-elected workers then return. The elected
144
+ # worker waits for every booted peer's `.done` to appear,
145
+ # orchestrates the snapshot + coverage merge, emits the merged
146
+ # reporters, and purges per-worker dirs.
147
+ #
148
+ # `touch_done!` MUST stay the last write our code performs into
149
+ # `parallel_tests_N/` — anything written later would land after
150
+ # the elected has decided it's safe to purge, leaving stragglers.
151
+ def self.finalize!
152
+ return false unless active?
153
+
154
+ touch_done!
155
+
156
+ return false unless last_process?
157
+
158
+ ::ParallelTests.wait_for_other_processes_to_finish if defined?(::ParallelTests)
159
+
160
+ # Belt-and-suspenders barrier: pid-file said everyone's done,
161
+ # but observed CI evidence (GHA Linux x86_64, Ruby 3.4 cells)
162
+ # caught a sibling's `parallel_tests_N/` reappearing post-purge
163
+ # — i.e., the pid signal returned while a peer hadn't fully
164
+ # flushed yet. Cross-check via the .boot/.done filesystem
165
+ # markers before declaring the peer set stable.
166
+ wait_for_peer_done_markers!
167
+
168
+ merge_snapshot!
169
+ merge_coverage! unless RSpecTracer.simplecov?
170
+ # Emit terminal/JSON/HTML reporters ONCE at the merged top-level
171
+ # location BEFORE purge_worker_dirs! removes the per-worker
172
+ # `parallel_tests_N` dirs. Earlier behavior had each worker emit
173
+ # reports into its `rspec_tracer_report/parallel_tests_N` dir
174
+ # and the purge then deleted them, leaving the user with zero
175
+ # usable output. Now reporters consume the just-merged
176
+ # top-level snapshot.
177
+ emit_merged_reporters!
178
+ purge_worker_dirs!
179
+ remove_lock_file!
180
+ true
181
+ rescue StandardError => e
182
+ RSpecTracer.logger.warn(
183
+ "RSpec tracer: parallel_tests finalize failed (#{e.class}: #{e.message})"
184
+ )
185
+ false
186
+ end
187
+
188
+ # Drop `parallel_tests_N/.rspec_tracer_done` as a flush-complete
189
+ # signal for the elected worker's verification loop. The cache
190
+ # dir already exists by this point (run_finalize mkdir_p's it
191
+ # earlier in the at_exit chain); the explicit mkdir_p here is
192
+ # belt-and-suspenders for the no-examples / early-return paths.
193
+ # Graceful-degradation rescue keeps a marker-write failure from
194
+ # propagating into the user's exit status.
195
+ def self.touch_done!
196
+ ::FileUtils.mkdir_p(RSpecTracer.cache_path)
197
+ ::File.write(
198
+ ::File.join(RSpecTracer.cache_path, DONE_MARKER_FILENAME),
199
+ ::Time.now.utc.iso8601
200
+ )
201
+ rescue StandardError => e
202
+ RSpecTracer.logger.warn(
203
+ "RSpec tracer: failed to write done marker (#{e.class}: #{e.message})"
204
+ )
205
+ end
206
+
207
+ # Block until every peer that wrote `.boot` has also written
208
+ # `.done`, or the deadline elapses. Polled at 50ms — fine
209
+ # enough that the typical "barrier returned a tick early" case
210
+ # closes within one or two polls, coarse enough not to dominate
211
+ # CPU.
212
+ #
213
+ # On timeout we log a warn and proceed: a peer that never wrote
214
+ # `.done` either crashed (then its dir is orphan content; the
215
+ # subsequent `purge_worker_dirs!` cleans it) or is genuinely
216
+ # hung (the elected can't fix that — we choose merge correctness
217
+ # over indefinite wait).
218
+ def self.wait_for_peer_done_markers!
219
+ base_dir = ::File.dirname(RSpecTracer.cache_path)
220
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + PEER_DONE_DEADLINE_SECONDS
221
+
222
+ loop do
223
+ missing = peer_dirs_missing_done(base_dir)
224
+ return if missing.empty?
225
+
226
+ if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
227
+ RSpecTracer.logger.warn(
228
+ 'RSpec tracer: peers booted without finishing within ' \
229
+ "#{PEER_DONE_DEADLINE_SECONDS}s: #{missing.inspect}; " \
230
+ 'proceeding (peer dirs will be purged regardless of completion state)'
231
+ )
232
+ return
233
+ end
234
+
235
+ sleep 0.05
236
+ end
237
+ end
238
+
239
+ # Set difference of `.boot`-bearing peer dirs and `.done`-bearing
240
+ # peer dirs under `base_dir`. A returned entry means "this peer
241
+ # registered but hasn't signaled completion yet" — either still
242
+ # mid-flush, or crashed.
243
+ def self.peer_dirs_missing_done(base_dir)
244
+ boot_dirs = peer_dirs_with_marker(base_dir, BOOT_MARKER_FILENAME)
245
+ done_dirs = peer_dirs_with_marker(base_dir, DONE_MARKER_FILENAME)
246
+ boot_dirs - done_dirs
247
+ end
248
+
249
+ # Internal helper for the tracer pipeline.
250
+ # @api private
251
+ def self.peer_dirs_with_marker(base_dir, marker_filename)
252
+ paths = ::Dir.glob(::File.join(base_dir, 'parallel_tests_*', marker_filename))
253
+ paths.map { |path| ::File.dirname(path) }
254
+ end
255
+
256
+ # Emit reporters against the merged top-level snapshot
257
+ # so the user gets one terminal summary + one JSON report + one
258
+ # HTML report at the canonical (non-`parallel_tests_N`) path.
259
+ # Wrapped in its own rescue so a failed reporter never blocks
260
+ # purge / lock cleanup downstream.
261
+ def self.emit_merged_reporters!
262
+ return unless RSpecTracer.storage_backend == :json
263
+
264
+ base_dir = ::File.dirname(RSpecTracer.cache_path)
265
+ merged_snapshot = load_merged_snapshot(base_dir)
266
+ return if merged_snapshot.nil?
267
+
268
+ top_report_dir = ::File.dirname(RSpecTracer.report_path)
269
+ ::FileUtils.mkdir_p(top_report_dir)
270
+
271
+ RSpecTracer::Reporters::Registry.emit_all(
272
+ configuration: RSpecTracer,
273
+ snapshot: merged_snapshot,
274
+ report_dir: top_report_dir,
275
+ run_metadata: build_merged_run_metadata(base_dir)
276
+ )
277
+ rescue StandardError => e
278
+ RSpecTracer.logger.warn(
279
+ "RSpec tracer: merged reporter emission failed (#{e.class}: #{e.message})"
280
+ )
281
+ end
282
+
283
+ # Internal helper for the tracer pipeline.
284
+ # @api private
285
+ def self.load_merged_snapshot(base_dir)
286
+ backend = RSpecTracer::Storage::JsonBackend.new(
287
+ cache_path: base_dir,
288
+ logger: RSpecTracer.logger,
289
+ retention_local_count: RSpecTracer.cache_retention_local_count,
290
+ serializer: RSpecTracer.storage_backend_opts[:serializer] || :json
291
+ )
292
+ backend.load_graph(schema_version: RSpecTracer::Storage::Schema::CURRENT)
293
+ end
294
+
295
+ # Internal helper for the tracer pipeline.
296
+ # @api private
297
+ def self.build_merged_run_metadata(base_dir)
298
+ {
299
+ pid: Process.pid,
300
+ run_time: nil,
301
+ started_at: nil,
302
+ cache_path: base_dir,
303
+ parallel_tests: true,
304
+ rails: RSpecTracer.rails?
305
+ }
306
+ end
307
+
308
+ # Internal helper for the tracer pipeline.
309
+ # @api private
310
+ def self.track_test_env_number!
311
+ ::File.open(RSpecTracer.lock_file, ::File::RDWR | ::File::CREAT, 0o644) do |f|
312
+ f.flock(::File::LOCK_EX)
313
+
314
+ test_num = [f.read.to_i, ::ENV.fetch('TEST_ENV_NUMBER').to_i].max
315
+
316
+ f.rewind
317
+ f.write("#{test_num}\n")
318
+ f.flush
319
+ f.truncate(f.pos)
320
+ end
321
+ end
322
+
323
+ # Elects the worker that performs the per-run merge. Delegates to
324
+ # `::ParallelTests.first_process?`, which returns true iff
325
+ # `TEST_ENV_NUMBER.to_i <= 1` -- i.e. for exactly one worker
326
+ # (TEST_ENV_NUMBER == '' or '1'), regardless of how many workers
327
+ # were actually spawned vs. how many CPUs the runner reports.
328
+ #
329
+ # Two historical approaches do NOT work here:
330
+ #
331
+ # 1. The 1.x lock-file scheme (each worker wrote its
332
+ # TEST_ENV_NUMBER to `rspec_tracer.lock` at RSpecTracer.start
333
+ # time; last_process? picked the max) deadlocked under CI:
334
+ # worker 1 could finish its examples before worker 2 even
335
+ # loaded spec_helper, observe itself as the max, and enter
336
+ # `wait_for_other_processes_to_finish` concurrently with
337
+ # worker 2's own self-election -- both workers spun on each
338
+ # other's pid.
339
+ #
340
+ # 2. `::ParallelTests.last_process?` compares TEST_ENV_NUMBER
341
+ # against PARALLEL_TEST_GROUPS. parallel_rspec's CLI sets
342
+ # PARALLEL_TEST_GROUPS to the CPU-based *intended* process
343
+ # count, NOT the actual worker count -- so when fewer specs
344
+ # than CPUs are present, no TEST_ENV_NUMBER ever matches
345
+ # PARALLEL_TEST_GROUPS and the merge is silently skipped.
346
+ #
347
+ # `first_process?` avoids both: it is immutable across worker
348
+ # lifetime (set by the parent at spawn) and identifies exactly
349
+ # one worker regardless of CPU count. The elected worker still
350
+ # calls `wait_for_other_processes_to_finish` before merging, so
351
+ # peer caches are guaranteed on disk by merge time.
352
+ def self.last_process?
353
+ return false unless active?
354
+ return false unless defined?(::ParallelTests)
355
+
356
+ ::ParallelTests.first_process?
357
+ end
358
+
359
+ # Internal helper for the tracer pipeline.
360
+ # @api private
361
+ def self.remove_lock_file!
362
+ ::FileUtils.rm_f(RSpecTracer.lock_file)
363
+ end
364
+
365
+ # parallel_tests sets `PARALLEL_TEST_GROUPS = num_processes.to_s`
366
+ # for each child, where `num_processes` is the user-requested
367
+ # process count (Parallel.processor_count by default) - NOT the
368
+ # number of workers actually spawned. When `num_processes` and
369
+ # the spawned-worker count diverge (e.g. when the spec count caps
370
+ # the partition below the CPU count, or when shared-runner
371
+ # cgroup throttling shifts the visible CPU count between when
372
+ # the parent computed `num_processes` and the spec count is
373
+ # observed), iterating `1..ENV['PARALLEL_TEST_GROUPS']` either
374
+ # over-iterates (cheap; rm_rf on a non-existent path is a no-op)
375
+ # or UNDER-iterates (expensive; merge skips peers + purge leaves
376
+ # `parallel_tests_N` stragglers behind, breaking the integration
377
+ # spec at `spec/integration/parallel_tests_spec.rb:88`).
378
+ #
379
+ # Glob the actual filesystem state rather than reconstructing dir
380
+ # names from an env var with surprising semantics. The directory
381
+ # IS the source of truth for which workers ran. The wait at
382
+ # `finalize!` (`wait_for_other_processes_to_finish`) guarantees
383
+ # every other worker's at_exit has flushed its `parallel_tests_N`
384
+ # tree before this method runs, so the glob captures every peer.
385
+ def self.peer_paths_for(base_dir)
386
+ ::Dir.glob(::File.join(base_dir, 'parallel_tests_*')).select do |path|
387
+ ::File.directory?(path)
388
+ end
389
+ end
390
+
391
+ # Merge the per-worker v2 snapshots into the top-level cache.
392
+ # SqliteBackend has no merge surface (single-file, latest-run
393
+ # only); the elected worker persists its own run via Engine
394
+ # finalize and the per-worker files accumulate next to it
395
+ # untouched. The JSON merge path stays authoritative for the
396
+ # default `:json` backend which is what parallel_tests fixtures
397
+ # exercise in CI.
398
+ def self.merge_snapshot!
399
+ return unless RSpecTracer.storage_backend == :json
400
+
401
+ base_dir = ::File.dirname(RSpecTracer.cache_path)
402
+ peer_paths = peer_paths_for(base_dir)
403
+ return if peer_paths.empty?
404
+
405
+ starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
406
+
407
+ top = RSpecTracer::Storage::JsonBackend.new(
408
+ cache_path: base_dir, logger: RSpecTracer.logger,
409
+ retention_local_count: RSpecTracer.cache_retention_local_count,
410
+ warn_per_file_mb: RSpecTracer.cache_size_warn_per_file_mb,
411
+ warn_total_mb: RSpecTracer.cache_size_warn_total_mb,
412
+ serializer: RSpecTracer.storage_backend_opts[:serializer] || :json
413
+ )
414
+ top.merge_from_peers(peer_paths, schema_version: RSpecTracer::Storage::Schema::CURRENT)
415
+
416
+ ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
417
+ elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
418
+
419
+ RSpecTracer.logger.debug("RSpec tracer merged parallel tests reports (took #{elapsed})") if log_rollups?
420
+ end
421
+
422
+ # Merge per-worker coverage.json files into a top-level coverage.json.
423
+ # Routed through Reporters::CoverageJsonReporter.merge_parallel
424
+ # (replaces the legacy CoverageMerger + CoverageWriter pair).
425
+ def self.merge_coverage!
426
+ base_dir = ::File.dirname(RSpecTracer.coverage_path)
427
+ peer_paths = peer_paths_for(base_dir)
428
+ return if peer_paths.empty?
429
+
430
+ starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
+
432
+ RSpecTracer::Reporters::CoverageJsonReporter.merge_parallel(
433
+ peer_paths: peer_paths,
434
+ output_path: ::File.join(base_dir, 'coverage.json'),
435
+ logger: RSpecTracer.logger
436
+ )
437
+
438
+ ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
439
+ elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
440
+
441
+ RSpecTracer.logger.debug("RSpec tracer merged parallel tests coverage (took #{elapsed})") if log_rollups?
442
+ end
443
+
444
+ # Sweep every `parallel_tests_*` subdirectory under each managed
445
+ # base path. Globbing matches the same source-of-truth contract
446
+ # documented on `peer_paths_for`: the directories that actually
447
+ # exist are exactly the workers that ran, regardless of what
448
+ # PARALLEL_TEST_GROUPS reports.
449
+ def self.purge_worker_dirs!
450
+ [RSpecTracer.cache_path, RSpecTracer.coverage_path, RSpecTracer.report_path].each do |path|
451
+ base_dir = ::File.dirname(path)
452
+ ::Dir.glob(::File.join(base_dir, 'parallel_tests_*')).each do |worker_dir|
453
+ ::FileUtils.rm_rf(worker_dir)
454
+ end
455
+ end
456
+ end
457
+ end
458
+ end
459
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecTracer
4
+ # Internal RSpec — see {RSpecTracer} for the user-facing surface.
5
+ # @api private
6
+ module RSpec
7
+ # Prepended onto `RSpec::Core::Reporter` by
8
+ # `RSpecTracer::RSpec::Installation.install!`. Replaces the 1.x
9
+ # `RSpecTracer::RSpecReporter` singleton-class prepend.
10
+ #
11
+ # Forwards RSpec's example lifecycle notifications into the engine,
12
+ # then chains to `super`. Every callback is no-op when either:
13
+ # - the engine isn't set up (start never called, graceful degrade)
14
+ # - the example carries no `:rspec_tracer_example_id` metadata
15
+ # (it was partitioned into `ignore_spec_files` by RunnerHook, so
16
+ # the tracer treats it as invisible)
17
+ #
18
+ # The per-example coverage peek+diff sequence (peek before, peek
19
+ # after) runs through Engine#example_started + Engine#example_finished
20
+ # only. 2.0 retired the legacy CoverageReporter that previously
21
+ # peeked a second time per example; coverage.json emission now
22
+ # consumes the Engine's per-example deltas + a single finalize-time
23
+ # peek through Tracker::CoverageAdapter#peek_unfiltered.
24
+ module ReporterHook
25
+ # Internal method on the tracer pipeline.
26
+ # @api private
27
+ def example_started(_example)
28
+ RSpecTracer.engine&.example_started
29
+
30
+ super
31
+ end
32
+
33
+ # Internal method on the tracer pipeline.
34
+ # @api private
35
+ def example_finished(example)
36
+ engine = RSpecTracer.engine
37
+ if engine
38
+ example_id = example.metadata[:rspec_tracer_example_id]
39
+ engine.example_finished(example_id) if example_id
40
+ end
41
+
42
+ super
43
+ end
44
+
45
+ # Internal method on the tracer pipeline.
46
+ # @api private
47
+ def example_passed(example)
48
+ _rspec_tracer_status(example, :on_example_passed)
49
+
50
+ super
51
+ end
52
+
53
+ # Internal method on the tracer pipeline.
54
+ # @api private
55
+ def example_failed(example)
56
+ _rspec_tracer_status(example, :on_example_failed)
57
+
58
+ super
59
+ end
60
+
61
+ # Internal method on the tracer pipeline.
62
+ # @api private
63
+ def example_pending(example)
64
+ _rspec_tracer_status(example, :on_example_pending)
65
+
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ # Internal method on the tracer pipeline.
72
+ # @api private
73
+ def _rspec_tracer_status(example, method)
74
+ engine = RSpecTracer.engine
75
+ return unless engine
76
+
77
+ example_id = example.metadata[:rspec_tracer_example_id]
78
+ return unless example_id
79
+
80
+ engine.public_send(method, example_id, example.execution_result)
81
+ end
82
+ end
83
+ end
84
+ end