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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/md5'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module RSpecTracer
|
|
7
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module Tracker
|
|
10
|
+
# Observer #5 in the 2.0 tracker pipeline (WholeSuiteInvalidators
|
|
11
|
+
# is #4). Owns the per-run snapshot of environment-variable
|
|
12
|
+
# values that the per-example `tracks: { env: ... }` metadata declares.
|
|
13
|
+
#
|
|
14
|
+
# Watch list is caller-provided - unlike WholeSuiteInvalidators,
|
|
15
|
+
# which hard-codes Gemfile.lock / .ruby-version / .rspec-tracer,
|
|
16
|
+
# env names come from user metadata and are only known once
|
|
17
|
+
# RSpec has discovered every example. The engine unions every
|
|
18
|
+
# example's declared env names and hands the set to
|
|
19
|
+
# `digest_snapshot` at finalize time.
|
|
20
|
+
#
|
|
21
|
+
# Digest algorithm: `Digest::MD5.hexdigest(ENV[name].to_s)`.
|
|
22
|
+
# Missing env var digests the same as empty string - absent is a
|
|
23
|
+
# valid state. ENV is process-stable within a single run (we
|
|
24
|
+
# never mutate it mid-run), so one snapshot per run is enough.
|
|
25
|
+
#
|
|
26
|
+
# Graceful degradation: the observer never raises on malformed
|
|
27
|
+
# input. Non-String env names are coerced via #to_s; nil / empty
|
|
28
|
+
# names are skipped silently.
|
|
29
|
+
class EnvSnapshot
|
|
30
|
+
# ENV source is injectable for testability - specs can pass a
|
|
31
|
+
# Hash double without poking the real process env.
|
|
32
|
+
def initialize(env: ::ENV)
|
|
33
|
+
@env = env
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Snapshot the current ENV values for `names`. Returns
|
|
37
|
+
# `Hash[name => md5_hex]`. Idempotent; callers may invoke it
|
|
38
|
+
# repeatedly without side effects.
|
|
39
|
+
def digest_snapshot(names)
|
|
40
|
+
snapshot = {}
|
|
41
|
+
names.each do |raw|
|
|
42
|
+
key = raw.to_s
|
|
43
|
+
next if key.empty?
|
|
44
|
+
|
|
45
|
+
snapshot[key] = Digest::MD5.hexdigest(@env[key].to_s)
|
|
46
|
+
end
|
|
47
|
+
snapshot
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return the set of env names whose digest differs between
|
|
51
|
+
# `previous_snapshot` and the current ENV. Keys in either
|
|
52
|
+
# snapshot participate:
|
|
53
|
+
#
|
|
54
|
+
# - present in both, digests differ => invalidated
|
|
55
|
+
# - present in previous, absent now => invalidated
|
|
56
|
+
# - absent previously, present now => invalidated
|
|
57
|
+
#
|
|
58
|
+
# `previous_snapshot` may be nil (first run, no cache); in that
|
|
59
|
+
# case every currently-tracked key is considered invalidated so
|
|
60
|
+
# the examples declaring them get a mandatory cold re-run on
|
|
61
|
+
# first sighting. `names` scopes the check: only keys in
|
|
62
|
+
# `names` are considered, which means one example adding a new
|
|
63
|
+
# `tracks: env: ...` doesn't cascade-invalidate examples that
|
|
64
|
+
# declared different env keys previously.
|
|
65
|
+
def invalidated_keys(previous_snapshot, names)
|
|
66
|
+
previous = previous_snapshot || {}
|
|
67
|
+
current = digest_snapshot(names)
|
|
68
|
+
invalidated = Set.new
|
|
69
|
+
|
|
70
|
+
current.each do |key, digest|
|
|
71
|
+
invalidated << key if digest != previous[key]
|
|
72
|
+
end
|
|
73
|
+
invalidated
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Tracker
|
|
9
|
+
# Per-example status + metadata registry. The Filter consults the
|
|
10
|
+
# registry to decide which examples always re-run (failed / flaky
|
|
11
|
+
# / pending / interrupted); the registry also owns duplicate
|
|
12
|
+
# detection via RSpec's identity-hash surface (the same hash 1.x
|
|
13
|
+
# uses for `fail_on_duplicates`).
|
|
14
|
+
#
|
|
15
|
+
# This module owns the data structure only. `update_status` is
|
|
16
|
+
# called by the RSpec integration hooks on example-finished events
|
|
17
|
+
# and by signal handlers on interruption; `Tracker.setup` passes
|
|
18
|
+
# the registry instance through. The registry itself has no
|
|
19
|
+
# opinion about where status comes from.
|
|
20
|
+
#
|
|
21
|
+
# Statuses
|
|
22
|
+
# --------
|
|
23
|
+
# :passed - completed cleanly
|
|
24
|
+
# :failed - example assertion failed
|
|
25
|
+
# :pending - RSpec `pending`
|
|
26
|
+
# :interrupted - RSpec was killed mid-example (SIGINT / SIGTERM)
|
|
27
|
+
# :flaky - passed this run but previously failed
|
|
28
|
+
# (detected via retry semantics)
|
|
29
|
+
# :skipped - skipped via `skip` or `:skip` metadata; tracked
|
|
30
|
+
# for coverage attribution but NOT auto-re-run
|
|
31
|
+
#
|
|
32
|
+
# `always_re_run_ids` returns the union of {failed, flaky,
|
|
33
|
+
# pending, interrupted} - the 1.x invariant that examples with
|
|
34
|
+
# those statuses run on every subsequent suite regardless of
|
|
35
|
+
# whether their input files changed. `:skipped` is deliberately
|
|
36
|
+
# excluded (matches 1.x `skipped_examples.json` which is written
|
|
37
|
+
# but not added to the re-run set).
|
|
38
|
+
class ExampleRegistry
|
|
39
|
+
# Internal constant.
|
|
40
|
+
# @api private
|
|
41
|
+
STATUSES = %i[passed failed pending interrupted flaky skipped].freeze
|
|
42
|
+
# Internal constant.
|
|
43
|
+
# @api private
|
|
44
|
+
ALWAYS_RE_RUN_STATUSES = %i[failed flaky pending interrupted].freeze
|
|
45
|
+
|
|
46
|
+
# Internal method on the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def initialize
|
|
49
|
+
@examples = {}
|
|
50
|
+
@identity_index = {}
|
|
51
|
+
@duplicates = {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Registers an example id with optional metadata (opaque Hash
|
|
55
|
+
# passed through to Snapshot.all_examples) and optional
|
|
56
|
+
# identity_hash for duplicate detection. First call wins for
|
|
57
|
+
# the identity_hash binding; subsequent calls with the same
|
|
58
|
+
# identity_hash but a different example_id accumulate into
|
|
59
|
+
# `@duplicates` and do not re-bind.
|
|
60
|
+
def register(example_id, metadata: {}, identity_hash: nil)
|
|
61
|
+
@examples[example_id] ||= { metadata: metadata.dup, status: nil }
|
|
62
|
+
track_identity(example_id, identity_hash) if identity_hash
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Internal method on the tracer pipeline.
|
|
67
|
+
# @api private
|
|
68
|
+
def update_status(example_id, status)
|
|
69
|
+
raise ArgumentError, "unknown status: #{status.inspect}" unless STATUSES.include?(status)
|
|
70
|
+
raise ArgumentError, "example not registered: #{example_id.inspect}" unless @examples.key?(example_id)
|
|
71
|
+
|
|
72
|
+
@examples[example_id][:status] = status
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Internal method on the tracer pipeline.
|
|
77
|
+
# @api private
|
|
78
|
+
def status_of(example_id)
|
|
79
|
+
@examples[example_id]&.dig(:status)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Internal method on the tracer pipeline.
|
|
83
|
+
# @api private
|
|
84
|
+
def metadata_of(example_id)
|
|
85
|
+
entry = @examples[example_id]
|
|
86
|
+
return nil if entry.nil?
|
|
87
|
+
|
|
88
|
+
entry[:metadata].dup
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Internal method on the tracer pipeline.
|
|
92
|
+
# @api private
|
|
93
|
+
def registered?(example_id)
|
|
94
|
+
@examples.key?(example_id)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Internal method on the tracer pipeline.
|
|
98
|
+
# @api private
|
|
99
|
+
def all_example_ids
|
|
100
|
+
@examples.keys.to_set
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Internal method on the tracer pipeline.
|
|
104
|
+
# @api private
|
|
105
|
+
def size
|
|
106
|
+
@examples.size
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Internal method on the tracer pipeline.
|
|
110
|
+
# @api private
|
|
111
|
+
def ids_with_status(status)
|
|
112
|
+
@examples.each_with_object(Set.new) do |(id, entry), acc|
|
|
113
|
+
acc << id if entry[:status] == status
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Internal method on the tracer pipeline.
|
|
118
|
+
# @api private
|
|
119
|
+
def always_re_run_ids
|
|
120
|
+
@examples.each_with_object(Set.new) do |(id, entry), acc|
|
|
121
|
+
acc << id if ALWAYS_RE_RUN_STATUSES.include?(entry[:status])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Hash[identity_hash => Array<example_id>] of every collision
|
|
126
|
+
# observed. The array always has >= 2 entries (the first
|
|
127
|
+
# registrant plus every colliding follower).
|
|
128
|
+
def duplicates
|
|
129
|
+
@duplicates.transform_values(&:dup)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Internal method on the tracer pipeline.
|
|
133
|
+
# @api private
|
|
134
|
+
def duplicate?(identity_hash)
|
|
135
|
+
@duplicates.key?(identity_hash)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Internal method on the tracer pipeline.
|
|
141
|
+
# @api private
|
|
142
|
+
def track_identity(example_id, identity_hash)
|
|
143
|
+
existing = @identity_index[identity_hash]
|
|
144
|
+
if existing.nil?
|
|
145
|
+
@identity_index[identity_hash] = example_id
|
|
146
|
+
elsif existing != example_id
|
|
147
|
+
bucket = (@duplicates[identity_hash] ||= [existing])
|
|
148
|
+
bucket << example_id unless bucket.include?(example_id)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Tracker
|
|
9
|
+
# Process-wide SHA256 file-digest cache. Keyed on absolute path
|
|
10
|
+
# with [mtime_ns, size] as the freshness check — when the file's
|
|
11
|
+
# stat timestamps + size match the cached values, the prior
|
|
12
|
+
# digest is reused; otherwise the digest is recomputed and the
|
|
13
|
+
# cache entry refreshed.
|
|
14
|
+
#
|
|
15
|
+
# The cache is populated lazily and never evicted within a
|
|
16
|
+
# process. Tracer runs are bounded (one rspec invocation per
|
|
17
|
+
# process), so unbounded growth is bounded by the project's
|
|
18
|
+
# dependency-graph size (typically << 10k unique files).
|
|
19
|
+
#
|
|
20
|
+
# SystemCallError on stat / digest is treated as "file gone /
|
|
21
|
+
# unreadable" and returns nil — same graceful-degradation
|
|
22
|
+
# contract every call site already implemented locally before
|
|
23
|
+
# this module centralized them.
|
|
24
|
+
#
|
|
25
|
+
# Thread-safety: rspec example execution is single-threaded
|
|
26
|
+
# (cold-subprocess contract); the cache uses a plain Hash without
|
|
27
|
+
# locking. Multi-threaded callers are unsupported (consistent
|
|
28
|
+
# with the rest of the tracer's threading model).
|
|
29
|
+
module FileDigest
|
|
30
|
+
class << self
|
|
31
|
+
# Returns the SHA256 hex digest of `path`, or nil when the
|
|
32
|
+
# file can't be stat'd (missing / permission denied / racey
|
|
33
|
+
# delete). Subsequent calls for the same path with unchanged
|
|
34
|
+
# stat skip the digest computation entirely.
|
|
35
|
+
def compute(path)
|
|
36
|
+
stat = File.stat(path)
|
|
37
|
+
key = (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec
|
|
38
|
+
size = stat.size
|
|
39
|
+
cache = (@cache ||= {})
|
|
40
|
+
cached = cache[path]
|
|
41
|
+
return cached[2] if cached && cached[0] == key && cached[1] == size
|
|
42
|
+
|
|
43
|
+
digest = Digest::SHA256.file(path).hexdigest
|
|
44
|
+
cache[path] = [key, size, digest]
|
|
45
|
+
digest
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Drop the cache. Tests that mutate file content in-place at
|
|
51
|
+
# nanosecond granularity (and so might collide on the
|
|
52
|
+
# mtime+size key) should call this between scenarios. Normal
|
|
53
|
+
# tracer runs never need it — the cache lives for one
|
|
54
|
+
# rspec invocation only.
|
|
55
|
+
def reset!
|
|
56
|
+
@cache = {}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Tracker
|
|
9
|
+
# Pure function that computes the filter result for a suite run:
|
|
10
|
+
# given the previous dependency graph, the current change set,
|
|
11
|
+
# the example registry, the whole-suite invalidation signal, and
|
|
12
|
+
# the full list of example ids that exist this run, return the
|
|
13
|
+
# Hash of {example_id => reason} for every example that must run.
|
|
14
|
+
#
|
|
15
|
+
# Stateless - every input is passed explicitly so the function is
|
|
16
|
+
# trivial to unit-test, property-test, and use as a behavior-
|
|
17
|
+
# parity target against 1.x's runner.rb.
|
|
18
|
+
#
|
|
19
|
+
# Precedence (first-match wins, highest to lowest):
|
|
20
|
+
#
|
|
21
|
+
# 1. :whole_suite_invalidator - any watched file changed
|
|
22
|
+
# (Gemfile.lock, .ruby-version, .rspec-tracer, gem version)
|
|
23
|
+
# => every id in all_example_ids runs, no further checks.
|
|
24
|
+
# 2. :interrupted - example was killed mid-run
|
|
25
|
+
# on the previous run; always re-run.
|
|
26
|
+
# 3. :flaky_example - example was marked flaky; always
|
|
27
|
+
# re-run to re-observe.
|
|
28
|
+
# 4. :failed_example - previous failure; always re-run.
|
|
29
|
+
# 5. :pending_example - previous pending; always re-run.
|
|
30
|
+
# 6. :no_cache - example exists this run but
|
|
31
|
+
# isn't in the graph (new example, never observed).
|
|
32
|
+
# 7. :files_changed - example's cached dependency
|
|
33
|
+
# set intersects the change_set.
|
|
34
|
+
#
|
|
35
|
+
# :skipped is deliberately not a reason - 1.x tracks skipped
|
|
36
|
+
# examples for coverage attribution but does not re-run them.
|
|
37
|
+
# ExampleRegistry#always_re_run_ids excludes :skipped; this
|
|
38
|
+
# function mirrors that by iterating only the four re-run
|
|
39
|
+
# statuses.
|
|
40
|
+
module Filter
|
|
41
|
+
# Internal constant.
|
|
42
|
+
# @api private
|
|
43
|
+
REASONS = %i[
|
|
44
|
+
whole_suite_invalidator
|
|
45
|
+
interrupted
|
|
46
|
+
flaky_example
|
|
47
|
+
failed_example
|
|
48
|
+
pending_example
|
|
49
|
+
no_cache
|
|
50
|
+
files_changed
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Map from registry status to filter reason. Keyed in
|
|
54
|
+
# precedence order so iteration preserves the precedence when
|
|
55
|
+
# first-match wins in #assign_once.
|
|
56
|
+
STATUS_TO_REASON = {
|
|
57
|
+
interrupted: :interrupted,
|
|
58
|
+
flaky: :flaky_example,
|
|
59
|
+
failed: :failed_example,
|
|
60
|
+
pending: :pending_example
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# Internal helper for the tracer pipeline.
|
|
64
|
+
# @api private
|
|
65
|
+
def self.select(graph:, change_set:, registry:, whole_suite_invalidated:, all_example_ids:)
|
|
66
|
+
ids = all_example_ids.to_set
|
|
67
|
+
return whole_suite_result(ids) if whole_suite_invalidated
|
|
68
|
+
|
|
69
|
+
result = {}
|
|
70
|
+
add_always_re_run(result, registry, ids)
|
|
71
|
+
add_new_examples(result, graph, ids)
|
|
72
|
+
add_files_changed(result, graph, change_set, ids)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Convenience: Set<example_id> view of a select() result.
|
|
77
|
+
def self.to_run_set(result)
|
|
78
|
+
result.keys.to_set
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Internal helper for the tracer pipeline.
|
|
82
|
+
# @api private
|
|
83
|
+
def self.whole_suite_result(ids)
|
|
84
|
+
ids.to_h { |id| [id, :whole_suite_invalidator] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Internal helper for the tracer pipeline.
|
|
88
|
+
# @api private
|
|
89
|
+
def self.add_always_re_run(result, registry, ids)
|
|
90
|
+
STATUS_TO_REASON.each do |status, reason|
|
|
91
|
+
registry.ids_with_status(status).each do |id|
|
|
92
|
+
next unless ids.include?(id)
|
|
93
|
+
|
|
94
|
+
assign_once(result, id, reason)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Internal helper for the tracer pipeline.
|
|
100
|
+
# @api private
|
|
101
|
+
def self.add_new_examples(result, graph, ids)
|
|
102
|
+
known = graph.example_ids.to_set
|
|
103
|
+
ids.each do |id|
|
|
104
|
+
next if known.include?(id)
|
|
105
|
+
|
|
106
|
+
assign_once(result, id, :no_cache)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Internal helper for the tracer pipeline.
|
|
111
|
+
# @api private
|
|
112
|
+
def self.add_files_changed(result, graph, change_set, ids)
|
|
113
|
+
graph.examples_depending_on(change_set).each do |id|
|
|
114
|
+
next unless ids.include?(id)
|
|
115
|
+
|
|
116
|
+
assign_once(result, id, :files_changed)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Internal helper for the tracer pipeline.
|
|
121
|
+
# @api private
|
|
122
|
+
def self.assign_once(result, id, reason)
|
|
123
|
+
result[id] ||= reason
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Tracker
|
|
9
|
+
# Closed taxonomy of input sources. The kinds correspond to the
|
|
10
|
+
# observation surface: `:ruby` (Coverage-observed source), `:data`
|
|
11
|
+
# (I/O hooks), `:declared` / `:lockfile` (declared globs), `:env`
|
|
12
|
+
# (env_snapshot), `:notification` (Rails notifications),
|
|
13
|
+
# `:template` / `:schema` (Rails subscribers). Adding a new kind
|
|
14
|
+
# is a one-line change here plus a test; shrinking the set is a
|
|
15
|
+
# schema_version bump.
|
|
16
|
+
ALLOWED_INPUT_KINDS = %i[
|
|
17
|
+
ruby template data schema lockfile declared env notification
|
|
18
|
+
].to_set.freeze
|
|
19
|
+
|
|
20
|
+
# Value object representing a single input to a test. A test is a
|
|
21
|
+
# pure function of its inputs (see ARCHITECTURE.md); every input
|
|
22
|
+
# the tracker observes becomes one Input.
|
|
23
|
+
#
|
|
24
|
+
# Construct via Input.for_file - it expands the absolute path,
|
|
25
|
+
# validates the kind, and precomputes the stable identity string.
|
|
26
|
+
# The returned struct is frozen.
|
|
27
|
+
#
|
|
28
|
+
# Equality, hash, and eql? key on :identity only - two Inputs with
|
|
29
|
+
# the same identity but different digests are considered the same
|
|
30
|
+
# input at different points in time. Freshness lives on :digest
|
|
31
|
+
# and is queried via #stale?.
|
|
32
|
+
#
|
|
33
|
+
# Digest algorithm is caller-chosen (the observer owns content
|
|
34
|
+
# hashing); 2.0's default is SHA256 hex (see CoverageAdapter).
|
|
35
|
+
# Changing the algorithm is a storage schema_version bump.
|
|
36
|
+
#
|
|
37
|
+
# Methods are defined on the reopened class body (not the
|
|
38
|
+
# Struct.new block) so mutant can introspect them via
|
|
39
|
+
# Method#source_location - block-scoped defs live on an anonymous
|
|
40
|
+
# singleton and mutant reports Subjects: 0 for them.
|
|
41
|
+
Input = Struct.new(:path, :kind, :digest, :identity, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
# Internal Input — see {RSpecTracer} for the user-facing surface.
|
|
44
|
+
# @api private
|
|
45
|
+
class Input
|
|
46
|
+
# Internal helper for the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def self.for_file(path:, kind:, digest:, root:)
|
|
49
|
+
unless ALLOWED_INPUT_KINDS.include?(kind)
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"invalid Input kind: #{kind.inspect}; " \
|
|
52
|
+
"allowed: #{ALLOWED_INPUT_KINDS.to_a.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
abs_path = File.expand_path(path)
|
|
56
|
+
identity = "#{kind}:#{relative_path(abs_path, root)}"
|
|
57
|
+
|
|
58
|
+
new(path: abs_path, kind: kind, digest: digest, identity: identity).freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Strip `root/` prefix from an absolute path. When the path
|
|
62
|
+
# escapes the root (absolute symlink target, vendored gem under a
|
|
63
|
+
# different tree, etc.) we fall back to the full absolute path so
|
|
64
|
+
# identity stays unique - the deterministic rule is "same path
|
|
65
|
+
# under same root => same identity", nothing stronger.
|
|
66
|
+
def self.relative_path(abs_path, root)
|
|
67
|
+
root_abs = File.expand_path(root)
|
|
68
|
+
prefix = "#{root_abs}/"
|
|
69
|
+
|
|
70
|
+
return abs_path unless abs_path.start_with?(prefix)
|
|
71
|
+
|
|
72
|
+
abs_path[prefix.length..]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `!=` handles every case correctly: nil-vs-nil is NOT stale
|
|
76
|
+
# (absent stayed absent), present-vs-nil and nil-vs-present are
|
|
77
|
+
# stale (file appeared/disappeared), and digest-vs-digest is
|
|
78
|
+
# stale iff the content changed.
|
|
79
|
+
def stale?(current_digest)
|
|
80
|
+
current_digest != digest
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Inputs are value-typed and not meant to be subclassed;
|
|
84
|
+
# `instance_of?` is precise where `is_a?` would let a subclass
|
|
85
|
+
# with matching identity compare equal.
|
|
86
|
+
def ==(other)
|
|
87
|
+
other.instance_of?(self.class) && identity == other.identity
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
alias eql? ==
|
|
91
|
+
|
|
92
|
+
# Internal method on the tracer pipeline.
|
|
93
|
+
# @api private
|
|
94
|
+
def hash
|
|
95
|
+
identity.hash
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Internal IOHooks — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module IOHooks
|
|
10
|
+
# Prepended onto File.singleton_class. Each method records the
|
|
11
|
+
# path (IOHooks.record fast-rejects outside a bucketed example
|
|
12
|
+
# and swallows any error) then forwards every argument + block
|
|
13
|
+
# to super via `(...)`.
|
|
14
|
+
#
|
|
15
|
+
# The `if Thread.current[BUCKET_KEY]` guard is the hot-path
|
|
16
|
+
# cut: FileReads sits in every File.singleton_class read on
|
|
17
|
+
# the entire process for the life of the run, even between
|
|
18
|
+
# examples and during boot. Without the guard each File.read
|
|
19
|
+
# paid 2 method-dispatch frames + IOHooks._record's
|
|
20
|
+
# `@root_prefix` nil-check before the inner bucket check could
|
|
21
|
+
# bail. With the guard, the bucket check happens at FileReads
|
|
22
|
+
# so the reject path skips IOHooks.record entirely. Cuts the
|
|
23
|
+
# M2 Max reject overhead from ~530 ns/call to ~300 ns/call.
|
|
24
|
+
module FileReads
|
|
25
|
+
# Internal method on the tracer pipeline.
|
|
26
|
+
# @api private
|
|
27
|
+
def read(path, ...)
|
|
28
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Internal method on the tracer pipeline.
|
|
33
|
+
# @api private
|
|
34
|
+
def binread(path, ...)
|
|
35
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Internal method on the tracer pipeline.
|
|
40
|
+
# @api private
|
|
41
|
+
def readlines(path, ...)
|
|
42
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Internal method on the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def open(path, ...)
|
|
49
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Internal IOHooks — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module IOHooks
|
|
10
|
+
# Prepended onto IO.singleton_class. IO.read is the only method
|
|
11
|
+
# the brief asks for here - File.read covers the bulk of the
|
|
12
|
+
# use cases; IO.read exists mostly for compatibility with older
|
|
13
|
+
# code paths and third-party libraries that reach through IO.
|
|
14
|
+
module IOReads
|
|
15
|
+
# Internal method on the tracer pipeline.
|
|
16
|
+
# @api private
|
|
17
|
+
def read(path, ...)
|
|
18
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Internal IOHooks — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module IOHooks
|
|
10
|
+
# Prepended onto JSON.singleton_class. JSON.load_file is the
|
|
11
|
+
# user-level file-reading entry point; JSON.parse takes a
|
|
12
|
+
# string and is not hooked here.
|
|
13
|
+
module JSONReads
|
|
14
|
+
# Internal method on the tracer pipeline.
|
|
15
|
+
# @api private
|
|
16
|
+
def load_file(path, ...)
|
|
17
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Internal IOHooks — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module IOHooks
|
|
10
|
+
# Prepended onto both Kernel and Kernel.singleton_class so both
|
|
11
|
+
# implicit `load 'x.rb'` (method-lookup via Object's ancestor
|
|
12
|
+
# chain) and explicit `Kernel.load('x.rb')` (singleton dispatch)
|
|
13
|
+
# fire the hook. Records as :ruby - CoverageAdapter also sees
|
|
14
|
+
# these files through the Coverage module's load-path
|
|
15
|
+
# instrumentation; the example registry dedupes the overlap.
|
|
16
|
+
module KernelReads
|
|
17
|
+
# Internal method on the tracer pipeline.
|
|
18
|
+
# @api private
|
|
19
|
+
def load(path, ...)
|
|
20
|
+
IOHooks.record_ruby_load(path) if Thread.current[BUCKET_KEY]
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Tracker — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Tracker
|
|
7
|
+
# Internal IOHooks — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module IOHooks
|
|
10
|
+
# Prepended onto YAML.singleton_class (Psych). Hooks the three
|
|
11
|
+
# load_file-family entry points; Psych's internals eventually
|
|
12
|
+
# call File.read but YAML.load_file is the user-level API that
|
|
13
|
+
# .rspec-tracer filters target.
|
|
14
|
+
module YAMLReads
|
|
15
|
+
# Internal method on the tracer pipeline.
|
|
16
|
+
# @api private
|
|
17
|
+
def load_file(path, ...)
|
|
18
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Internal method on the tracer pipeline.
|
|
23
|
+
# @api private
|
|
24
|
+
def safe_load_file(path, ...)
|
|
25
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Internal method on the tracer pipeline.
|
|
30
|
+
# @api private
|
|
31
|
+
def unsafe_load_file(path, ...)
|
|
32
|
+
IOHooks.record(path) if Thread.current[BUCKET_KEY]
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|