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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../tracker/file_digest'
|
|
4
|
+
require_relative '../tracker/input'
|
|
5
|
+
|
|
6
|
+
module RSpecTracer
|
|
7
|
+
# Internal Rails — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module Rails
|
|
10
|
+
# ActiveSupport::Notifications observer for Rails-side inputs that
|
|
11
|
+
# Coverage and IOHooks can't see directly. Subscribes to:
|
|
12
|
+
#
|
|
13
|
+
# - render_template.action_view -> :template
|
|
14
|
+
# - render_partial.action_view -> :template
|
|
15
|
+
# - render_collection.action_view -> :template (Rails 7.1+)
|
|
16
|
+
# - sql.active_record -> :notification
|
|
17
|
+
# (on the first query per example, emits db/schema.rb +
|
|
18
|
+
# db/structure.sql as inputs)
|
|
19
|
+
#
|
|
20
|
+
# Lifecycle mirrors IOHooks: Engine.setup installs, example_started
|
|
21
|
+
# opens a thread-local bucket, subscribers append, example_finished
|
|
22
|
+
# harvests and clears the bucket, Engine.finalize unsubscribes.
|
|
23
|
+
#
|
|
24
|
+
# Payload access is defensively permissive - Rails minors differ on
|
|
25
|
+
# symbol vs string keys; missing payload or nil identifier is
|
|
26
|
+
# swallowed. Errors inside subscribers never propagate (CLAUDE.md
|
|
27
|
+
# "graceful degradation").
|
|
28
|
+
#
|
|
29
|
+
# Precedence: an observed template path already covered by a user
|
|
30
|
+
# declared glob (e.g. the Preset :views glob) skips notification
|
|
31
|
+
# emission via the injected `filter:` callable. Matches IOHooks'
|
|
32
|
+
# declared-glob-precedence rule from ARCHITECTURE.md.
|
|
33
|
+
#
|
|
34
|
+
# The sql.active_record subscriber is only attached when the caller
|
|
35
|
+
# passes a non-empty `ar_schema_paths:` list. Engine resolves the
|
|
36
|
+
# list from the `track_ar_schema_notifications` opt-in DSL; absent
|
|
37
|
+
# that, the AR subscriber is never installed and the sql.active_record
|
|
38
|
+
# event stream is ignored.
|
|
39
|
+
class Notifications
|
|
40
|
+
# Internal constant.
|
|
41
|
+
# @api private
|
|
42
|
+
BUCKET_KEY = :rspec_tracer_rails_bucket
|
|
43
|
+
# Internal constant.
|
|
44
|
+
# @api private
|
|
45
|
+
AR_FLAG_KEY = :rspec_tracer_rails_ar_emitted
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Internal attribute.
|
|
49
|
+
# @api private
|
|
50
|
+
attr_reader :root
|
|
51
|
+
|
|
52
|
+
# Internal method on the tracer pipeline.
|
|
53
|
+
# @api private
|
|
54
|
+
def install(root:, filter: ->(_path) { true }, ar_schema_paths: [])
|
|
55
|
+
@root = File.expand_path(root)
|
|
56
|
+
@root_prefix = "#{@root}/"
|
|
57
|
+
@filter = filter
|
|
58
|
+
@ar_schema_inputs = build_schema_inputs(ar_schema_paths)
|
|
59
|
+
@handles = []
|
|
60
|
+
|
|
61
|
+
subscribe_render_template
|
|
62
|
+
subscribe_render_partial
|
|
63
|
+
subscribe_render_collection if render_collection_supported?
|
|
64
|
+
subscribe_sql_active_record if ar_enabled?
|
|
65
|
+
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Internal method on the tracer pipeline.
|
|
70
|
+
# @api private
|
|
71
|
+
def uninstall
|
|
72
|
+
(@handles || []).each { |handle| safely_unsubscribe(handle) }
|
|
73
|
+
@handles = nil
|
|
74
|
+
@root = nil
|
|
75
|
+
@root_prefix = nil
|
|
76
|
+
@filter = nil
|
|
77
|
+
@ar_schema_inputs = nil
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Internal method on the tracer pipeline.
|
|
82
|
+
# @api private
|
|
83
|
+
def installed?
|
|
84
|
+
!@root_prefix.nil?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
88
|
+
def set_bucket(bucket)
|
|
89
|
+
Thread.current[BUCKET_KEY] = bucket
|
|
90
|
+
Thread.current[AR_FLAG_KEY] = false
|
|
91
|
+
end
|
|
92
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
93
|
+
|
|
94
|
+
def clear_bucket
|
|
95
|
+
Thread.current[BUCKET_KEY] = nil
|
|
96
|
+
Thread.current[AR_FLAG_KEY] = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Internal method on the tracer pipeline.
|
|
100
|
+
# @api private
|
|
101
|
+
def current_bucket
|
|
102
|
+
Thread.current[BUCKET_KEY]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Pure-logic entry point for the render_*.action_view
|
|
106
|
+
# subscribers. Extracted so mutation smoke can exercise
|
|
107
|
+
# payload parsing without driving AS::Notifications.
|
|
108
|
+
def handle_render_event(payload)
|
|
109
|
+
return nil unless payload.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
identifier = payload[:identifier] || payload['identifier']
|
|
112
|
+
return nil if identifier.nil?
|
|
113
|
+
|
|
114
|
+
record_template(identifier)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Pure-logic entry point for the sql.active_record subscriber.
|
|
118
|
+
# Payload contents are ignored - the event itself is the
|
|
119
|
+
# "this example touched AR" signal.
|
|
120
|
+
def handle_sql_event(_payload)
|
|
121
|
+
record_ar_schema
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Record a :template Input for the observed render identifier.
|
|
125
|
+
# Guarded by the same fast-reject ladder as IOHooks: install
|
|
126
|
+
# state, bucket presence, path-under-root, filter callable,
|
|
127
|
+
# identity dedup. The early-return ladder looks complex to
|
|
128
|
+
# rubocop's metric but is the simplest shape for a hot path.
|
|
129
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
130
|
+
def record_template(path)
|
|
131
|
+
return nil if @root_prefix.nil?
|
|
132
|
+
|
|
133
|
+
bucket = Thread.current[BUCKET_KEY]
|
|
134
|
+
return nil if bucket.nil?
|
|
135
|
+
return nil unless path.is_a?(String) || path.respond_to?(:to_s)
|
|
136
|
+
|
|
137
|
+
path_str = path.to_s
|
|
138
|
+
return nil unless path_str.start_with?(@root_prefix)
|
|
139
|
+
return nil unless @filter.call(path_str)
|
|
140
|
+
|
|
141
|
+
identity = "template:#{path_str[@root_prefix.length..]}"
|
|
142
|
+
return nil if bucket.key?(identity)
|
|
143
|
+
|
|
144
|
+
digest = Tracker::FileDigest.compute(path_str)
|
|
145
|
+
return nil if digest.nil?
|
|
146
|
+
|
|
147
|
+
bucket[identity] = Tracker::Input.for_file(
|
|
148
|
+
path: path_str, kind: :template, digest: digest, root: @root
|
|
149
|
+
)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
154
|
+
|
|
155
|
+
# Emit the pre-digested schema Inputs into the bucket on the
|
|
156
|
+
# first sql.active_record event per example. Subsequent events
|
|
157
|
+
# short-circuit via AR_FLAG_KEY - O(1) after the first query.
|
|
158
|
+
def record_ar_schema
|
|
159
|
+
return nil if @ar_schema_inputs.nil? || @ar_schema_inputs.empty?
|
|
160
|
+
|
|
161
|
+
bucket = Thread.current[BUCKET_KEY]
|
|
162
|
+
return nil if bucket.nil?
|
|
163
|
+
return nil if Thread.current[AR_FLAG_KEY]
|
|
164
|
+
|
|
165
|
+
Thread.current[AR_FLAG_KEY] = true
|
|
166
|
+
@ar_schema_inputs.each do |input|
|
|
167
|
+
bucket[input.identity] ||= input
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# Internal method on the tracer pipeline.
|
|
177
|
+
# @api private
|
|
178
|
+
def ar_enabled?
|
|
179
|
+
!(@ar_schema_inputs.nil? || @ar_schema_inputs.empty?)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Internal method on the tracer pipeline.
|
|
183
|
+
# @api private
|
|
184
|
+
def subscribe_render_template
|
|
185
|
+
@handles << ::ActiveSupport::Notifications.subscribe('render_template.action_view') do |*args|
|
|
186
|
+
handle_render_event(args.last)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Internal method on the tracer pipeline.
|
|
191
|
+
# @api private
|
|
192
|
+
def subscribe_render_partial
|
|
193
|
+
@handles << ::ActiveSupport::Notifications.subscribe('render_partial.action_view') do |*args|
|
|
194
|
+
handle_render_event(args.last)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Internal method on the tracer pipeline.
|
|
199
|
+
# @api private
|
|
200
|
+
def subscribe_render_collection
|
|
201
|
+
@handles << ::ActiveSupport::Notifications.subscribe('render_collection.action_view') do |*args|
|
|
202
|
+
handle_render_event(args.last)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Internal method on the tracer pipeline.
|
|
207
|
+
# @api private
|
|
208
|
+
def subscribe_sql_active_record
|
|
209
|
+
@handles << ::ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
|
|
210
|
+
handle_sql_event(args.last)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Rails 7.1 added render_collection.action_view. Subscribing to
|
|
215
|
+
# a non-existent event silently no-ops on modern AS, but the
|
|
216
|
+
# version check keeps `@handles` tight for uninstall accounting.
|
|
217
|
+
def render_collection_supported?
|
|
218
|
+
return false unless defined?(::Rails::VERSION::STRING)
|
|
219
|
+
|
|
220
|
+
Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new('7.1')
|
|
221
|
+
rescue StandardError
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Pre-digest the schema paths at install time. An unreadable or
|
|
226
|
+
# missing file is dropped on the floor - graceful degradation.
|
|
227
|
+
# Returns a frozen array so record_ar_schema can iterate without
|
|
228
|
+
# defensive dup.
|
|
229
|
+
def build_schema_inputs(paths)
|
|
230
|
+
Array(paths).each_with_object([]) do |path, acc|
|
|
231
|
+
input = try_build_schema_input(path)
|
|
232
|
+
acc << input if input
|
|
233
|
+
end.freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Internal method on the tracer pipeline.
|
|
237
|
+
# @api private
|
|
238
|
+
def try_build_schema_input(path)
|
|
239
|
+
abs = File.expand_path(path.to_s, @root)
|
|
240
|
+
return nil unless abs.start_with?(@root_prefix)
|
|
241
|
+
return nil unless File.file?(abs)
|
|
242
|
+
|
|
243
|
+
digest = Tracker::FileDigest.compute(abs)
|
|
244
|
+
return nil if digest.nil?
|
|
245
|
+
|
|
246
|
+
Tracker::Input.for_file(
|
|
247
|
+
path: abs, kind: :notification, digest: digest, root: @root
|
|
248
|
+
)
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Internal method on the tracer pipeline.
|
|
254
|
+
# @api private
|
|
255
|
+
def safely_unsubscribe(handle)
|
|
256
|
+
::ActiveSupport::Notifications.unsubscribe(handle)
|
|
257
|
+
rescue StandardError
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative '../configuration'
|
|
6
|
+
|
|
7
|
+
module RSpecTracer
|
|
8
|
+
# Internal Rails — see {RSpecTracer} for the user-facing surface.
|
|
9
|
+
# @api private
|
|
10
|
+
#
|
|
11
|
+
# Internal Rails-integration subsystem (Railtie + ActiveSupport
|
|
12
|
+
# notifications + I18n hooks + the default-glob preset). Activated
|
|
13
|
+
# when the user's spec_helper requires `rspec_tracer/rails`.
|
|
14
|
+
module Rails
|
|
15
|
+
# Default Rails glob preset - the Coverage-invisible surface that
|
|
16
|
+
# rspec-tracer cannot auto-observe via its standard Ruby-execution
|
|
17
|
+
# tracking. Enabled by `track_rails_defaults` in .rspec-tracer.
|
|
18
|
+
#
|
|
19
|
+
# Covered categories (each toggleable via the `except:` kwarg):
|
|
20
|
+
#
|
|
21
|
+
# :views - app/views/**/* (ERB / Slim / Haml / JBuilder)
|
|
22
|
+
# :helpers - app/helpers/**/*.rb (tracked as declared so helper
|
|
23
|
+
# files re-invalidate even before Zeitwerk loads them)
|
|
24
|
+
# :locales - config/locales/**/*.yml (I18n data files)
|
|
25
|
+
# :config - config/*.yml + config/routes.rb (database.yml,
|
|
26
|
+
# cable.yml, storage.yml, routes - the YAML + routes
|
|
27
|
+
# dispatcher are all config surface)
|
|
28
|
+
# :schema - db/schema.rb + db/structure.sql
|
|
29
|
+
# :factories - spec/factories/**/*.rb + test/factories/**/*.rb
|
|
30
|
+
# :fixtures - spec/fixtures/**/*.{yml,yaml,json} + same under test/
|
|
31
|
+
#
|
|
32
|
+
# Intentionally omitted:
|
|
33
|
+
# - Gemfile.lock, .ruby-version, .rspec-tracer: already tracked as
|
|
34
|
+
# whole-suite invalidators (Tracker::WholeSuiteInvalidators).
|
|
35
|
+
# Listing them as :declared would cross input-kind categories.
|
|
36
|
+
# - config/environments/*.rb, config/app_constants.rb: loaded as
|
|
37
|
+
# Ruby at Rails boot, so LoadedFilesTracker.@boot_set captures
|
|
38
|
+
# them already as transitive-load inputs.
|
|
39
|
+
#
|
|
40
|
+
# Unknown `except:` keys raise Configuration::InvalidUsageError, same
|
|
41
|
+
# pattern as `storage_backend(:bogus)`.
|
|
42
|
+
class Preset
|
|
43
|
+
# Internal constant.
|
|
44
|
+
# @api private
|
|
45
|
+
DEFAULTS = {
|
|
46
|
+
views: %w[app/views/**/*].freeze,
|
|
47
|
+
helpers: %w[app/helpers/**/*.rb].freeze,
|
|
48
|
+
locales: %w[config/locales/**/*.yml].freeze,
|
|
49
|
+
config: %w[config/*.yml config/routes.rb].freeze,
|
|
50
|
+
schema: %w[db/schema.rb db/structure.sql].freeze,
|
|
51
|
+
factories: %w[spec/factories/**/*.rb test/factories/**/*.rb].freeze,
|
|
52
|
+
fixtures: %w[
|
|
53
|
+
spec/fixtures/**/*.{yml,yaml,json}
|
|
54
|
+
test/fixtures/**/*.{yml,yaml,json}
|
|
55
|
+
].freeze
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# Internal constant.
|
|
59
|
+
# @api private
|
|
60
|
+
ALLOWED_KEYS = DEFAULTS.keys.to_set.freeze
|
|
61
|
+
|
|
62
|
+
# Internal helper for the tracer pipeline.
|
|
63
|
+
# @api private
|
|
64
|
+
def self.globs(except: [])
|
|
65
|
+
excluded = normalize_excluded(except)
|
|
66
|
+
validate_excluded!(excluded)
|
|
67
|
+
|
|
68
|
+
DEFAULTS
|
|
69
|
+
.except(*excluded)
|
|
70
|
+
.values
|
|
71
|
+
.flatten
|
|
72
|
+
.uniq
|
|
73
|
+
.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Internal helper for the tracer pipeline.
|
|
77
|
+
# @api private
|
|
78
|
+
def self.normalize_excluded(except)
|
|
79
|
+
Array(except).compact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Internal helper for the tracer pipeline.
|
|
83
|
+
# @api private
|
|
84
|
+
def self.validate_excluded!(excluded)
|
|
85
|
+
unknown = excluded.reject { |key| ALLOWED_KEYS.include?(key) }
|
|
86
|
+
return if unknown.empty?
|
|
87
|
+
|
|
88
|
+
raise RSpecTracer::Configuration::InvalidUsageError,
|
|
89
|
+
"unknown track_rails_defaults keys: #{unknown.inspect}; " \
|
|
90
|
+
"allowed: #{ALLOWED_KEYS.to_a.inspect}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Rails — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Rails
|
|
7
|
+
# Rails lifecycle adapter. Only required by `lib/rspec_tracer/rails.rb`
|
|
8
|
+
# when `defined?(::Rails::Railtie)` is true, so loading this file in
|
|
9
|
+
# a non-Rails process is not expected and will raise NameError.
|
|
10
|
+
#
|
|
11
|
+
# Defines the Railtie class + registers one initializer that logs
|
|
12
|
+
# a single confirmation line. The actual ActiveSupport::Notifications
|
|
13
|
+
# subscribers for ActionView template renders, I18n lookups, and
|
|
14
|
+
# schema/factory/fixture tracking live in `notifications.rb` and
|
|
15
|
+
# `i18n_tracking.rb` and are wired by `Engine.setup`.
|
|
16
|
+
class Railtie < ::Rails::Railtie
|
|
17
|
+
initializer 'rspec_tracer.setup' do
|
|
18
|
+
RSpecTracer.logger.info 'rspec-tracer Rails integration loaded'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails integration entry point. Loaded explicitly by users who put
|
|
4
|
+
# `require 'rspec_tracer/rails'` in their spec_helper - the gem does not
|
|
5
|
+
# auto-load this file, so pure-Ruby suites pay zero cost.
|
|
6
|
+
#
|
|
7
|
+
# Behavior:
|
|
8
|
+
# - Always loads RSpecTracer::Rails::Preset so Configuration#track_rails_defaults
|
|
9
|
+
# works whether or not Rails itself is booted in the current process.
|
|
10
|
+
# - Loads RSpecTracer::Rails::Railtie iff `::Rails::Railtie` is defined,
|
|
11
|
+
# so requiring this file when Rails is absent is a clean no-op
|
|
12
|
+
# (AC1: "Rails absent: requiring rspec_tracer/rails noops cleanly").
|
|
13
|
+
|
|
14
|
+
require_relative 'rails/preset'
|
|
15
|
+
require_relative 'rails/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Remote cache
|
|
2
|
+
|
|
3
|
+
Optional cross-machine cache layer. Downloads a prior run's dependency
|
|
4
|
+
graph so a fresh checkout (CI, new contributor) starts warm.
|
|
5
|
+
|
|
6
|
+
## Responsibilities
|
|
7
|
+
|
|
8
|
+
- Download the cache for a given ref (commit SHA) into the local cache
|
|
9
|
+
directory, with schema-version validation.
|
|
10
|
+
- Upload the current local cache under the branch ref.
|
|
11
|
+
- Persist branch refs so history rewrites (`git commit --amend`,
|
|
12
|
+
`git pull -r`) don't invalidate the next run.
|
|
13
|
+
- Prune old entries per configured retention policy.
|
|
14
|
+
|
|
15
|
+
## Public protocol
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
module RSpecTracer::RemoteCache::Backend
|
|
19
|
+
REQUIRED_METHODS = %i[
|
|
20
|
+
download upload branch_refs write_branch_refs prune! prune_all!
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.conforms?(backend); end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The shared-examples contract in `spec/contracts/remote_cache_backend.rb`
|
|
28
|
+
asserts the full behavioral contract on every implementation.
|
|
29
|
+
|
|
30
|
+
## Shipped backends
|
|
31
|
+
|
|
32
|
+
All three share the two-tier layout (main + per-PR-branch), the same
|
|
33
|
+
schema_version validator, and the same retention knobs.
|
|
34
|
+
|
|
35
|
+
- **`S3Backend`** — primary target. Shells out to `aws` / `awslocal`
|
|
36
|
+
CLI. Cache is one `cache.tar.gz` per ref.
|
|
37
|
+
- **`LocalFsBackend`** — shared directory (NFS, dev cache, CI
|
|
38
|
+
workspace). Same `cache.tar.gz` archive format as S3 — a LocalFs
|
|
39
|
+
root can be rsync'd to/from S3 with no transform. Atomic uploads via
|
|
40
|
+
tmp-write + rename. No locking (unreliable over NFS; last-write-wins
|
|
41
|
+
on same SHA is correct because archive bytes are deterministic).
|
|
42
|
+
- **`RedisBackend`** — each ref stored as a Redis hash (field-per-file)
|
|
43
|
+
keyed under `<prefix>:main:<sha>` / `<prefix>:pr:<branch>:<sha>`.
|
|
44
|
+
`redis` gem is an OPTIONAL dependency — users add it to their own
|
|
45
|
+
Gemfile. A missing gem logs a clear warning and falls back to a cold
|
|
46
|
+
run instead of crashing the test suite.
|
|
47
|
+
|
|
48
|
+
## Layout
|
|
49
|
+
|
|
50
|
+
### S3 / LocalFS (archive-per-ref)
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
<root>/main/<sha>/[<test_suite_id>/]cache.tar.gz
|
|
54
|
+
<root>/pr/<branch>/<sha>/[<test_suite_id>/]cache.tar.gz
|
|
55
|
+
<root>/pr/<branch>/branch_refs.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(`<root>` is `s3://<bucket>/<prefix>` for S3, a local/NFS path for
|
|
59
|
+
LocalFs.) Each `cache.tar.gz` packs `last_run.json` + the `<run_id>/`
|
|
60
|
+
directory (the 15-file local layout documented in
|
|
61
|
+
`USER_FACING_SURFACE.md` §6). Upload = 1 PUT; download = 1 GET. Local
|
|
62
|
+
disk layout is unchanged after unpack — external tooling that walks
|
|
63
|
+
`rspec_tracer_cache/` still sees the full 15-file breakdown.
|
|
64
|
+
|
|
65
|
+
### Redis (hash-per-ref)
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
<prefix>:main:<sha>[:<test_suite_id>] -> HASH
|
|
69
|
+
<prefix>:pr:<branch>:<sha>[:<test_suite_id>] -> HASH
|
|
70
|
+
<prefix>:pr:<branch>:branch_refs -> STRING (JSON)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Hash fields: `_timestamp` (epoch), `last_run.json`, and one field per
|
|
74
|
+
file in the 15-file layout (`<run_id>/<file>.json`). Keeps Redis-native
|
|
75
|
+
inspection (`HGETALL`, `HKEYS`) usable without extracting an archive.
|
|
76
|
+
|
|
77
|
+
## Tier routing
|
|
78
|
+
|
|
79
|
+
Tier is determined from `$GIT_BRANCH` vs `$GIT_DEFAULT_BRANCH`. PR
|
|
80
|
+
builds write to the PR tier; main builds write to the main tier. PR
|
|
81
|
+
downloads try their own tier first, then fall back to main tier for
|
|
82
|
+
the same ref (catches cherry-picks from main).
|
|
83
|
+
|
|
84
|
+
## User-facing surface
|
|
85
|
+
|
|
86
|
+
The Rakefile (`lib/rspec_tracer/remote_cache/Rakefile`) defines three
|
|
87
|
+
tasks:
|
|
88
|
+
|
|
89
|
+
- `rspec_tracer:remote_cache:download`
|
|
90
|
+
- `rspec_tracer:remote_cache:upload`
|
|
91
|
+
- `rspec_tracer:remote_cache:prune_all` — maintenance task that walks
|
|
92
|
+
every PR branch and drops ones idle longer than
|
|
93
|
+
`cache_retention_pr_branch_ttl`. Intended for a nightly cron.
|
|
94
|
+
|
|
95
|
+
Users load the Rakefile from their own Rakefile:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
spec = Gem::Specification.find_by_name('rspec-tracer')
|
|
99
|
+
load "#{spec.gem_dir}/lib/rspec_tracer/remote_cache/Rakefile"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Env vars: `GIT_DEFAULT_BRANCH` (required), `GIT_BRANCH` (required for
|
|
103
|
+
download/upload; defaults to `GIT_DEFAULT_BRANCH` for prune_all), and
|
|
104
|
+
`TEST_SUITE_ID` (optional; scopes the cache key when set).
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
Shortest path — one `remote_cache_uri` call:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
RSpecTracer.configure do
|
|
112
|
+
remote_cache_uri 's3://my-bucket/rspec-tracer'
|
|
113
|
+
# or: remote_cache_uri 'file:///mnt/shared-cache'
|
|
114
|
+
# or: remote_cache_uri 'redis://redis.internal:6379/0'
|
|
115
|
+
cache_retention_count 100 # keep newest N on main tier
|
|
116
|
+
cache_retention_pr_branch_ttl '14 days' # idle-branch cutoff on pr tier
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Structured form for cases the URI cannot express:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
RSpecTracer.configure do
|
|
124
|
+
remote_cache_backend :s3, bucket: 'my-bucket', prefix: 'rspec-tracer'
|
|
125
|
+
# or:
|
|
126
|
+
remote_cache_backend :local_fs, root: '/mnt/shared-cache'
|
|
127
|
+
# or:
|
|
128
|
+
remote_cache_backend :redis, url: ENV.fetch('REDIS_URL'), prefix: 'rspec-tracer'
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Caveats
|
|
133
|
+
|
|
134
|
+
- **LocalFS over NFS**: cross-node consistency is eventual. A
|
|
135
|
+
download issued by node B immediately after an upload on node A may
|
|
136
|
+
miss; retries converge. Not a backend correctness bug.
|
|
137
|
+
- **Redis memory**: hash-per-ref without gzip compression uses ~2-5x
|
|
138
|
+
more bytes than the S3/LocalFS tar+gzip archive. Fine for realistic
|
|
139
|
+
cache sizes (a few hundred refs × ~100 KB raw); budget Redis memory
|
|
140
|
+
accordingly for very large fleets.
|
|
@@ -1,37 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# User-facing Rake task shim. The canonical loading pattern in a
|
|
4
|
+
# user's own Rakefile (preserved from 1.x, documented in README.md
|
|
5
|
+
# "Configuring CI"):
|
|
6
|
+
#
|
|
7
|
+
# spec = Gem::Specification.find_by_name('rspec-tracer')
|
|
8
|
+
# load "#{spec.gem_dir}/lib/rspec_tracer/remote_cache/Rakefile"
|
|
9
|
+
#
|
|
10
|
+
# This file must remain at the same path and continue to define the
|
|
11
|
+
# same two tasks. The `require 'rspec_tracer'` + `require
|
|
12
|
+
# 'rspec_tracer/remote_cache'` pair lazy-loads the backend tree only
|
|
13
|
+
# when the user actually invokes a cache task.
|
|
14
|
+
|
|
15
|
+
require 'rspec_tracer'
|
|
16
|
+
require 'rspec_tracer/remote_cache'
|
|
17
|
+
|
|
18
|
+
# rubocop:disable Metrics/BlockLength
|
|
3
19
|
namespace :rspec_tracer do
|
|
4
20
|
namespace :remote_cache do
|
|
5
21
|
desc 'Download cache'
|
|
6
22
|
task :download do
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
unless system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
|
|
23
|
+
unless RSpecTracer::RemoteCache::UserTasks.git_repo?
|
|
10
24
|
RSpecTracer.logger.error 'Not a git repository'
|
|
11
|
-
|
|
12
25
|
exit
|
|
13
26
|
end
|
|
14
27
|
|
|
15
|
-
RSpecTracer::RemoteCache::
|
|
28
|
+
RSpecTracer::RemoteCache::UserTasks.download!(
|
|
29
|
+
configuration: RSpecTracer,
|
|
30
|
+
env: ENV
|
|
31
|
+
)
|
|
16
32
|
end
|
|
17
33
|
|
|
18
34
|
desc 'Upload cache'
|
|
19
35
|
task :upload do
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
unless system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
|
|
36
|
+
unless RSpecTracer::RemoteCache::UserTasks.git_repo?
|
|
23
37
|
RSpecTracer.logger.error 'Not a git repository'
|
|
24
|
-
|
|
25
38
|
exit
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
unless ENV['CI'] == 'true' || RSpecTracer.upload_non_ci_reports
|
|
29
42
|
RSpecTracer.logger.warn 'Uploading reports from a non CI environment is disabled'
|
|
30
|
-
|
|
31
43
|
exit
|
|
32
44
|
end
|
|
33
45
|
|
|
34
|
-
RSpecTracer::RemoteCache::
|
|
46
|
+
RSpecTracer::RemoteCache::UserTasks.upload!(
|
|
47
|
+
configuration: RSpecTracer,
|
|
48
|
+
env: ENV
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc 'Prune dead PR branches across the whole remote cache (maintenance task)'
|
|
53
|
+
task :prune_all do
|
|
54
|
+
RSpecTracer::RemoteCache::UserTasks.prune_all!(
|
|
55
|
+
configuration: RSpecTracer,
|
|
56
|
+
env: ENV
|
|
57
|
+
)
|
|
35
58
|
end
|
|
36
59
|
end
|
|
37
60
|
end
|
|
61
|
+
# rubocop:enable Metrics/BlockLength
|