Class: RSpecTracer::Engine

Inherits:
Object
  • Object
show all
Defined in:
lib/rspec_tracer/engine.rb

Overview

Top-level coordinator for the v2 core engine. Wires CoverageAdapter + IOHooks + DeclaredGlobs + NewFileDetector + WholeSuiteInvalidators + LoadedFilesTracker + ExampleRegistry + DependencyGraph + Storage into a single pipeline.

Named ‘Engine` rather than `Tracker` because the `Tracker` namespace is already taken by the sub-module that houses the leaf observers (`Tracker::CoverageAdapter`, `Tracker::IOHooks`, etc.). `RSpecTracer.engine` is the public accessor the RSpec hooks dispatch through during a run.

Lifecycle (driven by RSpec hooks in ‘lib/rspec_tracer.rb`):

engine = Engine.new(configuration: RSpecTracer)
engine.setup                       # install hooks, load cache,
                                   # compute filter decisions
engine.run_example?(id)            # per-example filter (from cache)
engine.register_example(example)   # record metadata + duplicates
engine.example_started             # peek baseline + open bucket
  # ... example body runs, IOHooks record into bucket ...
engine.example_finished(id)        # diff coverage, attribute, close
engine.on_example_{passed,failed,pending,skipped}(id, result)
engine.finalize                    # persist snapshot + coverage

Per-example coverage delta map: peek baseline at example_started, peek again at example_finished, store the per-line strength delta

under ‘@examples_coverage[file_path]`. Reporters

CoverageJsonReporter consumes the cumulative coverage at finalize via Tracker::CoverageAdapter#peek_unfiltered + the engine’s ‘merge_skipped_coverage` algorithm.

Cache parity: ‘finalize` builds a Snapshot with file-name-keyed dependency / reverse_dependency / all_files maps (matching the 1.x on-disk convention - root-stripped file names with a leading “/”) and hands it to Storage::JsonBackend. The 2.0 schema bump adds `boot_set`; everything else mirrors the 1.x cache layout byte-for-byte. rubocop:disable Metrics/ClassLength

Constant Summary collapse

EXAMPLE_RUN_REASON =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Internal constant.

{
  explicit_run: 'Explicit run',
  no_cache: 'No cache',
  interrupted: 'Interrupted previously',
  flaky_example: 'Flaky example',
  failed_example: 'Failed previously',
  pending_example: 'Pending previously',
  files_changed: 'Files changed',
  whole_suite_invalidator: 'Whole-suite invalidator changed',
  env_changed: 'Environment changed'
}.freeze
FILTER_REASON_STRINGS =

Map from Filter#select reasons to the legacy-shaped strings users see in test output (“foo (Files changed)”). Keeps the user surface unchanged under v2.

{
  whole_suite_invalidator: EXAMPLE_RUN_REASON[:whole_suite_invalidator],
  interrupted: EXAMPLE_RUN_REASON[:interrupted],
  flaky_example: EXAMPLE_RUN_REASON[:flaky_example],
  failed_example: EXAMPLE_RUN_REASON[:failed_example],
  pending_example: EXAMPLE_RUN_REASON[:pending_example],
  no_cache: EXAMPLE_RUN_REASON[:no_cache],
  files_changed: EXAMPLE_RUN_REASON[:files_changed],
  env_changed: EXAMPLE_RUN_REASON[:env_changed]
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration: RSpecTracer) ⇒ Engine

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/rspec_tracer/engine.rb', line 104

def initialize(configuration: RSpecTracer)
  @configuration = configuration
  @filtered_examples = {}
  @all_examples = {}
  @duplicate_examples = {}
  @examples_coverage = {}
  @all_files = {}
  @tracks_files = Hash.new { |h, id| h[id] = Set.new } # id => Set<abs_path>
  @tracks_env = Hash.new { |h, id| h[id] = Set.new }   # id => Set<env_name>
  @tracked_env_names = Set.new
  @config_tracked_env_names = Set.new # config-level subset (post-expansion)
  @previous_snapshot = nil
  @run_id = nil
  @before_peek = nil
end

Instance Attribute Details

#all_examplesObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def all_examples
  @all_examples
end

#all_filesObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def all_files
  @all_files
end

#coverage_adapterObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def coverage_adapter
  @coverage_adapter
end

#declared_globsObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def declared_globs
  @declared_globs
end

#duplicate_examplesObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def duplicate_examples
  @duplicate_examples
end

#env_snapshotObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def env_snapshot
  @env_snapshot
end

#examples_coverageObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def examples_coverage
  @examples_coverage
end

#graphObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def graph
  @graph
end

#loaded_files_trackerObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def loaded_files_tracker
  @loaded_files_tracker
end

#new_file_detectorObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def new_file_detector
  @new_file_detector
end

#registryObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def registry
  @registry
end

#storage_backendObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def storage_backend
  @storage_backend
end

#whole_suite_invalidatorsObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal attribute.



97
98
99
# File 'lib/rspec_tracer/engine.rb', line 97

def whole_suite_invalidators
  @whole_suite_invalidators
end

Instance Method Details

#apply_env_filter_decisionsObject

Called from RunnerHook AFTER the filter-decision pre-walk has populated ‘@tracks_env` / `@tracked_env_names` for every example. Compares each declared env key against the previous snapshot’s ‘env_snapshot` via Tracker::EnvSnapshot; marks any example whose tracked-env set intersects the invalidated set as re-runnable. Strictly additive vs other filter reasons - if the example was already in `@filtered_examples` for a stronger reason (files_changed / whole_suite_invalidator / failed_example / …), env_changed does NOT overwrite.

Config-level path: when an invalidated key intersects ‘@config_tracked_env_names` (the post-expansion config-level set), every previously-seen example re-runs - mirrors the `track_files` “declared globs attach to every example” semantics. New examples (not in @previous_snapshot.all_examples) already run via the no_cache path; no special-casing needed.



225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/rspec_tracer/engine.rb', line 225

def apply_env_filter_decisions
  return self if @previous_snapshot.nil?
  return self if @tracked_env_names.empty?

  invalidated = @env_snapshot.invalidated_keys(
    @previous_snapshot.env_snapshot, @tracked_env_names
  )
  return self if invalidated.empty?

  reason = FILTER_REASON_STRINGS.fetch(:env_changed)
  mark_all_prev_examples(reason) if invalidated.intersect?(@config_tracked_env_names)
  mark_per_example_intersections(invalidated, reason)
  self
end

#deregister_duplicate_examplesObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



242
243
244
245
246
247
248
# File 'lib/rspec_tracer/engine.rb', line 242

def deregister_duplicate_examples
  @duplicate_examples.select! { |_, entries| entries.count > 1 }
  return if @duplicate_examples.empty?

  @all_examples.reject! { |id, _| @duplicate_examples.key?(id) }
  self
end

#example_finished(example_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/rspec_tracer/engine.rb', line 265

def example_finished(example_id)
  after_peek = @coverage_adapter.peek
  record_coverage_delta(example_id, @before_peek, after_peek)
  io_inputs = @current_bucket.values
  rails_inputs = @current_rails_bucket ? @current_rails_bucket.values : []
  RSpecTracer::Tracker::IOHooks.clear_bucket
  clear_rails_bucket

  transitive_inputs = @loaded_files_tracker.loaded_set_inputs |
    @loaded_files_tracker.stop_example(example_id)
  coverage_inputs = @coverage_adapter.compute_diff(@before_peek, after_peek)
  declared_inputs = @declared_globs.walk
  tracks_inputs = per_example_tracks_inputs(example_id)
  attribute_to_example(
    example_id,
    coverage_inputs | transitive_inputs | io_inputs.to_set |
      rails_inputs.to_set | declared_inputs | tracks_inputs
  )

  @before_peek = nil
  @current_bucket = nil
  @current_rails_bucket = nil
  self
end

#example_startedObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



254
255
256
257
258
259
260
261
# File 'lib/rspec_tracer/engine.rb', line 254

def example_started
  @before_peek = @coverage_adapter.peek
  @current_bucket = {}
  @current_rails_bucket = {}
  RSpecTracer::Tracker::IOHooks.set_bucket(@current_bucket)
  set_rails_bucket(@current_rails_bucket)
  self
end

#filtered_example_idsObject

— accessors used by specs ———————————



371
372
373
# File 'lib/rspec_tracer/engine.rb', line 371

def filtered_example_ids
  @filtered_examples.keys
end

#finalizeObject

— finalize ————————————————



332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/rspec_tracer/engine.rb', line 332

def finalize
  @registry.all_example_ids.each do |id|
    next if @registry.status_of(id)

    @registry.update_status(id, :interrupted)
  end

  snapshot = build_snapshot
  @storage_backend.save_graph(snapshot, schema_version: RSpecTracer::Storage::Schema::CURRENT)
  uninstall_rails_observers
  snapshot
end

#merge_skipped_coverage(skipped_ids, previous_examples_coverage = nil) ⇒ Object

For every previously-skipped example id, accumulate per-line coverage strengths from the previous run’s per-example coverage map into the missed_coverage return value. Deleted files / missing entries are skipped silently. Consumed by Reporters::CoverageJsonReporter at finalize time so coverage.json carries forward the contribution of skipped examples.

Returns Hash[file_path => Hash[line_number => cumulative_strength]].



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/rspec_tracer/engine.rb', line 353

def merge_skipped_coverage(skipped_ids, previous_examples_coverage = nil)
  source = previous_examples_coverage || @previous_snapshot&.examples_coverage || {}
  missed = Hash.new { |h, f| h[f] = Hash.new(0) }

  skipped_ids.each do |example_id|
    example_coverage = source[example_id]
    next if example_coverage.nil?

    example_coverage.each do |file_path, line_coverage|
      accumulate_line_coverage(missed[file_path], line_coverage)
    end
  end

  missed
end

#on_example_failed(example_id, result) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



311
312
313
314
315
316
317
318
# File 'lib/rspec_tracer/engine.rb', line 311

def on_example_failed(example_id, result)
  return if @duplicate_examples[example_id]&.count.to_i > 1

  status = previously_flaky?(example_id) ? :flaky : :failed
  @registry.update_status(example_id, status)
  record_execution_result(example_id, result)
  self
end

#on_example_passed(example_id, result) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



300
301
302
303
304
305
306
307
# File 'lib/rspec_tracer/engine.rb', line 300

def on_example_passed(example_id, result)
  return if @duplicate_examples[example_id]&.count.to_i > 1

  status = flaky_history?(example_id) ? :flaky : :passed
  @registry.update_status(example_id, status)
  record_execution_result(example_id, result)
  self
end

#on_example_pending(example_id, result) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



322
323
324
325
326
327
328
# File 'lib/rspec_tracer/engine.rb', line 322

def on_example_pending(example_id, result)
  return if @duplicate_examples[example_id]&.count.to_i > 1

  @registry.update_status(example_id, :pending)
  record_execution_result(example_id, result)
  self
end

#on_example_skipped(example_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



292
293
294
295
296
# File 'lib/rspec_tracer/engine.rb', line 292

def on_example_skipped(example_id)
  @registry.register(example_id) unless @registry.registered?(example_id)
  @registry.update_status(example_id, :skipped)
  self
end

#previous_snapshot_loaded?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.

Returns:

  • (Boolean)


377
378
379
# File 'lib/rspec_tracer/engine.rb', line 377

def previous_snapshot_loaded?
  !@previous_snapshot.nil?
end

#register_example(example) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Records one example registration into the engine’s per-run state. Overwrites any prior ‘@all_examples` entry on purpose: on warm runs, `seed_all_examples_from_previous` seeds `@all_examples` with the prior snapshot’s metadata (including the prior ‘:run_reason`), and the RunnerHook’s per-example call here carries this run’s freshly-tagged value. Preserving the seeded entry via ‘||=` would persist the stale prior reason and surface “No cache” in `report.json#run_reason` for examples re-run because they failed / pended / were interrupted / had files change / had env change. Duplicate detection is unaffected: duplicates accumulate in `@duplicate_examples`, and `deregister_duplicate_examples` drops them from `@all_examples` outright.



171
172
173
174
175
176
177
178
# File 'lib/rspec_tracer/engine.rb', line 171

def register_example(example)
  example_id = example[:example_id]
  @registry.register(example_id, metadata: example, identity_hash: example_id)
  @all_examples[example_id] = example
  @duplicate_examples[example_id] ||= []
  @duplicate_examples[example_id] << example
  self
end

#register_tracks(example_id, tracks) ⇒ Object

Per-example tracking DSL hook. Called from RunnerHook with the normalized ‘Set<String>, env: Set<String>` that `RSpec::Metadata.tracks_for(example)` produced. Resolves the file globs against the project root once per distinct glob string (memoized) and unions the matching Inputs into this example’s dependency set. Env names are accumulated into ‘@tracked_env_names` so the finalize snapshot covers every key the run cared about.

Per-example env entries may carry wildcard patterns (‘tracks: { env: ’RAILS_*‘ }`). `EnvMatcher.expand` is the single funnel

  • literals pass through, wildcards expand against the live ENV,

and unsupported syntax raises ArgumentError at this point (RunnerHook Pass 1, before any example body runs). rubocop:disable Metrics/PerceivedComplexity



195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/rspec_tracer/engine.rb', line 195

def register_tracks(example_id, tracks)
  files = tracks[:files] || tracks['files'] || Set.new
  envs = tracks[:env] || tracks['env'] || Set.new

  files.each { |glob| @tracks_files[example_id].merge(resolved_glob_inputs(glob)) } unless files.empty?
  return self if envs.empty?

  expanded = RSpecTracer::Tracker::EnvMatcher.expand(envs.map(&:to_s))
  @tracks_env[example_id].merge(expanded)
  @tracked_env_names.merge(expanded)
  self
end

#run_example?(example_id) ⇒ Boolean

— filter-phase surface (mirrors legacy Runner) ————–

Returns:

  • (Boolean)


142
143
144
145
146
147
# File 'lib/rspec_tracer/engine.rb', line 142

def run_example?(example_id)
  return true if @configuration.run_all_examples

  previously_seen = @previous_snapshot&.all_examples&.key?(example_id)
  !previously_seen || @filtered_examples.key?(example_id)
end

#run_example_reason(example_id) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



151
152
153
154
155
# File 'lib/rspec_tracer/engine.rb', line 151

def run_example_reason(example_id)
  return EXAMPLE_RUN_REASON[:explicit_run] if @configuration.run_all_examples

  @filtered_examples[example_id] || EXAMPLE_RUN_REASON[:no_cache]
end

#setupObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Internal method on the tracer pipeline.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/rspec_tracer/engine.rb', line 122

def setup
  @configuration.freeze_declared_globs!

  build_observers
  install_io_hooks
  install_rails_observers
  ensure_coverage_started

  @loaded_files_tracker.capture_boot_set!
  @declared_globs.walk

  @previous_snapshot = load_previous_snapshot
  seed_state_from_previous(@previous_snapshot) if @previous_snapshot
  register_config_tracked_env_names
  compute_filter_decisions
  self
end