testsift 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +22 -0
- data/README.md +572 -0
- data/bin/testsift +15 -0
- data/lib/testsift/cache/Rakefile +43 -0
- data/lib/testsift/cli/blast_radius.rb +318 -0
- data/lib/testsift/cli/cache_clear.rb +114 -0
- data/lib/testsift/cli/cache_info.rb +106 -0
- data/lib/testsift/cli/doctor.rb +307 -0
- data/lib/testsift/cli/explain.rb +273 -0
- data/lib/testsift/cli/report_open.rb +90 -0
- data/lib/testsift/cli/snapshot_helpers.rb +119 -0
- data/lib/testsift/cli.rb +219 -0
- data/lib/testsift/configuration.rb +1467 -0
- data/lib/testsift/defaults.rb +11 -0
- data/lib/testsift/engine/snapshot_builder.rb +117 -0
- data/lib/testsift/engine.rb +896 -0
- data/lib/testsift/example.rb +188 -0
- data/lib/testsift/filter.rb +103 -0
- data/lib/testsift/line_stub.rb +62 -0
- data/lib/testsift/load_config.rb +15 -0
- data/lib/testsift/load_default_config.rb +46 -0
- data/lib/testsift/load_global_config.rb +33 -0
- data/lib/testsift/load_local_config.rb +17 -0
- data/lib/testsift/logger.rb +59 -0
- data/lib/testsift/rails/README.md +78 -0
- data/lib/testsift/rails/i18n_tracking.rb +137 -0
- data/lib/testsift/rails/notifications.rb +263 -0
- data/lib/testsift/rails/preset.rb +94 -0
- data/lib/testsift/rails/railtie.rb +22 -0
- data/lib/testsift/rails.rb +15 -0
- data/lib/testsift/remote_cache/README.md +140 -0
- data/lib/testsift/remote_cache/Rakefile +61 -0
- data/lib/testsift/remote_cache/archive.rb +137 -0
- data/lib/testsift/remote_cache/backend.rb +73 -0
- data/lib/testsift/remote_cache/git_ancestry.rb +241 -0
- data/lib/testsift/remote_cache/local_fs_backend.rb +439 -0
- data/lib/testsift/remote_cache/redis_backend.rb +554 -0
- data/lib/testsift/remote_cache/s3_backend.rb +712 -0
- data/lib/testsift/remote_cache/user_tasks.rb +436 -0
- data/lib/testsift/remote_cache/validator.rb +50 -0
- data/lib/testsift/remote_cache.rb +22 -0
- data/lib/testsift/reporters/README.md +103 -0
- data/lib/testsift/reporters/base.rb +87 -0
- data/lib/testsift/reporters/coverage_json_reporter.rb +338 -0
- data/lib/testsift/reporters/html/.gitignore +19 -0
- data/lib/testsift/reporters/html/.prettierignore +4 -0
- data/lib/testsift/reporters/html/.prettierrc.json +9 -0
- data/lib/testsift/reporters/html/README.md +80 -0
- data/lib/testsift/reporters/html/dist/assets/index.css +2 -0
- data/lib/testsift/reporters/html/dist/assets/index.js +1 -0
- data/lib/testsift/reporters/html/dist/index.html +24 -0
- data/lib/testsift/reporters/html/eslint.config.js +62 -0
- data/lib/testsift/reporters/html/package-lock.json +4910 -0
- data/lib/testsift/reporters/html/package.json +29 -0
- data/lib/testsift/reporters/html/src/app.jsx +130 -0
- data/lib/testsift/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/testsift/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/testsift/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/testsift/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/testsift/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/testsift/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/testsift/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/testsift/reporters/html/src/index.html +23 -0
- data/lib/testsift/reporters/html/src/main.jsx +37 -0
- data/lib/testsift/reporters/html/src/styles.css +434 -0
- data/lib/testsift/reporters/html/vite.config.js +42 -0
- data/lib/testsift/reporters/html_reporter.rb +266 -0
- data/lib/testsift/reporters/json_reporter.rb +88 -0
- data/lib/testsift/reporters/payload_builder.rb +241 -0
- data/lib/testsift/reporters/registry.rb +120 -0
- data/lib/testsift/reporters/terminal_reporter.rb +264 -0
- data/lib/testsift/rspec/README.md +73 -0
- data/lib/testsift/rspec/installation.rb +97 -0
- data/lib/testsift/rspec/metadata.rb +96 -0
- data/lib/testsift/rspec/parallel_tests.rb +459 -0
- data/lib/testsift/rspec/rails_setup.rb +42 -0
- data/lib/testsift/rspec/reporter_hook.rb +90 -0
- data/lib/testsift/rspec/runner_hook.rb +253 -0
- data/lib/testsift/source_file.rb +56 -0
- data/lib/testsift/storage/README.md +35 -0
- data/lib/testsift/storage/backend.rb +130 -0
- data/lib/testsift/storage/json_backend.rb +884 -0
- data/lib/testsift/storage/lazy_snapshot.rb +65 -0
- data/lib/testsift/storage/schema.rb +50 -0
- data/lib/testsift/storage/serializer/json.rb +41 -0
- data/lib/testsift/storage/serializer/msgpack.rb +167 -0
- data/lib/testsift/storage/snapshot.rb +141 -0
- data/lib/testsift/storage/sqlite_backend.rb +709 -0
- data/lib/testsift/time_formatter.rb +74 -0
- data/lib/testsift/tracker/README.md +36 -0
- data/lib/testsift/tracker/coverage_adapter.rb +180 -0
- data/lib/testsift/tracker/declared_globs.rb +100 -0
- data/lib/testsift/tracker/dependency_graph.rb +134 -0
- data/lib/testsift/tracker/env_matcher.rb +127 -0
- data/lib/testsift/tracker/env_snapshot.rb +77 -0
- data/lib/testsift/tracker/example_registry.rb +153 -0
- data/lib/testsift/tracker/file_digest.rb +61 -0
- data/lib/testsift/tracker/filter.rb +127 -0
- data/lib/testsift/tracker/input.rb +99 -0
- data/lib/testsift/tracker/io_hooks/file.rb +55 -0
- data/lib/testsift/tracker/io_hooks/io.rb +24 -0
- data/lib/testsift/tracker/io_hooks/json.rb +23 -0
- data/lib/testsift/tracker/io_hooks/kernel.rb +26 -0
- data/lib/testsift/tracker/io_hooks/yaml.rb +38 -0
- data/lib/testsift/tracker/io_hooks.rb +195 -0
- data/lib/testsift/tracker/loaded_files_tracker.rb +290 -0
- data/lib/testsift/tracker/new_file_detector.rb +62 -0
- data/lib/testsift/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/testsift/version.rb +7 -0
- data/lib/testsift.rb +335 -0
- metadata +193 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Builds the identity-hash payload (`:example_id`-keyed Hash) that
|
|
5
|
+
# RSpec::RunnerHook attaches to every example pre-run.
|
|
6
|
+
#
|
|
7
|
+
# == Identity stability contract
|
|
8
|
+
#
|
|
9
|
+
# `example_id` is the MD5 of a stable subset of the payload:
|
|
10
|
+
# `example_group` (the describe block's *description* string),
|
|
11
|
+
# `description`, `full_description`, `shared_group` (inclusion
|
|
12
|
+
# locations with the trailing line number stripped), and
|
|
13
|
+
# `file_name`. The contract, in one line: *rename = new identity;
|
|
14
|
+
# restructure = same identity.*
|
|
15
|
+
#
|
|
16
|
+
# Identity is PRESERVED when:
|
|
17
|
+
# - blank lines or comments are added/removed around the example
|
|
18
|
+
# - examples are reordered within a describe block (named
|
|
19
|
+
# examples only - see "Unnamed examples" below)
|
|
20
|
+
# - a sibling describe / example in the same file is renamed
|
|
21
|
+
# - metadata changes (`skip:`, tags, `tracks: { ... }`) - the
|
|
22
|
+
# spec-file digest still triggers the re-run and status history
|
|
23
|
+
# is kept
|
|
24
|
+
# - the example body or its hooks (`before`, `let`) are edited -
|
|
25
|
+
# again, the file digest triggers the re-run
|
|
26
|
+
#
|
|
27
|
+
# Identity CHANGES (one cold "No cache" run) when:
|
|
28
|
+
# - the file is renamed or moved
|
|
29
|
+
# - the `describe` / `it` / shared-example name is changed
|
|
30
|
+
# - the example moves to a different describe block
|
|
31
|
+
#
|
|
32
|
+
# == Unnamed examples (`it { }`, `specify { }`, `example { }`)
|
|
33
|
+
#
|
|
34
|
+
# An example with no description string has no stable name to hash,
|
|
35
|
+
# and identity must be computed pre-run (for the filter decision),
|
|
36
|
+
# before RSpec generates a matcher-derived description. The only
|
|
37
|
+
# line-independent signal RSpec exposes pre-run is position, so an
|
|
38
|
+
# unnamed example's identity is derived from its ordinal among the
|
|
39
|
+
# *unnamed* examples of its group. (RSpec's `description` for an
|
|
40
|
+
# unnamed example is the line-bearing `"example at <path>:<line>"`
|
|
41
|
+
# fallback, which would otherwise leak the line number straight
|
|
42
|
+
# back into the digest - issue #210.)
|
|
43
|
+
#
|
|
44
|
+
# For unnamed examples the contract above is amended: identity is
|
|
45
|
+
# still PRESERVED across blank-line / comment edits, sibling
|
|
46
|
+
# renames, and adding or removing *named* siblings - but it CHANGES
|
|
47
|
+
# when the unnamed examples are reordered, or one is inserted or
|
|
48
|
+
# removed ahead of it. Give an example an explicit description
|
|
49
|
+
# (`it 'does X' do`) for a fully reorder-stable identity.
|
|
50
|
+
#
|
|
51
|
+
# `line_number` / `rerun_file_name` / `rerun_line_number` stay in
|
|
52
|
+
# the returned Hash for the reporter + `explain` location columns,
|
|
53
|
+
# but are DELIBERATELY EXCLUDED from the digest - a no-op edit that
|
|
54
|
+
# shifts line numbers must not invalidate the cache. `example_group`
|
|
55
|
+
# uses `example_group.description` (the user's string) rather than
|
|
56
|
+
# `example_group.name`: RSpec's generated class name carries a
|
|
57
|
+
# load-order-dependent `_2` / `_3` suffix when two files share a
|
|
58
|
+
# describe name, which would otherwise flip the id across runs.
|
|
59
|
+
#
|
|
60
|
+
# Helpers are `def self.x` + `private_class_method` so mutant
|
|
61
|
+
# attributes mutations through the singleton call path.
|
|
62
|
+
module Example
|
|
63
|
+
# Identity-keyed cache of `<example_group> => Array<unnamed sibling>`
|
|
64
|
+
# populated lazily by `unnamed_description`. A group with N unnamed
|
|
65
|
+
# examples computes the sibling list once per group rather than N
|
|
66
|
+
# times. Memory is bounded by the live group set (RSpec retains
|
|
67
|
+
# those for the run anyway).
|
|
68
|
+
@unnamed_siblings_cache = {}.compare_by_identity
|
|
69
|
+
|
|
70
|
+
# Builds the identity payload for one RSpec example. The MD5 is
|
|
71
|
+
# taken over the stability-contract subset (see `digest_identity`
|
|
72
|
+
# for the named-vs-unnamed split); `line_number` / `rerun_*` ride
|
|
73
|
+
# along in the returned Hash for the reporters but never enter the
|
|
74
|
+
# digest. See the module comment for the full stability contract.
|
|
75
|
+
# @api private
|
|
76
|
+
def self.from(example)
|
|
77
|
+
location = example_location(example)
|
|
78
|
+
identity = {
|
|
79
|
+
example_group: example.example_group.description,
|
|
80
|
+
description: example.description,
|
|
81
|
+
full_description: example.full_description,
|
|
82
|
+
shared_group: example.metadata[:shared_group_inclusion_backtrace]
|
|
83
|
+
.map { |frame| frame.formatted_inclusion_location.sub(/:\d+\z/, '') },
|
|
84
|
+
file_name: location[:file_name]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
identity
|
|
88
|
+
.merge(location)
|
|
89
|
+
.merge(example_id: Digest::MD5.hexdigest(digest_identity(example, identity).to_json))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The Hash actually fed to the MD5. For a named example this is
|
|
93
|
+
# `identity` unchanged - byte-identical to the pre-#210 digest.
|
|
94
|
+
# For an unnamed example (`it { }` / `specify { }` / `example { }`)
|
|
95
|
+
# RSpec's `description` is the line-bearing location fallback
|
|
96
|
+
# `"example at <path>:<line>"`, so `description` is swapped for a
|
|
97
|
+
# line-independent positional discriminator before hashing. The
|
|
98
|
+
# returned/stored payload still carries RSpec's `description` /
|
|
99
|
+
# `full_description` untouched - only the digest input differs.
|
|
100
|
+
# @api private
|
|
101
|
+
def self.digest_identity(example, identity)
|
|
102
|
+
return identity unless unnamed?(example)
|
|
103
|
+
|
|
104
|
+
identity.merge(description: unnamed_description(example))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True when the example has no explicit description string. RSpec's
|
|
108
|
+
# raw `metadata[:description]` is `""` for `it { }` / `specify { }`
|
|
109
|
+
# / `example { }`; the `description` *method* would instead return
|
|
110
|
+
# the line-bearing `"example at <path>:<line>"` fallback, so the
|
|
111
|
+
# raw metadata value is what cleanly tells named from unnamed.
|
|
112
|
+
# @api private
|
|
113
|
+
def self.unnamed?(example)
|
|
114
|
+
example.metadata[:description].to_s.strip.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Line-independent identity discriminator for an unnamed example:
|
|
118
|
+
# its 0-based ordinal among the *unnamed* examples of its group.
|
|
119
|
+
# Stable across blank-line / comment edits and across adding or
|
|
120
|
+
# renaming *named* siblings; changes only when the unnamed examples
|
|
121
|
+
# are reordered or one is inserted / removed ahead of it (the
|
|
122
|
+
# documented carve-out - see the module comment). Closes the
|
|
123
|
+
# remaining #196 gap reported in #210.
|
|
124
|
+
#
|
|
125
|
+
# The unnamed-siblings list is memoized per example_group object,
|
|
126
|
+
# so a group with N unnamed examples computes the list once, not N
|
|
127
|
+
# times. The discriminator string takes a Ruby-inspect-style
|
|
128
|
+
# `#<...>` form to make spoofing implausible - and even if a user
|
|
129
|
+
# description literally matched, full_description would still
|
|
130
|
+
# differ between the unnamed example (`"<group> "`) and the named
|
|
131
|
+
# one (`"<group> #<...>"`), so no real digest collision is
|
|
132
|
+
# possible.
|
|
133
|
+
# @api private
|
|
134
|
+
def self.unnamed_description(example)
|
|
135
|
+
group = example.example_group
|
|
136
|
+
unnamed_siblings = @unnamed_siblings_cache[group] ||=
|
|
137
|
+
group.examples.select { |sibling| unnamed?(sibling) }
|
|
138
|
+
|
|
139
|
+
"#<testsift unnamed example #{unnamed_siblings.index(example)}>"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Internal helper for the tracer pipeline.
|
|
143
|
+
# @api private
|
|
144
|
+
def self.example_location(example)
|
|
145
|
+
metadata = example.metadata
|
|
146
|
+
|
|
147
|
+
location = {
|
|
148
|
+
file_name: location_file_name(metadata[:file_path]),
|
|
149
|
+
line_number: metadata[:line_number]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if metadata[:file_path] == metadata[:rerun_file_path]
|
|
153
|
+
return location.merge(
|
|
154
|
+
rerun_file_name: location[:file_name],
|
|
155
|
+
rerun_line_number: location[:line_number]
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
location.merge(example_rerun_location(example.example_group.parent_groups))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Internal helper for the tracer pipeline.
|
|
163
|
+
# @api private
|
|
164
|
+
def self.example_rerun_location(example_groups)
|
|
165
|
+
example_groups.each do |example_group|
|
|
166
|
+
metadata = example_group.metadata
|
|
167
|
+
|
|
168
|
+
next unless metadata[:file_path] == metadata[:rerun_file_path]
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
rerun_file_name: location_file_name(metadata[:file_path]),
|
|
172
|
+
rerun_line_number: metadata[:line_number]
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Internal helper for the tracer pipeline.
|
|
178
|
+
# @api private
|
|
179
|
+
def self.location_file_name(rspec_file_name)
|
|
180
|
+
file_path = TestSift::SourceFile.file_path(rspec_file_name)
|
|
181
|
+
|
|
182
|
+
TestSift::SourceFile.file_name(file_path)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private_class_method :digest_identity, :unnamed?, :unnamed_description,
|
|
186
|
+
:example_location, :example_rerun_location, :location_file_name
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Internal Filter — see {TestSift} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# Internal dispatch shim for the user-facing `add_filter` /
|
|
8
|
+
# `add_coverage_filter` DSL. The user passes a String / Regexp /
|
|
9
|
+
# Proc / Array; `Filter.register` wraps it in the matching subclass
|
|
10
|
+
# so the engine can call a uniform `#match?(source_file)`.
|
|
11
|
+
class Filter
|
|
12
|
+
# Internal attribute.
|
|
13
|
+
# @api private
|
|
14
|
+
attr_reader :filter
|
|
15
|
+
|
|
16
|
+
# Internal helper for the tracer pipeline.
|
|
17
|
+
# @api private
|
|
18
|
+
def self.register(filter)
|
|
19
|
+
return filter if filter.is_a?(Filter)
|
|
20
|
+
|
|
21
|
+
filter_class(filter).new(filter)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Internal helper for the tracer pipeline.
|
|
25
|
+
# @api private
|
|
26
|
+
def self.filter_class(filter)
|
|
27
|
+
case filter
|
|
28
|
+
when String
|
|
29
|
+
StringFilter
|
|
30
|
+
when Regexp
|
|
31
|
+
RegexFilter
|
|
32
|
+
when Proc
|
|
33
|
+
BlockFilter
|
|
34
|
+
when Array
|
|
35
|
+
ArrayFilter
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, 'Unknow filter'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Internal method on the tracer pipeline.
|
|
42
|
+
# @api private
|
|
43
|
+
def initialize(filter)
|
|
44
|
+
@filter = filter
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Internal method on the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def match?(_source_file)
|
|
50
|
+
raise "#{self.class.name}#match? is not intended for direct use"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Internal ArrayFilter — see {TestSift} for the user-facing surface.
|
|
55
|
+
# @api private
|
|
56
|
+
class ArrayFilter < TestSift::Filter
|
|
57
|
+
# Internal method on the tracer pipeline.
|
|
58
|
+
# @api private
|
|
59
|
+
def initialize(filters)
|
|
60
|
+
filter_list = filters.each_with_object([]) do |filter, list|
|
|
61
|
+
list << Filter.register(filter)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
super(filter_list)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Internal method on the tracer pipeline.
|
|
68
|
+
# @api private
|
|
69
|
+
def match?(source_file)
|
|
70
|
+
@filter.any? { |filter| filter.match?(source_file) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Internal BlockFilter — see {TestSift} for the user-facing surface.
|
|
75
|
+
# @api private
|
|
76
|
+
class BlockFilter < TestSift::Filter
|
|
77
|
+
# Internal method on the tracer pipeline.
|
|
78
|
+
# @api private
|
|
79
|
+
def match?(source_file)
|
|
80
|
+
@filter.call(source_file)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Internal RegexFilter — see {TestSift} for the user-facing surface.
|
|
85
|
+
# @api private
|
|
86
|
+
class RegexFilter < TestSift::Filter
|
|
87
|
+
# Internal method on the tracer pipeline.
|
|
88
|
+
# @api private
|
|
89
|
+
def match?(source_file)
|
|
90
|
+
source_file[:file_name] =~ @filter
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Internal StringFilter — see {TestSift} for the user-facing surface.
|
|
95
|
+
# @api private
|
|
96
|
+
class StringFilter < TestSift::Filter
|
|
97
|
+
# Internal method on the tracer pipeline.
|
|
98
|
+
# @api private
|
|
99
|
+
def match?(source_file)
|
|
100
|
+
source_file[:file_name].include?(@filter)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Per-engine line-stub builder for files that need a synthetic
|
|
5
|
+
# all-nil-but-executable-lines coverage array. Used by
|
|
6
|
+
# `Reporters::CoverageJsonReporter` when a tracked file is in
|
|
7
|
+
# `coverage_tracked_files` but has no recorded coverage (file never
|
|
8
|
+
# loaded during this run).
|
|
9
|
+
#
|
|
10
|
+
# Lives at the top level of lib/testsift so the per-engine
|
|
11
|
+
# branches sit outside the tracker_coverage_gate's
|
|
12
|
+
# 100%-line+branch contract (the JRuby branch cannot be exercised
|
|
13
|
+
# on MRI; cross-engine coverage rollup would require fork-per-engine
|
|
14
|
+
# CI work that isn't justified for stub-line generation).
|
|
15
|
+
#
|
|
16
|
+
# Methods use `def self.x` (not module_function) so future mutation
|
|
17
|
+
# gating maps to the singleton form -- module_function attaches
|
|
18
|
+
# methods to an anonymous singleton that mutant cannot observe.
|
|
19
|
+
module LineStub
|
|
20
|
+
# Internal helper for the tracer pipeline.
|
|
21
|
+
# @api private
|
|
22
|
+
def self.for(file_path)
|
|
23
|
+
case RUBY_ENGINE
|
|
24
|
+
when 'ruby'
|
|
25
|
+
ruby(file_path)
|
|
26
|
+
when 'jruby'
|
|
27
|
+
jruby(file_path)
|
|
28
|
+
else
|
|
29
|
+
File.foreach(file_path).map { nil }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Internal helper for the tracer pipeline.
|
|
34
|
+
# @api private
|
|
35
|
+
def self.ruby(file_path)
|
|
36
|
+
lines = File.foreach(file_path).map { nil }
|
|
37
|
+
iseqs = [::RubyVM::InstructionSequence.compile_file(file_path)]
|
|
38
|
+
until iseqs.empty?
|
|
39
|
+
iseq = iseqs.pop
|
|
40
|
+
iseq.trace_points.each { |line_number, type| lines[line_number - 1] = 0 if type == :line }
|
|
41
|
+
iseq.each_child { |child| iseqs << child }
|
|
42
|
+
end
|
|
43
|
+
lines
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Internal helper for the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def self.jruby(file_path)
|
|
49
|
+
lines = File.foreach(file_path).map { nil }
|
|
50
|
+
root_node = ::JRuby.parse(File.read(file_path, encoding: 'UTF-8'))
|
|
51
|
+
visitor = org.jruby.ast.visitor.NodeVisitor.impl do |_name, node|
|
|
52
|
+
if node.newline?
|
|
53
|
+
ln = node.respond_to?(:position) ? node.position.line : node.line
|
|
54
|
+
lines[ln] = 0
|
|
55
|
+
end
|
|
56
|
+
node.child_nodes.each { |child| child&.accept(visitor) }
|
|
57
|
+
end
|
|
58
|
+
root_node.accept(visitor)
|
|
59
|
+
lines
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'configuration'
|
|
4
|
+
TestSift.extend TestSift::Configuration
|
|
5
|
+
|
|
6
|
+
require_relative 'load_default_config'
|
|
7
|
+
require_relative 'load_global_config'
|
|
8
|
+
require_relative 'load_local_config'
|
|
9
|
+
|
|
10
|
+
# NOTE: `Configuration#configure` installs the public DSL wrappers
|
|
11
|
+
# (alias `_name` + redefine `name` to forward `|*args, **kwargs, &block|`)
|
|
12
|
+
# the first time any configurer runs. `load_default_config` is
|
|
13
|
+
# unconditional, so the wrappers are guaranteed to exist before anyone
|
|
14
|
+
# can call `TestSift.add_filter` etc. No second install step is
|
|
15
|
+
# needed here.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
TestSift.configure do
|
|
5
|
+
log_level :info
|
|
6
|
+
fail_on_duplicates true
|
|
7
|
+
|
|
8
|
+
filters.clear
|
|
9
|
+
add_filter %w[
|
|
10
|
+
/vendor/bundle/
|
|
11
|
+
/lib/testsift/
|
|
12
|
+
/lib/testsift.rb
|
|
13
|
+
/usr/local/lib/ruby/
|
|
14
|
+
/usr/local/bundle/
|
|
15
|
+
/opt/hostedtoolcache/
|
|
16
|
+
/.rbenv/versions/
|
|
17
|
+
/.asdf/installs/ruby/
|
|
18
|
+
/.rvm/
|
|
19
|
+
/testsift_cache/
|
|
20
|
+
/testsift_coverage/
|
|
21
|
+
/testsift_report/
|
|
22
|
+
testsift.lock
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
coverage_filters.clear
|
|
26
|
+
add_coverage_filter %w[
|
|
27
|
+
/autotest/
|
|
28
|
+
/features/
|
|
29
|
+
/spec/
|
|
30
|
+
/test/
|
|
31
|
+
/vendor/bundle/
|
|
32
|
+
/lib/testsift/
|
|
33
|
+
/lib/testsift.rb
|
|
34
|
+
/usr/local/lib/ruby/
|
|
35
|
+
/usr/local/bundle/
|
|
36
|
+
/opt/hostedtoolcache/
|
|
37
|
+
/.rbenv/versions/
|
|
38
|
+
/.asdf/installs/ruby/
|
|
39
|
+
/.rvm/
|
|
40
|
+
/testsift_cache/
|
|
41
|
+
/testsift_coverage/
|
|
42
|
+
/testsift_report/
|
|
43
|
+
testsift.lock
|
|
44
|
+
].freeze
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'etc'
|
|
4
|
+
|
|
5
|
+
# Resolve the user's home directory without raising at gem-load time. Each
|
|
6
|
+
# primitive (Dir.home, Etc.getpwuid.dir, File.expand_path("~user")) can raise
|
|
7
|
+
# ArgumentError when HOME is unset and the passwd database has no matching
|
|
8
|
+
# entry (common in minimal containers and CI images). Graceful degradation:
|
|
9
|
+
# if none resolves, skip global config entirely rather than aborting boot.
|
|
10
|
+
home_dir = begin
|
|
11
|
+
Dir.home
|
|
12
|
+
rescue ArgumentError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
home_dir ||= begin
|
|
17
|
+
Etc.getpwuid.dir
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
user = ENV.fetch('USER', nil)
|
|
23
|
+
home_dir ||= begin
|
|
24
|
+
File.expand_path("~#{user}") if user
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if home_dir
|
|
30
|
+
global_config_path = File.join(home_dir, '.testsift')
|
|
31
|
+
|
|
32
|
+
load global_config_path if File.exist?(global_config_path)
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
config_path = Pathname.new(TestSift.root)
|
|
4
|
+
|
|
5
|
+
loop do
|
|
6
|
+
filename = config_path.join('.testsift')
|
|
7
|
+
|
|
8
|
+
if filename.exist?
|
|
9
|
+
load filename
|
|
10
|
+
|
|
11
|
+
break
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
config_path, = config_path.split
|
|
15
|
+
|
|
16
|
+
break if config_path.root?
|
|
17
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Internal Logger — see {TestSift} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# Internal logger; thin wrapper around `IO#puts` gated on numeric
|
|
8
|
+
# log level. Exposed via `TestSift.logger`.
|
|
9
|
+
class Logger
|
|
10
|
+
class << self
|
|
11
|
+
# Process-wide default destination for loggers constructed
|
|
12
|
+
# without an explicit `out:`. `nil` (the norm) means `$stdout`,
|
|
13
|
+
# which normal `rspec` runs rely on. The `testsift` binary
|
|
14
|
+
# sets this to its stderr BEFORE booting the tracer library, so
|
|
15
|
+
# loggers constructed while the project's `.testsift` config
|
|
16
|
+
# loads (e.g. by the 1.x deprecation shims, or recreated after a
|
|
17
|
+
# config `log_level` call resets the memoized instance) already
|
|
18
|
+
# bind to stderr and machine-readable stdout (`blast-radius
|
|
19
|
+
# --json`) stays free of diagnostics. See {TestSift::CLI}.
|
|
20
|
+
#
|
|
21
|
+
# @return [IO, nil]
|
|
22
|
+
# @api private
|
|
23
|
+
attr_accessor :default_out
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param log_level [Integer] numeric level (Configuration::LOG_LEVEL)
|
|
27
|
+
# @param out [IO, nil] destination stream. Falls back to
|
|
28
|
+
# {Logger.default_out} when nil, then to `$stdout`.
|
|
29
|
+
# @api private
|
|
30
|
+
def initialize(log_level, out: nil)
|
|
31
|
+
@log_level = log_level
|
|
32
|
+
@out = out || self.class.default_out || $stdout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Internal method on the tracer pipeline.
|
|
36
|
+
# @api private
|
|
37
|
+
def debug(message)
|
|
38
|
+
@out.puts message if @log_level == 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Internal method on the tracer pipeline.
|
|
42
|
+
# @api private
|
|
43
|
+
def info(message)
|
|
44
|
+
@out.puts message if @log_level.between?(1, 2)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Internal method on the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def warn(message)
|
|
50
|
+
@out.puts message if @log_level.between?(1, 3)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Internal method on the tracer pipeline.
|
|
54
|
+
# @api private
|
|
55
|
+
def error(message)
|
|
56
|
+
@out.puts message if @log_level.between?(1, 4)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Rails integration
|
|
2
|
+
|
|
3
|
+
Loaded by `require 'testsift/rails'`. Exposes the Rails preset, the
|
|
4
|
+
Rails-side observer family, and a Railtie that integrates with the Rails
|
|
5
|
+
lifecycle when Rails is present in the process.
|
|
6
|
+
|
|
7
|
+
## Surface
|
|
8
|
+
|
|
9
|
+
- **`TestSift::Rails::Preset`** - closed-enum glob set covering the
|
|
10
|
+
Coverage-invisible Rails surface (views, helpers, locales, config
|
|
11
|
+
YAML, schema, factories, fixtures). Opt in via `track_rails_defaults`
|
|
12
|
+
in `.testsift`; opt out per category via
|
|
13
|
+
`track_rails_defaults except: [:views, :locales]`.
|
|
14
|
+
- **`TestSift::Rails::Railtie`** - only loaded when
|
|
15
|
+
`defined?(::Rails::Railtie)`. Registers a single `testsift.setup`
|
|
16
|
+
initializer that logs a confirmation line when Rails boots.
|
|
17
|
+
- **`TestSift::Rails::Notifications`** - ActiveSupport::Notifications
|
|
18
|
+
observer for the render_template / render_partial / render_collection
|
|
19
|
+
events (ActionView) plus an opt-in sql.active_record subscriber for
|
|
20
|
+
narrow schema attribution. Installed by `Engine.setup` when
|
|
21
|
+
`TestSift.rails?` is true. Emits `:template` Inputs for observed
|
|
22
|
+
template renders and `:notification` Inputs for schema files on the
|
|
23
|
+
first AR query per example.
|
|
24
|
+
- **`TestSift::Rails::I18nTracking`** - prepends onto
|
|
25
|
+
`::I18n::Backend::Base#load_translations` so every I18n backend
|
|
26
|
+
(including custom Redis/DB/Chain backends that bypass
|
|
27
|
+
`YAML.load_file`) emits `:notification` Inputs for the translation
|
|
28
|
+
files it loads.
|
|
29
|
+
|
|
30
|
+
## Detection
|
|
31
|
+
|
|
32
|
+
`TestSift.rails?` returns true when `::Rails::VERSION` is defined at
|
|
33
|
+
the time `TestSift.start` runs. The flag is computed once during
|
|
34
|
+
`initial_setup`; subsequent Rails loads do not flip it.
|
|
35
|
+
|
|
36
|
+
## Zero-cost when Rails is absent
|
|
37
|
+
|
|
38
|
+
`require 'testsift/rails'` loads Preset unconditionally and skips
|
|
39
|
+
the Railtie via `defined?(::Rails::Railtie)`. Notifications and
|
|
40
|
+
I18nTracking are required lazily by `Engine.setup` only when
|
|
41
|
+
`TestSift.rails?` is truthy. A pure-Ruby suite that accidentally
|
|
42
|
+
requires the file never pays for Rails-specific code.
|
|
43
|
+
|
|
44
|
+
## Narrow schema attribution (opt-in)
|
|
45
|
+
|
|
46
|
+
By default, `track_rails_defaults` attaches `db/schema.rb` and
|
|
47
|
+
`db/structure.sql` to every example via the Preset's `:schema`
|
|
48
|
+
declared-glob - a conservative whole-suite signal. Teams that want
|
|
49
|
+
schema changes to re-run only examples that actually touched AR can
|
|
50
|
+
opt into an `sql.active_record` subscriber:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
TestSift.configure do
|
|
54
|
+
track_rails_defaults except: [:schema]
|
|
55
|
+
track_ar_schema_notifications
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
On the first `sql.active_record` event inside an example, the
|
|
60
|
+
subscriber emits `:notification` Inputs for `db/schema.rb` and
|
|
61
|
+
`db/structure.sql` if they exist under the project root, then
|
|
62
|
+
short-circuits for the remainder of that example. A non-DB-touching
|
|
63
|
+
example never sees the schema inputs and is not re-run on schema edits.
|
|
64
|
+
|
|
65
|
+
Leaving `:schema` in `track_rails_defaults` while also enabling the
|
|
66
|
+
subscriber is a no-op in terms of the re-run set - declared-glob
|
|
67
|
+
dominates at graph registration.
|
|
68
|
+
|
|
69
|
+
## Components
|
|
70
|
+
|
|
71
|
+
- **Preset + detection + Railtie scaffold.**
|
|
72
|
+
- **Notifications + I18nTracking observers**, the
|
|
73
|
+
`track_ar_schema_notifications` opt-in DSL, and `Engine.setup`
|
|
74
|
+
wiring. Factory and fixture coverage ride the existing
|
|
75
|
+
`LoadedFilesTracker` / `YAML.load_file` hook surface.
|
|
76
|
+
- **Integration coverage** against the reference Rails app
|
|
77
|
+
(`spec/fixtures/rails_app/`) verifying the full "change X -> Y
|
|
78
|
+
re-runs" behavior matrix.
|