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,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative 'git_ancestry'
|
|
8
|
+
require_relative 'local_fs_backend'
|
|
9
|
+
require_relative 'redis_backend'
|
|
10
|
+
require_relative 's3_backend'
|
|
11
|
+
|
|
12
|
+
module RSpecTracer
|
|
13
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
14
|
+
# @api private
|
|
15
|
+
module RemoteCache
|
|
16
|
+
# Orchestrator for the user-facing `rspec_tracer:remote_cache:*`
|
|
17
|
+
# Rake tasks. Composes `GitAncestry` + a `Backend` implementation,
|
|
18
|
+
# drives the download (candidate-ref walk + first-valid wins) and
|
|
19
|
+
# upload (branch_ref + branch_refs update + retention prune) flows.
|
|
20
|
+
#
|
|
21
|
+
# Called from `lib/rspec_tracer/remote_cache/Rakefile` which is
|
|
22
|
+
# loaded by the user's own Rakefile per USER_FACING_SURFACE.md section 5.
|
|
23
|
+
# The user-facing task surface is preserved from 1.x bit-for-bit:
|
|
24
|
+
# same task names, same env vars, same exit behavior.
|
|
25
|
+
#
|
|
26
|
+
# Graceful-degradation contract:
|
|
27
|
+
# - `download!` catches every StandardError, logs, returns false.
|
|
28
|
+
# A failed download is cold run; tests still proceed.
|
|
29
|
+
# - `upload!` catches every StandardError, logs, returns false.
|
|
30
|
+
# A failed upload is logged but doesn't propagate non-zero -
|
|
31
|
+
# the tests already passed; cache miss is recoverable next run.
|
|
32
|
+
#
|
|
33
|
+
class UserTasks
|
|
34
|
+
# Internal constant.
|
|
35
|
+
# @api private
|
|
36
|
+
BUILT_IN_BACKENDS = {
|
|
37
|
+
s3: S3Backend,
|
|
38
|
+
local_fs: LocalFsBackend,
|
|
39
|
+
redis: RedisBackend
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Module-level convenience for {#download!}. Equivalent to
|
|
43
|
+
# `UserTasks.new(configuration: ..., env: ...).download!`. Invoked
|
|
44
|
+
# by the bundled `rspec_tracer/remote_cache/Rakefile` shim that
|
|
45
|
+
# users `import` from their own `Rakefile`.
|
|
46
|
+
#
|
|
47
|
+
# @param configuration [Object] anything responding to the
|
|
48
|
+
# `cache_path` / `logger` / `remote_cache_*` config surface
|
|
49
|
+
# (defaults to the {RSpecTracer} top-level module).
|
|
50
|
+
# @param env [Hash] env hash to read `GIT_BRANCH` /
|
|
51
|
+
# `GIT_DEFAULT_BRANCH` from (defaults to `ENV`).
|
|
52
|
+
# @return [Boolean] true on a successful cache hit, false on cold
|
|
53
|
+
# run or graceful failure.
|
|
54
|
+
def self.download!(configuration: RSpecTracer, env: ENV)
|
|
55
|
+
new(configuration: configuration, env: env).download!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Module-level convenience for {#upload!}. Equivalent to
|
|
59
|
+
# `UserTasks.new(configuration: ..., env: ...).upload!`. Invoked
|
|
60
|
+
# by the bundled `rspec_tracer/remote_cache/Rakefile` shim.
|
|
61
|
+
#
|
|
62
|
+
# @param configuration [Object] anything responding to the
|
|
63
|
+
# `cache_path` / `logger` / `remote_cache_*` config surface
|
|
64
|
+
# (defaults to the {RSpecTracer} top-level module).
|
|
65
|
+
# @param env [Hash] env hash to read `GIT_BRANCH` /
|
|
66
|
+
# `GIT_DEFAULT_BRANCH` from (defaults to `ENV`).
|
|
67
|
+
# @return [Boolean] true on a successful upload, false on
|
|
68
|
+
# graceful failure (logged but not raised).
|
|
69
|
+
def self.upload!(configuration: RSpecTracer, env: ENV)
|
|
70
|
+
new(configuration: configuration, env: env).upload!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Internal helper for the tracer pipeline.
|
|
74
|
+
# @api private
|
|
75
|
+
def self.prune_all!(configuration: RSpecTracer, env: ENV)
|
|
76
|
+
new(configuration: configuration, env: env).prune_all!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Internal helper for the tracer pipeline.
|
|
80
|
+
# @api private
|
|
81
|
+
def self.git_repo?
|
|
82
|
+
system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Internal method on the tracer pipeline.
|
|
86
|
+
# @api private
|
|
87
|
+
def initialize(configuration:, env:)
|
|
88
|
+
@config = configuration
|
|
89
|
+
@env = env
|
|
90
|
+
@logger = configuration.logger
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Pull the closest matching cache for the current branch + commit
|
|
94
|
+
# ancestry from the configured backend. Walks the candidate refs
|
|
95
|
+
# produced by {GitAncestry}, downloads + extracts the first ref
|
|
96
|
+
# whose archive validates, and returns true. Cold-run on miss.
|
|
97
|
+
#
|
|
98
|
+
# Errors are caught and logged; a failed download never propagates
|
|
99
|
+
# a non-zero exit into the test suite (graceful degradation).
|
|
100
|
+
#
|
|
101
|
+
# @example In a Rakefile
|
|
102
|
+
# require 'rspec_tracer/remote_cache/Rakefile'
|
|
103
|
+
# # provides `rake rspec_tracer:remote_cache:download` which
|
|
104
|
+
# # invokes RSpecTracer::RemoteCache::UserTasks.download!
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true on a successful download, false on cold
|
|
107
|
+
# run / graceful failure.
|
|
108
|
+
def download!
|
|
109
|
+
ancestry = build_ancestry
|
|
110
|
+
ancestry.merge_base_branch!
|
|
111
|
+
backend = build_backend(ancestry)
|
|
112
|
+
|
|
113
|
+
refs = candidate_refs(ancestry, backend)
|
|
114
|
+
if refs.empty?
|
|
115
|
+
@logger.warn 'rspec-tracer remote_cache: no cache candidates found; cold run'
|
|
116
|
+
return false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
tree_sha = head_tree_sha
|
|
120
|
+
refs.each do |ref, origin|
|
|
121
|
+
@logger.debug "rspec-tracer remote_cache: trying ref #{ref}"
|
|
122
|
+
next unless backend.download(ref, tree_sha: tree_sha)
|
|
123
|
+
|
|
124
|
+
log_download_success(ref, origin, ancestry)
|
|
125
|
+
return true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@logger.warn 'rspec-tracer remote_cache: no suitable cache found; cold run'
|
|
129
|
+
false
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
@logger.warn "rspec-tracer remote_cache: download failed (#{e.class}: #{e.message}); cold run"
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Push the local cache directory for the current branch + commit
|
|
136
|
+
# to the configured backend, refresh the per-branch ref index, and
|
|
137
|
+
# apply retention pruning.
|
|
138
|
+
#
|
|
139
|
+
# Errors are caught and logged; a failed upload never propagates
|
|
140
|
+
# non-zero into the test suite (the tests already passed).
|
|
141
|
+
#
|
|
142
|
+
# @example In a Rakefile
|
|
143
|
+
# require 'rspec_tracer/remote_cache/Rakefile'
|
|
144
|
+
# # provides `rake rspec_tracer:remote_cache:upload` which
|
|
145
|
+
# # invokes RSpecTracer::RemoteCache::UserTasks.upload!
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true on success, false on graceful failure.
|
|
148
|
+
def upload!
|
|
149
|
+
ancestry = build_ancestry
|
|
150
|
+
ancestry.merge_base_branch!
|
|
151
|
+
backend = build_backend(ancestry)
|
|
152
|
+
|
|
153
|
+
backend.upload(ancestry.branch_ref, tree_sha: head_tree_sha)
|
|
154
|
+
@logger.info "rspec-tracer remote_cache: uploaded cache to #{ancestry.branch_ref}"
|
|
155
|
+
maybe_update_branch_refs(backend, ancestry)
|
|
156
|
+
maybe_prune(backend, ancestry)
|
|
157
|
+
maybe_warn_unbounded(backend)
|
|
158
|
+
true
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
@logger.warn "rspec-tracer remote_cache: upload failed (#{e.class}: #{e.message})"
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Cross-tier PR-branch prune. Walks every branch under the
|
|
165
|
+
# configured prefix and deletes whole branches idle longer than
|
|
166
|
+
# `cache_retention_pr_branch_ttl_seconds`. Designed as a periodic
|
|
167
|
+
# maintenance task (nightly cron) - dead PR branches whose tip is
|
|
168
|
+
# never re-uploaded otherwise accumulate forever because
|
|
169
|
+
# `maybe_prune` only scopes to the current upload's tier. Returns
|
|
170
|
+
# the total refs removed across all branches, or 0 on graceful
|
|
171
|
+
# failure.
|
|
172
|
+
def prune_all!
|
|
173
|
+
ttl = safe_config(:cache_retention_pr_branch_ttl_seconds)
|
|
174
|
+
if ttl.nil?
|
|
175
|
+
@logger.warn 'rspec-tracer remote_cache: prune_all requires cache_retention_pr_branch_ttl; skipping'
|
|
176
|
+
return 0
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
backend = build_backend(build_admin_ancestry)
|
|
180
|
+
removed = backend.prune_all!(pr_branch_ttl_seconds: ttl)
|
|
181
|
+
@logger.info "rspec-tracer remote_cache: prune_all removed #{removed} refs"
|
|
182
|
+
removed
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
@logger.warn "rspec-tracer remote_cache: prune_all failed (#{e.class}: #{e.message})"
|
|
185
|
+
0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Internal method on the tracer pipeline.
|
|
191
|
+
# @api private
|
|
192
|
+
def build_ancestry
|
|
193
|
+
GitAncestry.new(
|
|
194
|
+
default_branch: require_env('GIT_DEFAULT_BRANCH'),
|
|
195
|
+
branch: require_env('GIT_BRANCH')
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Ancestry for cross-tier admin tasks (prune_all). Only
|
|
200
|
+
# GIT_DEFAULT_BRANCH is required; GIT_BRANCH defaults to it so
|
|
201
|
+
# the backend constructs in main-tier mode (prune_all walks every
|
|
202
|
+
# pr/ branch regardless of the backend's own tier, so the branch
|
|
203
|
+
# value does not affect behavior). Running prune_all from a
|
|
204
|
+
# cron/workflow that is not tied to a specific PR should work
|
|
205
|
+
# with just GIT_DEFAULT_BRANCH set.
|
|
206
|
+
def build_admin_ancestry
|
|
207
|
+
default = require_env('GIT_DEFAULT_BRANCH')
|
|
208
|
+
branch = @env['GIT_BRANCH']
|
|
209
|
+
branch = default if branch.nil? || branch.to_s.empty?
|
|
210
|
+
GitAncestry.new(default_branch: default, branch: branch)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Internal method on the tracer pipeline.
|
|
214
|
+
# @api private
|
|
215
|
+
def require_env(name)
|
|
216
|
+
value = @env[name]
|
|
217
|
+
raise "#{name} environment variable is not set" if value.nil? || value.to_s.empty?
|
|
218
|
+
|
|
219
|
+
value
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Internal method on the tracer pipeline.
|
|
223
|
+
# @api private
|
|
224
|
+
def build_backend(ancestry)
|
|
225
|
+
entry = remote_cache_backend_entry
|
|
226
|
+
raise 'no remote_cache_backend configured' if entry.nil?
|
|
227
|
+
|
|
228
|
+
name_or_class, user_opts = entry
|
|
229
|
+
klass = resolve_backend_class(name_or_class)
|
|
230
|
+
klass.new(**merge_runtime_opts(user_opts, ancestry))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Internal method on the tracer pipeline.
|
|
234
|
+
# @api private
|
|
235
|
+
def remote_cache_backend_entry
|
|
236
|
+
explicit = safe_config(:remote_cache_backend_entry)
|
|
237
|
+
return explicit if explicit
|
|
238
|
+
|
|
239
|
+
derive_from_legacy_dsl
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Probe the legacy `reports_s3_path` DSL when no explicit
|
|
243
|
+
# `remote_cache_backend` is configured. Gated on
|
|
244
|
+
# {Configuration#reports_s3_path_set?} so the deprecation
|
|
245
|
+
# warning fires only when the user actually set the DSL or its
|
|
246
|
+
# env var — never on the probe path when neither is configured.
|
|
247
|
+
# @api private
|
|
248
|
+
def derive_from_legacy_dsl
|
|
249
|
+
return nil unless safe_config(:reports_s3_path_set?)
|
|
250
|
+
|
|
251
|
+
s3_uri = safe_config(:reports_s3_path)
|
|
252
|
+
return nil if s3_uri.nil? || s3_uri.to_s.empty?
|
|
253
|
+
|
|
254
|
+
uri = URI.parse(s3_uri)
|
|
255
|
+
return nil unless uri.scheme == 's3' && uri.host && !uri.host.empty?
|
|
256
|
+
|
|
257
|
+
prefix = uri.path.to_s.sub(%r{^/}, '')
|
|
258
|
+
[
|
|
259
|
+
:s3,
|
|
260
|
+
{
|
|
261
|
+
bucket: uri.host,
|
|
262
|
+
prefix: prefix,
|
|
263
|
+
local: safe_config(:use_local_aws) == true
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Internal method on the tracer pipeline.
|
|
269
|
+
# @api private
|
|
270
|
+
def resolve_backend_class(name_or_class)
|
|
271
|
+
case name_or_class
|
|
272
|
+
when Symbol
|
|
273
|
+
BUILT_IN_BACKENDS.fetch(name_or_class) do
|
|
274
|
+
raise "unknown remote_cache_backend: #{name_or_class.inspect}"
|
|
275
|
+
end
|
|
276
|
+
when Class
|
|
277
|
+
name_or_class
|
|
278
|
+
else
|
|
279
|
+
raise "invalid remote_cache_backend: #{name_or_class.class}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Internal method on the tracer pipeline.
|
|
284
|
+
# @api private
|
|
285
|
+
def merge_runtime_opts(user_opts, ancestry)
|
|
286
|
+
runtime = {
|
|
287
|
+
branch: ancestry.branch_name,
|
|
288
|
+
default_branch: ancestry.default_branch_name,
|
|
289
|
+
cache_path: @config.cache_path,
|
|
290
|
+
test_suite_id: @env['TEST_SUITE_ID'],
|
|
291
|
+
logger: @logger
|
|
292
|
+
}
|
|
293
|
+
# User opts win for bucket/prefix/local; runtime opts are injected
|
|
294
|
+
# fresh (caller never sets these via DSL).
|
|
295
|
+
runtime.merge(user_opts)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Returns a timestamp-sorted (newest first) list of
|
|
299
|
+
# `[ref, origin]` tuples where origin is `:branch` (PR-tier
|
|
300
|
+
# branch_refs upload) or `:ancestry` (commit-ancestry fallback,
|
|
301
|
+
# which on PR builds means a cross-branch hit on the
|
|
302
|
+
# default-branch tier). Same merge order as before — on PR
|
|
303
|
+
# builds branch_refs come first, ancestry refs second; on
|
|
304
|
+
# collision ancestry wins, so the origin reflects the winning
|
|
305
|
+
# source. The info-log shape in {#download!} reads the origin
|
|
306
|
+
# tag to qualify cross-branch fallback hits in the INFO line.
|
|
307
|
+
# @api private
|
|
308
|
+
def candidate_refs(ancestry, backend)
|
|
309
|
+
refs = {}
|
|
310
|
+
origins = {}
|
|
311
|
+
if ancestry.pr_build?
|
|
312
|
+
backend.branch_refs(ancestry.branch_name).each do |sha, ts|
|
|
313
|
+
refs[sha] = ts
|
|
314
|
+
origins[sha] = :branch
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
ancestry.ancestry_refs.each do |sha, ts|
|
|
318
|
+
refs[sha] = ts
|
|
319
|
+
origins[sha] = :ancestry
|
|
320
|
+
end
|
|
321
|
+
refs.sort_by { |_, ts| -ts }.map { |sha, _| [sha, origins[sha]] }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Emit an INFO log line on a successful download. Distinguishes
|
|
325
|
+
# PR-tier branch_refs hits ("restored cache from <ref>") from
|
|
326
|
+
# ancestry-fallback hits on PR builds, which get the explicit
|
|
327
|
+
# "(cross-branch fallback)" qualifier so the user can tell at
|
|
328
|
+
# a glance whether the PR-tier cache existed or whether the
|
|
329
|
+
# restore traversed the default branch.
|
|
330
|
+
# @api private
|
|
331
|
+
def log_download_success(ref, origin, ancestry)
|
|
332
|
+
qualifier = origin == :ancestry && ancestry.pr_build? ? ' (cross-branch fallback)' : ''
|
|
333
|
+
@logger.info "rspec-tracer remote_cache: restored cache from #{ref}#{qualifier}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Internal method on the tracer pipeline.
|
|
337
|
+
# @api private
|
|
338
|
+
def maybe_update_branch_refs(backend, ancestry)
|
|
339
|
+
return unless ancestry.pr_build?
|
|
340
|
+
|
|
341
|
+
existing = backend.branch_refs(ancestry.branch_name)
|
|
342
|
+
updated = existing.merge(ancestry.branch_ref => Time.now.to_i)
|
|
343
|
+
filtered = filter_branch_refs(updated, ancestry)
|
|
344
|
+
backend.write_branch_refs(ancestry.branch_name, filtered)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Bound branch_refs to 25 most-recent, filtered to refs newer
|
|
348
|
+
# than the oldest ancestry commit when ancestry is non-empty.
|
|
349
|
+
# Matches 1.x `Repo#filter_branch_refs`.
|
|
350
|
+
def filter_branch_refs(refs, ancestry)
|
|
351
|
+
ancestry_refs = ancestry.ancestry_refs
|
|
352
|
+
bounded =
|
|
353
|
+
if ancestry_refs.empty?
|
|
354
|
+
refs.sort_by { |_, ts| -ts }.first(GitAncestry::ANCESTRY_DEPTH)
|
|
355
|
+
else
|
|
356
|
+
oldest_ts = ancestry_refs.values.min
|
|
357
|
+
refs
|
|
358
|
+
.select { |_, ts| ts >= oldest_ts }
|
|
359
|
+
.sort_by { |_, ts| -ts }
|
|
360
|
+
.first(GitAncestry::ANCESTRY_DEPTH)
|
|
361
|
+
end
|
|
362
|
+
bounded.to_h
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Retention knob routing per approved scope:
|
|
366
|
+
# - `cache_retention_count` / `cache_retention_duration`
|
|
367
|
+
# apply to the main tier only. Main accumulates linearly;
|
|
368
|
+
# these cap it.
|
|
369
|
+
# - `cache_retention_pr_branch_ttl` applies to PR tier only.
|
|
370
|
+
# PR branches die after merge; TTL prunes the whole branch
|
|
371
|
+
# prefix when it's been idle.
|
|
372
|
+
def maybe_prune(backend, ancestry)
|
|
373
|
+
return unless backend.respond_to?(:prune!)
|
|
374
|
+
|
|
375
|
+
opts = retention_opts_for(ancestry)
|
|
376
|
+
return if opts.values.all?(&:nil?)
|
|
377
|
+
|
|
378
|
+
removed = backend.prune!(**opts)
|
|
379
|
+
@logger.debug "rspec-tracer remote_cache: pruned #{removed} refs" if removed.positive?
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Internal method on the tracer pipeline.
|
|
383
|
+
# @api private
|
|
384
|
+
def retention_opts_for(ancestry)
|
|
385
|
+
if ancestry.pr_build?
|
|
386
|
+
{ count: nil, duration_seconds: nil,
|
|
387
|
+
pr_branch_ttl_seconds: safe_config(:cache_retention_pr_branch_ttl_seconds) }
|
|
388
|
+
else
|
|
389
|
+
{ count: safe_config(:cache_retention_count),
|
|
390
|
+
duration_seconds: safe_config(:cache_retention_duration_seconds),
|
|
391
|
+
pr_branch_ttl_seconds: nil }
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Internal method on the tracer pipeline.
|
|
396
|
+
# @api private
|
|
397
|
+
def maybe_warn_unbounded(backend)
|
|
398
|
+
return unless backend.respond_to?(:unbounded_warning)
|
|
399
|
+
# Only meaningful on the main tier - PR tier gets branch-TTL
|
|
400
|
+
# retention and is bounded by branch lifecycle.
|
|
401
|
+
return if safe_config(:cache_retention_count)
|
|
402
|
+
return if safe_config(:cache_retention_duration_seconds)
|
|
403
|
+
|
|
404
|
+
warning = backend.unbounded_warning
|
|
405
|
+
@logger.warn(warning) if warning
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Internal method on the tracer pipeline.
|
|
409
|
+
# @api private
|
|
410
|
+
def safe_config(method)
|
|
411
|
+
@config.public_send(method)
|
|
412
|
+
rescue NoMethodError
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Resolve HEAD's tree SHA via `git rev-parse HEAD^{tree}` for
|
|
417
|
+
# forwarding to the backend's tree-SHA secondary index. Same
|
|
418
|
+
# shell-invocation shape as `GitAncestry`'s `git rev-parse` calls,
|
|
419
|
+
# but graceful nil instead of raising: tree_sha is best-effort.
|
|
420
|
+
# When git is unavailable, the rev-parse fails, or HEAD doesn't
|
|
421
|
+
# exist (shallow clone, fresh repo), nil signals "skip the tree-
|
|
422
|
+
# SHA index" to the backend - S3Backend treats nil as no-op and
|
|
423
|
+
# falls through to the standard commit-SHA lookup; LocalFs / Redis
|
|
424
|
+
# accept the kwarg as a no-op.
|
|
425
|
+
def head_tree_sha
|
|
426
|
+
output = `git rev-parse HEAD^{tree} 2>/dev/null`.chomp
|
|
427
|
+
return nil unless $CHILD_STATUS&.success?
|
|
428
|
+
return nil if output.empty?
|
|
429
|
+
|
|
430
|
+
output
|
|
431
|
+
rescue StandardError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
@@ -1,71 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
module RemoteCache
|
|
5
|
-
class Validator
|
|
6
|
-
class ValidationError < StandardError; end
|
|
7
|
-
|
|
8
|
-
CACHE_FILES_PER_TEST_SUITE = 11
|
|
9
|
-
|
|
10
|
-
def initialize
|
|
11
|
-
@test_suite_id = ENV.fetch('TEST_SUITE_ID', nil)
|
|
12
|
-
@test_suites = ENV.fetch('TEST_SUITES', nil)
|
|
13
|
-
@use_test_suite_id_cache = ENV.fetch('USE_TEST_SUITE_ID_CACHE', nil) == 'true'
|
|
14
|
-
|
|
15
|
-
if @test_suite_id.nil? ^ @test_suites.nil?
|
|
16
|
-
raise(
|
|
17
|
-
ValidationError,
|
|
18
|
-
'Both the environment variables TEST_SUITE_ID and TEST_SUITES are not set'
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
setup
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def valid?(ref, cache_files)
|
|
26
|
-
if @use_test_suite_id_cache
|
|
27
|
-
test_suite_id_specific_validation?(ref, cache_files)
|
|
28
|
-
else
|
|
29
|
-
general_validation?(ref, cache_files)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
3
|
+
require 'json'
|
|
34
4
|
|
|
35
|
-
|
|
36
|
-
if @test_suites.nil?
|
|
37
|
-
@last_run_files_count = 1
|
|
38
|
-
@last_run_files_regex = '/%<ref>s/last_run.json$'
|
|
39
|
-
@cached_files_count = CACHE_FILES_PER_TEST_SUITE
|
|
40
|
-
@cached_files_regex = '/%<ref>s/[0-9a-f]{32}/.+.json$'
|
|
41
|
-
else
|
|
42
|
-
@test_suites = @test_suites.to_i
|
|
43
|
-
@test_suites_regex = (1..@test_suites).to_a.join('|')
|
|
5
|
+
require_relative '../storage/schema'
|
|
44
6
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
7
|
+
module RSpecTracer
|
|
8
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
9
|
+
# @api private
|
|
10
|
+
module RemoteCache
|
|
11
|
+
# Cache validator. Replaces 1.x's CACHE_FILES_PER_TEST_SUITE=11
|
|
12
|
+
# file-count check, which broke under any FILENAMES change (v2
|
|
13
|
+
# grew from 11 to 15 files across Phase 3-6, so the old validator
|
|
14
|
+
# refused every v2 cache).
|
|
15
|
+
#
|
|
16
|
+
# New signal: `schema_version` in `last_run.json`. The storage
|
|
17
|
+
# backend writes `Storage::Schema::CURRENT` on every save; this
|
|
18
|
+
# validator accepts only `Storage::Schema::SUPPORTED` values. Same
|
|
19
|
+
# policy as `Storage::JsonBackend#load_graph`: mismatch means "cold
|
|
20
|
+
# run," no migrators, one free cold run on upgrade.
|
|
21
|
+
#
|
|
22
|
+
# Atomicity note: `last_run.json` is written last via tmp+rename
|
|
23
|
+
# (see `Storage::JsonBackend#write_last_run_atomic`). If
|
|
24
|
+
# `last_run.json` exists, every other file in the run was present
|
|
25
|
+
# at write time. So the file-count sanity check 1.x did was already
|
|
26
|
+
# redundant with the atomicity guarantee; we drop it cleanly.
|
|
27
|
+
module Validator
|
|
28
|
+
# True when the given parsed last_run manifest is acceptable to
|
|
29
|
+
# this tracer version. Missing / unparseable / wrong-shape inputs
|
|
30
|
+
# all return false without raising.
|
|
31
|
+
def self.valid?(manifest)
|
|
32
|
+
return false unless manifest.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
RSpecTracer::Storage::Schema.supported?(manifest['schema_version'])
|
|
60
35
|
end
|
|
61
36
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
37
|
+
# Read + parse + validate a last_run.json file on disk. Returns
|
|
38
|
+
# true iff the file exists, parses as JSON, has a Hash root, and
|
|
39
|
+
# carries a supported schema_version. Any I/O or parse failure
|
|
40
|
+
# (missing file -> Errno::ENOENT, unreadable -> Errno::EACCES,
|
|
41
|
+
# malformed JSON -> JSON::ParserError) is caught by the rescue
|
|
42
|
+
# below and degraded to false.
|
|
43
|
+
def self.valid_file?(path)
|
|
44
|
+
valid?(JSON.parse(File.read(path, encoding: 'UTF-8')))
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
69
47
|
end
|
|
70
48
|
end
|
|
71
49
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point for the `rspec-tracer/remote_cache` namespace. Loaded
|
|
4
|
+
# lazily by the user-facing Rakefile shim at
|
|
5
|
+
# `lib/rspec_tracer/remote_cache/Rakefile`, not by the main
|
|
6
|
+
# `rspec_tracer` gem load. Test-suite runs that never invoke the
|
|
7
|
+
# remote-cache Rake tasks pay zero cost for this subtree.
|
|
8
|
+
|
|
9
|
+
require_relative 'remote_cache/backend'
|
|
10
|
+
require_relative 'remote_cache/validator'
|
|
11
|
+
require_relative 'remote_cache/git_ancestry'
|
|
12
|
+
require_relative 'remote_cache/local_fs_backend'
|
|
13
|
+
require_relative 'remote_cache/redis_backend'
|
|
14
|
+
require_relative 'remote_cache/s3_backend'
|
|
15
|
+
require_relative 'remote_cache/user_tasks'
|
|
16
|
+
|
|
17
|
+
module RSpecTracer
|
|
18
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
19
|
+
# @api private
|
|
20
|
+
module RemoteCache
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Reporters
|
|
2
|
+
|
|
3
|
+
Output formatters for tracer results. Each reporter is pluggable via
|
|
4
|
+
`config.add_reporter`; the tracer engine functions with zero reporters
|
|
5
|
+
attached. Reporters sit above the Tracker + Storage layers, consume
|
|
6
|
+
the finalized `Storage::Snapshot`, and emit to `report_dir`.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
| File | Role |
|
|
11
|
+
|-------------------------|------------------------------------------------------------------------|
|
|
12
|
+
| `base.rb` | Abstract `Reporters::Base` with `initialize(snapshot:, report_dir:, run_metadata:, logger:, **opts)`, `generate`, `no_op?`. |
|
|
13
|
+
| `payload_builder.rb` | `Reporters::PayloadBuilder` — shared schema-v1 payload builder consumed by both JSON and HTML. |
|
|
14
|
+
| `json_reporter.rb` | `Reporters::JsonReporter` — writes `report_dir/report.json` with schema version 1; 5 report types. |
|
|
15
|
+
| `terminal_reporter.rb` | `Reporters::TerminalReporter` — concise stdout summary (≤ 5 lines); respects `NO_COLOR`. |
|
|
16
|
+
| `html_reporter.rb` | `Reporters::HtmlReporter` — writes `report_dir/index.html` (Preact bundle + server-rendered fallback tables). |
|
|
17
|
+
| `registry.rb` | `Reporters::Registry` — resolves configured reporters, rescues per-reporter, warns + continues on failure. |
|
|
18
|
+
| `html/` | Committed frontend toolchain (Preact + Vite). See [html/README.md](html/README.md). |
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# .rspec-tracer
|
|
24
|
+
add_reporter :terminal
|
|
25
|
+
add_reporter :json
|
|
26
|
+
add_reporter MyCustomReporter, color: false
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Symbols resolve via `Registry::BUILT_INS` (`:terminal`, `:json`,
|
|
30
|
+
`:html`). Class values pass through — must duck-type
|
|
31
|
+
`Reporters::Base`. If no `add_reporter` calls are made, `Registry`
|
|
32
|
+
defaults to `[:terminal, :json, :html]`.
|
|
33
|
+
|
|
34
|
+
## JSON schema (version 1)
|
|
35
|
+
|
|
36
|
+
`<report_dir>/report.json` envelope:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"schema_version": 1,
|
|
41
|
+
"run_id": "<hex>",
|
|
42
|
+
"generated_at": "<ISO-8601>",
|
|
43
|
+
"summary": { "total_examples": N, "passed_examples": N, "..." : "..." },
|
|
44
|
+
"reports": {
|
|
45
|
+
"all_examples": [...],
|
|
46
|
+
"duplicate_examples": [...],
|
|
47
|
+
"flaky_examples": [...],
|
|
48
|
+
"examples_dependency": [...],
|
|
49
|
+
"files_dependency": [...]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Additive fields on existing objects are non-breaking. Removing or
|
|
55
|
+
renaming a top-level key bumps `SCHEMA_VERSION`. Full field list
|
|
56
|
+
lives in `json_reporter.rb`'s documentation comment.
|
|
57
|
+
|
|
58
|
+
## Graceful degradation
|
|
59
|
+
|
|
60
|
+
Every reporter runs inside an isolated rescue in `Registry#emit_all`.
|
|
61
|
+
A raising reporter logs a warning via `configuration.logger.warn` and
|
|
62
|
+
emission continues with the next reporter. This matches the Storage
|
|
63
|
+
backend contract — a tracer failure never propagates a non-zero exit
|
|
64
|
+
into the user's test suite.
|
|
65
|
+
|
|
66
|
+
## Extension
|
|
67
|
+
|
|
68
|
+
Subclass `Reporters::Base`:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class MySlackReporter < RSpecTracer::Reporters::Base
|
|
72
|
+
def generate
|
|
73
|
+
return if no_op?
|
|
74
|
+
|
|
75
|
+
post_to_slack(snapshot, report_dir, run_metadata)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Register via `config.add_reporter MySlackReporter, webhook_url: ENV['SLACK_URL']`.
|
|
81
|
+
|
|
82
|
+
## HTML reporter
|
|
83
|
+
|
|
84
|
+
`HtmlReporter` emits `<report_dir>/index.html` plus a sibling
|
|
85
|
+
`assets/` directory containing the pre-built bundle (Preact + CSS).
|
|
86
|
+
The frontend source + build tooling live in
|
|
87
|
+
[`html/`](html/README.md); `dist/` is committed so users never run
|
|
88
|
+
`npm`. Rebuild maintainer-side via `task reporters:html:build`.
|
|
89
|
+
|
|
90
|
+
The reporter renders two layers:
|
|
91
|
+
|
|
92
|
+
1. A `<script id="report-data" type="application/json">` payload
|
|
93
|
+
built by `PayloadBuilder` (same payload JSON reporter writes,
|
|
94
|
+
minus the pretty-print).
|
|
95
|
+
2. Server-rendered fallback `<table>` elements for every report
|
|
96
|
+
type. If JavaScript is disabled or the bundle fails to load,
|
|
97
|
+
these stay in the DOM and remain readable; when Preact hydrates,
|
|
98
|
+
the bundle removes the fallback and renders the interactive
|
|
99
|
+
view.
|
|
100
|
+
|
|
101
|
+
This two-layer approach is load-bearing for the "works without
|
|
102
|
+
JavaScript" AC — the reporter output is a usable read even in
|
|
103
|
+
degraded environments.
|