specguard-rspec 0.2.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/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +735 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-lint +34 -0
- data/lib/specguard/rspec/annotation_lookup.rb +286 -0
- data/lib/specguard/rspec/annotation_scanner.rb +144 -0
- data/lib/specguard/rspec/cli.rb +466 -0
- data/lib/specguard/rspec/configuration.rb +459 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +63 -0
- data/lib/specguard/rspec/formatter.rb +919 -0
- data/lib/specguard/rspec/json_reporter.rb +163 -0
- data/lib/specguard/rspec/linter.rb +134 -0
- data/lib/specguard/rspec/payload_normalizer.rb +139 -0
- data/lib/specguard/rspec/scanner.rb +217 -0
- data/lib/specguard/rspec/schema.rb +122 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +310 -0
- data/lib/specguard/rspec/validator_backend.rb +1071 -0
- data/lib/specguard/rspec/version.rb +7 -0
- data/lib/specguard/rspec/violation_renderer.rb +372 -0
- data/lib/specguard/rspec.rb +70 -0
- data/script/bump-version.sh +115 -0
- metadata +88 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The formatter half of the client gem: it watches an RSpec run and records
|
|
4
|
+
# every example that finished — its name, where it lives, how long it took and
|
|
5
|
+
# how it ended — as one JSON object per run.
|
|
6
|
+
#
|
|
7
|
+
# == Why this file is not on `require "specguard/rspec"`'s chain
|
|
8
|
+
#
|
|
9
|
+
# `rspec` is a **development** dependency of this gem, not a runtime one
|
|
10
|
+
# (specguard-rspec.gemspec declares exactly one runtime dependency:
|
|
11
|
+
# json_schemer). `bin/specguard-lint` loads `specguard/rspec`, and it has to
|
|
12
|
+
# keep working for someone who installed the gem to lint annotations on a
|
|
13
|
+
# machine with no RSpec at all. Putting `require "rspec/core"` on that chain
|
|
14
|
+
# would turn a missing test framework into a broken linter.
|
|
15
|
+
#
|
|
16
|
+
# So the formatter is opt-in, by its own path:
|
|
17
|
+
#
|
|
18
|
+
# # spec/spec_helper.rb
|
|
19
|
+
# require "specguard/rspec/formatter"
|
|
20
|
+
# RSpec.configure { |config| config.add_formatter(SpecGuard::RSpecFormatter) }
|
|
21
|
+
#
|
|
22
|
+
# # ...or, equivalently, in .rspec
|
|
23
|
+
# --require specguard/rspec/formatter
|
|
24
|
+
# --format SpecGuard::RSpecFormatter
|
|
25
|
+
#
|
|
26
|
+
# Neither form names a human formatter, and neither has to: see {#seed} for how
|
|
27
|
+
# the one RSpec would have installed is kept, why registering this class would
|
|
28
|
+
# otherwise take it away, and {#message} for the second listener that keeping it
|
|
29
|
+
# would otherwise leave on the stream.
|
|
30
|
+
#
|
|
31
|
+
# The `--require` is not optional in the `.rspec` form: RSpec resolves a
|
|
32
|
+
# `--format` argument by constant lookup and only then guesses a file name to
|
|
33
|
+
# require, and the name it guesses for this class is `spec_guard/r_spec_formatter`.
|
|
34
|
+
#
|
|
35
|
+
# spec/specguard/rspec/formatter_loading_spec.rb pins the separation in both
|
|
36
|
+
# directions.
|
|
37
|
+
require "rspec/core"
|
|
38
|
+
require "rspec/core/formatters/base_formatter"
|
|
39
|
+
require "json"
|
|
40
|
+
require "fileutils"
|
|
41
|
+
|
|
42
|
+
require_relative "configuration"
|
|
43
|
+
require_relative "transport"
|
|
44
|
+
# Brings the linter's discovery chain with it (Scanner, Finding, Schema). That
|
|
45
|
+
# direction is safe — `specguard/rspec` does not require `rspec/core`, so the
|
|
46
|
+
# linter stays loadable without RSpec; it is only the reverse that would break
|
|
47
|
+
# packaging.
|
|
48
|
+
require_relative "annotation_lookup"
|
|
49
|
+
|
|
50
|
+
module SpecGuard
|
|
51
|
+
# == A NOTE ON CONSTANT RESOLUTION — read this before editing
|
|
52
|
+
#
|
|
53
|
+
# `SpecGuard::RSpec` is *this gem's own namespace*. Inside a `module SpecGuard`
|
|
54
|
+
# body an unqualified `RSpec::Core::Formatters` therefore resolves to
|
|
55
|
+
# `SpecGuard::RSpec::Core::Formatters` and dies with
|
|
56
|
+
# `NameError: uninitialized constant SpecGuard::RSpec::Core` — on the very
|
|
57
|
+
# first line of the class definition, before anything else can go wrong.
|
|
58
|
+
#
|
|
59
|
+
# Every reference to the real RSpec below is consequently top-level-qualified
|
|
60
|
+
# with `::`. It is also why this class is `SpecGuard::RSpecFormatter`, a
|
|
61
|
+
# *sibling* of `SpecGuard::RSpec` rather than a member of it: the sibling name
|
|
62
|
+
# keeps the two namespaces from shadowing each other for readers as well as
|
|
63
|
+
# for the interpreter.
|
|
64
|
+
#
|
|
65
|
+
# == What it captures, and for whom
|
|
66
|
+
#
|
|
67
|
+
# Every example, annotated or not. That is the whole point: SpecGuard's
|
|
68
|
+
# premise is that an unannotated test is an anonymous coordinate, and you
|
|
69
|
+
# cannot report on a gap you never recorded. Filtering to the annotated
|
|
70
|
+
# minority here would make the very first run of a new adopter look empty.
|
|
71
|
+
#
|
|
72
|
+
# id example.id — this example's identity within the run
|
|
73
|
+
# spec_file_path the spec file that *ran* the example, relative to the root
|
|
74
|
+
# file_path example.metadata[:file_path], relative to the project root
|
|
75
|
+
# line_number example.metadata[:line_number]
|
|
76
|
+
# name example.full_description — the composed describe/context/it
|
|
77
|
+
# duration example.execution_result.run_time, seconds, per example
|
|
78
|
+
# outcome example.execution_result.status — passed / failed / pending
|
|
79
|
+
# status "annotated" / "unannotated" — see {AnnotationLookup}
|
|
80
|
+
# intent the parsed annotation when annotated, null when not
|
|
81
|
+
#
|
|
82
|
+
# `status` is not decoration: `Ingest::Payload` validates it against a
|
|
83
|
+
# two-value enum for *every* spec and collects the failures globally, so a
|
|
84
|
+
# payload missing the key is not a payload with a gap — it is a 400 with one
|
|
85
|
+
# error per example.
|
|
86
|
+
#
|
|
87
|
+
# == Why `id` and `spec_file_path` exist alongside the coordinate
|
|
88
|
+
#
|
|
89
|
+
# `(file_path, line_number)` is the coordinate of the **code**, not of the
|
|
90
|
+
# **example**, and two entirely ordinary suite shapes put several examples on
|
|
91
|
+
# one coordinate:
|
|
92
|
+
#
|
|
93
|
+
# * a table-driven loop — `CASES.each { |c| it("...#{c}") { ... } }` writes
|
|
94
|
+
# the `it` once, so all N examples report the same line;
|
|
95
|
+
# * a shared example group — every including file reports the coordinate of
|
|
96
|
+
# `spec/support/shared.rb`, and the file that actually ran the example
|
|
97
|
+
# appears nowhere at all.
|
|
98
|
+
#
|
|
99
|
+
# Measured on a probe suite of a 3-case loop plus a 2-example shared group
|
|
100
|
+
# included by two files: 7 examples, 3 distinct coordinates. A key that folds
|
|
101
|
+
# three examples onto one row cannot carry a per-example duration, cannot
|
|
102
|
+
# follow one test's outcome across runs, and hands the duplicate-cluster
|
|
103
|
+
# surface rows that look identical because the *key* collapsed them — a
|
|
104
|
+
# manufactured duplicate in the product's headline answer.
|
|
105
|
+
#
|
|
106
|
+
# RSpec already ships the fix. `Example#id` is
|
|
107
|
+
# `"#{metadata[:rerun_file_path]}[#{metadata[:scoped_id]}]"`
|
|
108
|
+
# (rspec-core 3.13.6, `example.rb:117` → `metadata.rb:105`), it is rooted at
|
|
109
|
+
# the *including* file rather than the defining one, and it is also RSpec's
|
|
110
|
+
# own re-run argument — so a row that turns up slow or flaky is directly
|
|
111
|
+
# actionable: `rspec './spec/table_spec.rb[1:2]'`.
|
|
112
|
+
#
|
|
113
|
+
# **`id` is unique within a run, not stable across refactors.** `scoped_id` is
|
|
114
|
+
# positional, so reordering examples changes it — exactly as `line_number`
|
|
115
|
+
# changes when a line is inserted above it. It is the run-local primary key
|
|
116
|
+
# and nothing more; matching one test across runs remains `name` plus file.
|
|
117
|
+
#
|
|
118
|
+
# `spec_file_path` is `metadata[:rerun_file_path]`, which RSpec defaults to
|
|
119
|
+
# the defining file (`metadata.rb:160`) and overrides with the *including*
|
|
120
|
+
# file for a shared example. It is therefore equal to `file_path` for an
|
|
121
|
+
# ordinary example and differs only for a shared one — which is what makes
|
|
122
|
+
# duration-by-file aggregate to the file that ran the test rather than to a
|
|
123
|
+
# `spec/support/` helper.
|
|
124
|
+
#
|
|
125
|
+
# `file_path` and `line_number` keep their existing meaning — the definition
|
|
126
|
+
# site — and are deliberately not repurposed: the annotation lookup reads
|
|
127
|
+
# `@intent:` from exactly that line, so they are the coordinate the intent
|
|
128
|
+
# came from.
|
|
129
|
+
#
|
|
130
|
+
# == Where the run goes
|
|
131
|
+
#
|
|
132
|
+
# `api_key` set → one `POST <endpoint>/api/v1/ingest`.
|
|
133
|
+
# `api_key` unset → appended to `output_path`, one JSON object per line.
|
|
134
|
+
#
|
|
135
|
+
# The credential is the switch on purpose: local development is then the
|
|
136
|
+
# default and needs no opt-out. See {#deliver} for what happens when the POST
|
|
137
|
+
# does not land.
|
|
138
|
+
#
|
|
139
|
+
# A malformed or schema-invalid annotation is recorded as `unannotated` with a
|
|
140
|
+
# null intent rather than shipped or shouted about; {AnnotationLookup}
|
|
141
|
+
# documents why, and the linter is the half of this gem that tells the author.
|
|
142
|
+
#
|
|
143
|
+
# == The never-block-CI contract, and why it lives *here*
|
|
144
|
+
#
|
|
145
|
+
# SpecGuard's non-negotiable is that telemetry never fails a build. It is
|
|
146
|
+
# tempting to read that as a property of the *transport* — a rescue around
|
|
147
|
+
# the POST — but RSpec does not sandbox formatters, so it is a property of
|
|
148
|
+
# the **capture layer** too. A raise in any of the three hooks below escapes
|
|
149
|
+
# `RSpec::Core::Runner.run` entirely; RSpec's own exit code is then never
|
|
150
|
+
# returned at all, and the process exits 1. Probed against rspec-core 3.13.6:
|
|
151
|
+
#
|
|
152
|
+
# hook=example_finished process_exit=1 rspec's exit status: never reached
|
|
153
|
+
# hook=stop process_exit=1 rspec's exit status: never reached
|
|
154
|
+
# hook=close process_exit=1 rspec's exit status: never reached
|
|
155
|
+
#
|
|
156
|
+
# In the `close` case the suite had already printed `2 examples, 0 failures`
|
|
157
|
+
# and the process still exited 1 — a green suite turned red by telemetry. A
|
|
158
|
+
# `nil` line number, an unwritable `log/`, a full disk: each of those is
|
|
159
|
+
# somebody's broken build, for tests that all passed.
|
|
160
|
+
#
|
|
161
|
+
# {#seed} was added after that probe and is guarded for the same reason, with
|
|
162
|
+
# the stakes slightly higher: it is dispatched from `Reporter#start`, before
|
|
163
|
+
# any example has run, so a raise there costs the whole suite rather than its
|
|
164
|
+
# exit code.
|
|
165
|
+
#
|
|
166
|
+
# So every hook body runs inside {#never_fail_the_run}, which swallows
|
|
167
|
+
# `StandardError` *and* `ScriptError` (an autoload blowing up is not a
|
|
168
|
+
# StandardError, and a bare rescue would miss it), warns once, and returns.
|
|
169
|
+
# `Interrupt`, `SignalException` and `SystemExit` are deliberately not caught:
|
|
170
|
+
# Ctrl-C must stay Ctrl-C.
|
|
171
|
+
#
|
|
172
|
+
# spec/specguard/rspec/formatter_spec.rb fails if any of those rescues is
|
|
173
|
+
# removed.
|
|
174
|
+
class RSpecFormatter < ::RSpec::Core::Formatters::BaseFormatter
|
|
175
|
+
# `:seed` and `:message` are not hooks this formatter has anything to say
|
|
176
|
+
# about; both are here for the *human* half of the output.
|
|
177
|
+
#
|
|
178
|
+
# `:seed` is the earliest notification of a run, and {#seed} uses it as the
|
|
179
|
+
# moment to repair RSpec's default-formatter suppression.
|
|
180
|
+
#
|
|
181
|
+
# `:message` is registered for the sake of the registration itself.
|
|
182
|
+
# `setup_default` installs a `FallbackMessageFormatter` unless some already
|
|
183
|
+
# registered formatter listens for `:message` (`formatters.rb:133-135`, via
|
|
184
|
+
# `existing_formatter_implements?` → `reporter.registered_listeners`), and
|
|
185
|
+
# that fallback would then print every message a *second* time alongside the
|
|
186
|
+
# formatter {#seed} restores. Listening here suppresses it and takes over its
|
|
187
|
+
# job — see {#message}, which is that same fallback under this class's roof.
|
|
188
|
+
::RSpec::Core::Formatters.register self, :example_finished, :stop, :close, :seed, :message
|
|
189
|
+
|
|
190
|
+
# Prefixed so it is obvious in a CI log which tool is talking, and worded so
|
|
191
|
+
# a reader knows immediately that it is not their tests that broke.
|
|
192
|
+
WARNING_PREFIX = "SpecGuard: test telemetry failed and was skipped"
|
|
193
|
+
|
|
194
|
+
# The other half of that: the run was captured fine, the *delivery* did not
|
|
195
|
+
# land. Kept distinct from {WARNING_PREFIX} because the reader's situation
|
|
196
|
+
# is different — nothing was lost, the payload is sitting in a file, and the
|
|
197
|
+
# thing to fix is a key or a URL rather than a broken sink.
|
|
198
|
+
DELIVERY_WARNING_PREFIX = "SpecGuard: could not deliver test telemetry"
|
|
199
|
+
|
|
200
|
+
# The third shape, and the only one that reports a *deliberate* refusal
|
|
201
|
+
# rather than something going wrong. Nothing failed and nothing is
|
|
202
|
+
# recoverable from a file, because there was nothing worth keeping — see
|
|
203
|
+
# {#dry_run?}. It still gets said out loud: a user who wired the formatter
|
|
204
|
+
# up and then saw neither a POST nor a line would otherwise have to go
|
|
205
|
+
# looking for a bug that is not there.
|
|
206
|
+
DRY_RUN_WARNING_PREFIX = "SpecGuard: skipped test telemetry for a dry run"
|
|
207
|
+
|
|
208
|
+
# `Ingest::Payload::STATUSES`, restated. The platform validates every spec
|
|
209
|
+
# against this pair (`payload.rb:17`), so they are the contract and not a
|
|
210
|
+
# local naming choice.
|
|
211
|
+
STATUS_ANNOTATED = "annotated"
|
|
212
|
+
STATUS_UNANNOTATED = "unannotated"
|
|
213
|
+
|
|
214
|
+
# The published formatter-protocol methods that mean "this formatter tells a
|
|
215
|
+
# human what the suite did", as opposed to RSpec's own auxiliaries, which
|
|
216
|
+
# speak only about deprecations and timings. {#reports_the_run?} is where
|
|
217
|
+
# this set is justified, and where the `respond_to?` approximation's limits
|
|
218
|
+
# are written down.
|
|
219
|
+
REPORTS_THE_RUN = %i[
|
|
220
|
+
example_started example_passed example_failed example_pending dump_summary
|
|
221
|
+
].freeze
|
|
222
|
+
|
|
223
|
+
# @param output [IO] the stream RSpec hands every formatter. This class's
|
|
224
|
+
# product is a file, so nothing in the capture path writes here — but the
|
|
225
|
+
# stream is *not* unused, and a reader who is here because stdout went
|
|
226
|
+
# wrong is in the right place. {#message} writes to it, via
|
|
227
|
+
# {#relay_message} below: having taken over the `FallbackMessageFormatter`
|
|
228
|
+
# RSpec would otherwise have appointed, this formatter owes that
|
|
229
|
+
# formatter's duty, and printing a message no other registered formatter
|
|
230
|
+
# will print is precisely its job. That is the only write.
|
|
231
|
+
#
|
|
232
|
+
# The `nil` default has no caller. RSpec always supplies a stream, and the
|
|
233
|
+
# two direct constructions in the repo — both in `formatter_spec.rb` —
|
|
234
|
+
# pass one explicitly, so nothing exercises it. It survives because
|
|
235
|
+
# narrowing a public constructor's signature is not this slice's business,
|
|
236
|
+
# not because anything depends on it. It cannot reach
|
|
237
|
+
# `output.puts`: {#relay_message} returns early unless this object is in
|
|
238
|
+
# `RSpec.configuration.formatters`, which only RSpec puts it in, and doing
|
|
239
|
+
# so means RSpec built it. A `nil` that somehow got there would raise
|
|
240
|
+
# inside {#never_fail_the_run} and downgrade to a warning rather than
|
|
241
|
+
# taking the suite with it.
|
|
242
|
+
# @param error_stream [IO] where the one-shot warning goes. Injectable so a
|
|
243
|
+
# spec can read it back without reassigning `$stderr` globally.
|
|
244
|
+
# @param annotations [AnnotationLookup] resolves each example's `@intent:`.
|
|
245
|
+
# One per run: it is where the per-file scan is memoized, so sharing it
|
|
246
|
+
# across the run is what makes the cost O(files) rather than O(examples).
|
|
247
|
+
#
|
|
248
|
+
# Fully qualified for the same reason every `::RSpec` above is — and it is
|
|
249
|
+
# the same trap seen from the other side. This class is a *sibling* of
|
|
250
|
+
# `SpecGuard::RSpec`, not a member, so a bare `AnnotationLookup` here is
|
|
251
|
+
# looked up as `SpecGuard::RSpecFormatter::AnnotationLookup` and then
|
|
252
|
+
# `SpecGuard::AnnotationLookup`, neither of which exists — a NameError
|
|
253
|
+
# raised from a formatter's constructor, which RSpec reports as
|
|
254
|
+
# "No examples found".
|
|
255
|
+
def initialize(output = nil, error_stream: $stderr,
|
|
256
|
+
annotations: SpecGuard::RSpec::AnnotationLookup.new)
|
|
257
|
+
super(output)
|
|
258
|
+
@error_stream = error_stream
|
|
259
|
+
@annotations = annotations
|
|
260
|
+
@specs = []
|
|
261
|
+
@warned = false
|
|
262
|
+
# Stamped here rather than from a `start` hook on purpose. `:start` is not
|
|
263
|
+
# in this formatter's own registered set — it arrives only because
|
|
264
|
+
# BaseFormatter registered for it — and the wall clock a CI operator cares
|
|
265
|
+
# about includes loading the spec files, which happens before `start`
|
|
266
|
+
# fires. `duration_seconds` is therefore a superset of RSpec's own
|
|
267
|
+
# "Finished in N seconds", not a contradiction of it.
|
|
268
|
+
@started_at = monotonic_now
|
|
269
|
+
@duration_seconds = nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The first notification of the run — and the one hook here that exists for
|
|
273
|
+
# the *human* half of the output rather than the telemetry half.
|
|
274
|
+
#
|
|
275
|
+
# == The defect this repairs
|
|
276
|
+
#
|
|
277
|
+
# RSpec installs its own default formatter only if the user registered no
|
|
278
|
+
# formatter at all (rspec-core 3.13.6,
|
|
279
|
+
# `lib/rspec/core/formatters.rb:127`):
|
|
280
|
+
#
|
|
281
|
+
# add default_formatter, output_stream if @formatters.empty?
|
|
282
|
+
#
|
|
283
|
+
# Registering *this* formatter makes that list non-empty, so `progress` is
|
|
284
|
+
# never added — and since this formatter's product is a file, the run prints
|
|
285
|
+
# **nothing**. Measured on `formatter_run_spec.rb`'s `MIXED_SUITE`: ~900
|
|
286
|
+
# bytes of stdout without SpecGuard, **0** with it. No dots, no failure
|
|
287
|
+
# message, no diff, no `file:line`, no re-run command, and still exit 1. The
|
|
288
|
+
# telemetry was written perfectly (1 line, all 3 specs); the developer was
|
|
289
|
+
# left blind.
|
|
290
|
+
#
|
|
291
|
+
# (Two rules for the byte counts in this class's comments, both learned the
|
|
292
|
+
# hard way. **Name the suite** — they come from different ones and are not
|
|
293
|
+
# comparable across methods; this paragraph's and {#reports_the_run?}'s are
|
|
294
|
+
# `MIXED_SUITE`, {#message}'s are `NON_EXAMPLE_ERROR_SUITE`, both defined in
|
|
295
|
+
# `formatter_run_spec.rb`. And **prefer a delta or a count to a total**: a
|
|
296
|
+
# total carries the run's wall-clock digits, so it is not reproducible even
|
|
297
|
+
# on the same suite — `MIXED_SUITE`'s control measured 955 and 956 bytes on
|
|
298
|
+
# consecutive invocations, and `NON_EXAMPLE_ERROR_SUITE`'s 333 through 335.
|
|
299
|
+
# Hence the `~` above, and hence the figures that carry the argument
|
|
300
|
+
# elsewhere are deltas — 0, a 27-byte hole, one error block versus two.)
|
|
301
|
+
#
|
|
302
|
+
# It hit exactly the wrong person. The `.rspec` wiring escaped it only
|
|
303
|
+
# because the README spelled it with a second `--format progress` line, and
|
|
304
|
+
# a developer who explicitly chose `--format documentation` was unaffected —
|
|
305
|
+
# so the casualty was the reader who followed the README's first wiring
|
|
306
|
+
# block and customised nothing. (That `--format progress` line is gone from
|
|
307
|
+
# the README now: with this repair in place neither documented form needs
|
|
308
|
+
# it, which is what finally makes the two forms equivalent.)
|
|
309
|
+
#
|
|
310
|
+
# == Why here, and not in the user's `spec_helper`
|
|
311
|
+
#
|
|
312
|
+
# The obvious repair — `config.add_formatter(:progress) if
|
|
313
|
+
# config.formatters.empty?` inside `RSpec.configure` — is wrong, and
|
|
314
|
+
# silently so. `configuration_options.rb:21-25` applies `--require` (i.e.
|
|
315
|
+
# `spec_helper.rb`) *before* `--format`, so at that moment the list is `[]`
|
|
316
|
+
# no matter what the user asked for. Probed directly: with `.rspec` set to
|
|
317
|
+
# `--format documentation`, `config.formatters` still returns `[]` inside
|
|
318
|
+
# the helper. That repair gives the documentation user documentation *and*
|
|
319
|
+
# progress dots.
|
|
320
|
+
#
|
|
321
|
+
# `:seed` is the first notification `Reporter#start` sends
|
|
322
|
+
# (`reporter.rb:92`, ahead of `:start` on :93), and `Reporter#notify` runs
|
|
323
|
+
# `ensure_listeners_ready` — hence `setup_default` — before dispatching
|
|
324
|
+
# anything (`reporter.rb:207-208`). So by the time this runs, RSpec has
|
|
325
|
+
# finished deciding, and the decision can be read rather than predicted.
|
|
326
|
+
# Repairing here rather than in `start` is what keeps the output
|
|
327
|
+
# byte-identical: a formatter added during `:seed` still receives `:start`,
|
|
328
|
+
# and the only notification it misses *from `Reporter#start` onwards* is
|
|
329
|
+
# this one, which is forwarded to it by hand below. (Not "the only
|
|
330
|
+
# notification it misses" flatly — a `:message` can arrive before
|
|
331
|
+
# `Reporter#report` is ever entered, which is a real case and is {#message}'s
|
|
332
|
+
# to handle, not this method's.)
|
|
333
|
+
#
|
|
334
|
+
# The forwarding is not decoration, and it is worth knowing what goes if it
|
|
335
|
+
# goes: `:seed` is what prints `Randomized with seed N`, so without it a
|
|
336
|
+
# randomly-ordered run loses its *head* banner and keeps only the one RSpec
|
|
337
|
+
# re-sends at the end (`reporter.rb:189`). That is the line a developer
|
|
338
|
+
# needs to reproduce a flaky failure. Measured on `MIXED_SUITE` under
|
|
339
|
+
# `--order random`: banner twice both with and without this gem, and once if
|
|
340
|
+
# the forwarding loop is deleted (a 27-byte hole, the banner and its blank
|
|
341
|
+
# line). Under RSpec's *defined* ordering nothing prints a banner either
|
|
342
|
+
# way, which is why the parity example that pins this had to ask for random
|
|
343
|
+
# ordering explicitly.
|
|
344
|
+
#
|
|
345
|
+
# Arriving late is not the only way the restored formatter can diverge from
|
|
346
|
+
# one RSpec installed itself, and the second way cost a review round. By the
|
|
347
|
+
# time it is added, `setup_default` has already decided that nobody handles
|
|
348
|
+
# `:message` and installed a `FallbackMessageFormatter` to cover for it — so
|
|
349
|
+
# the newcomer, which does handle `:message`, became the *second* listener on
|
|
350
|
+
# that notification and every message printed twice. Measured on
|
|
351
|
+
# `NON_EXAMPLE_ERROR_SUITE`: one error block without SpecGuard, two with it
|
|
352
|
+
# (~335 bytes against ~546). {#message} is the fix, and it works
|
|
353
|
+
# by making `setup_default`'s premise true rather than by undoing its
|
|
354
|
+
# conclusion — there is no public way to withdraw a registered listener.
|
|
355
|
+
#
|
|
356
|
+
# == What counts as "the human formatter is missing"
|
|
357
|
+
#
|
|
358
|
+
# Not "the list is empty" — by now `setup_default` has appended RSpec's own
|
|
359
|
+
# `DeprecationFormatter` (and a `ProfileFormatter` under `--profile`). The
|
|
360
|
+
# question is whether any *other* registered formatter will give a human an
|
|
361
|
+
# account of the run, so that is what is asked: does it respond to any of
|
|
362
|
+
# `example_started`, `example_passed`, `example_failed`, `example_pending` or
|
|
363
|
+
# `dump_summary`? See {#reports_the_run?} for why that set, and for what the
|
|
364
|
+
# `respond_to?` approximation can and cannot see.
|
|
365
|
+
#
|
|
366
|
+
# Nothing here touches anything rspec-core marks `@private`: `formatters`,
|
|
367
|
+
# `add_formatter` and `default_formatter` are documented public API, and
|
|
368
|
+
# every method name in that set is part of the published formatter protocol
|
|
369
|
+
# (`formatters/protocol.rb`).
|
|
370
|
+
#
|
|
371
|
+
# `default_formatter` rather than a hard-coded `:progress`, for the same
|
|
372
|
+
# reason: a user who set `config.default_formatter = 'doc'` asked for that,
|
|
373
|
+
# and it is the value rspec-core's own line would have used.
|
|
374
|
+
def seed(notification)
|
|
375
|
+
never_fail_the_run { restore_suppressed_default_formatter(notification) }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# `RSpec::Core::Formatters::FallbackMessageFormatter`, moved inside this
|
|
379
|
+
# class — the other half of {#seed}'s repair, and the one that stops it
|
|
380
|
+
# printing everything twice.
|
|
381
|
+
#
|
|
382
|
+
# == Why this method has to exist at all
|
|
383
|
+
#
|
|
384
|
+
# `setup_default` ends with (`formatters.rb:133-135`):
|
|
385
|
+
#
|
|
386
|
+
# unless existing_formatter_implements?(:message)
|
|
387
|
+
# add FallbackMessageFormatter, output_stream
|
|
388
|
+
# end
|
|
389
|
+
#
|
|
390
|
+
# Somebody must print `reporter.message` output — a `--seed` banner, "No
|
|
391
|
+
# examples found.", and every non-example exception, since
|
|
392
|
+
# `notify_non_example_exception` routes through it (`reporter.rb:163-170`).
|
|
393
|
+
# `progress` and `documentation` do it via `BaseTextFormatter#message`; when
|
|
394
|
+
# no registered formatter listens for `:message`, RSpec appoints a fallback.
|
|
395
|
+
#
|
|
396
|
+
# Before this method, this class did not listen for `:message`, so the
|
|
397
|
+
# fallback was always appointed — and then {#seed} added `progress`, which
|
|
398
|
+
# listens for it too. Two listeners, one stream, every message twice. On
|
|
399
|
+
# `NON_EXAMPLE_ERROR_SUITE` the error block printed once without SpecGuard
|
|
400
|
+
# and twice with it, which is the claim that matters and the one the spec
|
|
401
|
+
# asserts; the streams were ~335 and ~546 bytes.
|
|
402
|
+
#
|
|
403
|
+
# The fallback cannot be withdrawn once appointed: `Reporter#register_listener`
|
|
404
|
+
# has no inverse, and `Configuration#formatters` hands back a `dup`
|
|
405
|
+
# (`configuration.rb:1024-1026`), so deleting from it changes nothing. The
|
|
406
|
+
# repair is therefore to stop it being appointed — this class listens for
|
|
407
|
+
# `:message`, `existing_formatter_implements?(:message)` is true, and the
|
|
408
|
+
# duty lands here instead.
|
|
409
|
+
#
|
|
410
|
+
# == When it actually prints
|
|
411
|
+
#
|
|
412
|
+
# Only when it is the last resort, which is exactly the fallback's own
|
|
413
|
+
# contract: if any *other* registered formatter handles `:message`, that one
|
|
414
|
+
# prints and this stays quiet. So the wirings behave identically to a run
|
|
415
|
+
# without this gem — `progress` prints it under the README's Ruby form once
|
|
416
|
+
# {#seed} has restored it, `documentation` prints it for a developer who
|
|
417
|
+
# asked for documentation, `json` swallows it into its hash exactly as it
|
|
418
|
+
# does on its own, and `--format html` (which has no `message`) gets it from
|
|
419
|
+
# here, just as it would have got it from the fallback.
|
|
420
|
+
#
|
|
421
|
+
# The check is made per message rather than cached, because a message can
|
|
422
|
+
# arrive **before** `:seed`: `Runner#setup` calls `world.announce_filters`,
|
|
423
|
+
# which reports "No examples found." through `reporter.message` before
|
|
424
|
+
# `Reporter#report` is ever entered. In that window this formatter really is
|
|
425
|
+
# the only listener, and it prints — which is what the fallback would have
|
|
426
|
+
# done.
|
|
427
|
+
#
|
|
428
|
+
# `respond_to?` is the public-API approximation of the question rspec-core
|
|
429
|
+
# answers with `registered_listeners(:message)`; {#reports_the_run?}
|
|
430
|
+
# documents how the two can differ. For `:message` specifically they agree
|
|
431
|
+
# across every formatter rspec-core ships, because the only classes that
|
|
432
|
+
# define `message` are the ones that register it.
|
|
433
|
+
def message(notification)
|
|
434
|
+
never_fail_the_run { relay_message(notification) }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# One example finished — passed, failed or pending. Called for every
|
|
438
|
+
# example in the run, in the order they complete.
|
|
439
|
+
def example_finished(notification)
|
|
440
|
+
never_fail_the_run { @specs << capture(notification.example) }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The suite is over but the process is still alive. Sealing the duration
|
|
444
|
+
# here rather than in `close` keeps the number from absorbing the time the
|
|
445
|
+
# other formatters spend dumping their summaries.
|
|
446
|
+
def stop(_notification)
|
|
447
|
+
never_fail_the_run { @duration_seconds = elapsed_since_start }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Last hook of the run: flush what we captured.
|
|
451
|
+
#
|
|
452
|
+
# `super` restores the output stream's sync setting, which BaseFormatter
|
|
453
|
+
# changed on our behalf at `start` (a notification it registers for, and so
|
|
454
|
+
# one this subclass receives too). Overriding `close` without calling it
|
|
455
|
+
# would leave stdout unbuffered for whatever runs next in the process.
|
|
456
|
+
def close(notification)
|
|
457
|
+
never_fail_the_run do
|
|
458
|
+
@duration_seconds ||= elapsed_since_start
|
|
459
|
+
deliver(payload)
|
|
460
|
+
end
|
|
461
|
+
never_fail_the_run { super(notification) }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# The run, as it will be written or POSTed. Public so a caller — or a spec
|
|
465
|
+
# — can inspect what was captured without going anywhere near the
|
|
466
|
+
# filesystem or the network.
|
|
467
|
+
#
|
|
468
|
+
# Key names match the platform's ingest contract
|
|
469
|
+
# (`Ingest::Payload`: commit_sha / branch / ci_run_id / duration_seconds /
|
|
470
|
+
# specs), which is what made adding transport a transport change rather
|
|
471
|
+
# than a reshaping of everything above it. {Transport} sends this Hash
|
|
472
|
+
# verbatim.
|
|
473
|
+
#
|
|
474
|
+
# `ci_run_id` is the field that keeps a sharded suite honest: every shard of
|
|
475
|
+
# one CI run emits the same one, and the platform folds them onto a single
|
|
476
|
+
# `TestRun` instead of recording one row per shard with a quarter of the
|
|
477
|
+
# denominator in it. `nil` here — a laptop run, an unrecognised
|
|
478
|
+
# provider — means "this run is its own run", which is the pre-existing
|
|
479
|
+
# behaviour and is left exactly alone.
|
|
480
|
+
#
|
|
481
|
+
# `shard_id` says *which* slice of that run this is, and it is what makes
|
|
482
|
+
# the fold idempotent. A CI run id survives a re-run by design (GitHub's
|
|
483
|
+
# `GITHUB_RUN_ID` is documented as unchanged across attempts), so without a
|
|
484
|
+
# per-shard key the platform could only add a re-delivered slice, never
|
|
485
|
+
# recognise it, and "re-run failed jobs" would report a suite bigger than
|
|
486
|
+
# the suite. With it, a retried shard replaces its own previous numbers.
|
|
487
|
+
# `nil` is allowed and still counts — see {Configuration::SHARD_ID_KEYS}.
|
|
488
|
+
#
|
|
489
|
+
# The settings are `run_id` / `shard_id` and the wire fields are
|
|
490
|
+
# `ci_run_id` / `shard_id`, deliberately.
|
|
491
|
+
# Configuration names are this gem's own (`SPECGUARD_RUN_ID`, next to
|
|
492
|
+
# `output_path` and `endpoint`); every key in *this* Hash is the platform's,
|
|
493
|
+
# spelled exactly as `TestRun` spells it. That rule is what lets a reader
|
|
494
|
+
# check the envelope against the schema without a translation table — and a
|
|
495
|
+
# run identity that two sides spell differently is one more way to split a
|
|
496
|
+
# run, which is the whole defect this field closes.
|
|
497
|
+
#
|
|
498
|
+
# @return [Hash]
|
|
499
|
+
def payload
|
|
500
|
+
configuration = SpecGuard::RSpec.configuration
|
|
501
|
+
|
|
502
|
+
{
|
|
503
|
+
"commit_sha" => configuration.commit_sha,
|
|
504
|
+
"branch" => configuration.branch,
|
|
505
|
+
"ci_run_id" => configuration.run_id,
|
|
506
|
+
"shard_id" => configuration.shard_id,
|
|
507
|
+
"duration_seconds" => @duration_seconds,
|
|
508
|
+
"specs" => @specs
|
|
509
|
+
}
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
private
|
|
513
|
+
|
|
514
|
+
# {#seed}'s body. Adds RSpec's default formatter when registering this one
|
|
515
|
+
# suppressed it, and hands the newcomer the notification it arrived too late
|
|
516
|
+
# to receive.
|
|
517
|
+
#
|
|
518
|
+
# The `equal?(self)` guard on the first line is not defensive padding: it is
|
|
519
|
+
# what keeps this method inert outside a real run. `spec/.../formatter_spec.rb`
|
|
520
|
+
# drives these hooks in-process against a formatter that RSpec never
|
|
521
|
+
# registered, and without the guard each of those examples would bolt a
|
|
522
|
+
# progress formatter onto the *parent* suite's live configuration. If this
|
|
523
|
+
# object is not in `RSpec.configuration.formatters`, nothing here is its
|
|
524
|
+
# business.
|
|
525
|
+
def restore_suppressed_default_formatter(notification)
|
|
526
|
+
configuration = rspec_configuration
|
|
527
|
+
registered = configuration.formatters
|
|
528
|
+
return unless registered.any? { |formatter| formatter.equal?(self) }
|
|
529
|
+
return if registered.any? { |formatter| reports_the_run?(formatter) }
|
|
530
|
+
|
|
531
|
+
configuration.add_formatter(configuration.default_formatter)
|
|
532
|
+
configuration.formatters.each do |formatter|
|
|
533
|
+
next if registered.any? { |already| already.equal?(formatter) }
|
|
534
|
+
|
|
535
|
+
formatter.seed(notification) if formatter.respond_to?(:seed)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# {#message}'s body: print, but only as the last resort.
|
|
540
|
+
#
|
|
541
|
+
# The `equal?(self)` guard is the same inertness guard
|
|
542
|
+
# {#restore_suppressed_default_formatter} carries, for the same reason — this
|
|
543
|
+
# object only owes anybody a message because RSpec registered it and skipped
|
|
544
|
+
# appointing its own fallback. A formatter RSpec never registered (the
|
|
545
|
+
# in-process examples in `spec/.../formatter_spec.rb`) owes nothing and
|
|
546
|
+
# writes nothing.
|
|
547
|
+
def relay_message(notification)
|
|
548
|
+
registered = rspec_configuration.formatters
|
|
549
|
+
return unless registered.any? { |formatter| formatter.equal?(self) }
|
|
550
|
+
return if registered.any? { |formatter| relays_messages?(formatter) }
|
|
551
|
+
|
|
552
|
+
output.puts notification.message
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# The live RSpec configuration, behind a method so a spec can hand this
|
|
556
|
+
# object a stand-in. Stubbing `::RSpec.configuration` itself is not an
|
|
557
|
+
# option: rspec-core reads it throughout the example it is running, so the
|
|
558
|
+
# double gets asked things like `dry_run?` and the example dies inside
|
|
559
|
+
# RSpec rather than inside this class.
|
|
560
|
+
#
|
|
561
|
+
# The `::` is load-bearing and this is the only place it can be wrong.
|
|
562
|
+
# Inside `module SpecGuard` a bare `RSpec.configuration` reaches *this
|
|
563
|
+
# gem's* Configuration, which has no `dry_run?` — see {#dry_run?} for what
|
|
564
|
+
# that costs. Keeping every reader of the real configuration funnelled
|
|
565
|
+
# through this one method is what gives that hazard a single site to be
|
|
566
|
+
# pinned by a test, and formatter_spec.rb pins it with an `equal`
|
|
567
|
+
# assertion against `::RSpec.configuration`.
|
|
568
|
+
def rspec_configuration
|
|
569
|
+
::RSpec.configuration
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Whether `formatter` is somebody else's account of the run — i.e. anything
|
|
573
|
+
# but this formatter that will tell a human what the suite did.
|
|
574
|
+
#
|
|
575
|
+
# == Why this set of methods
|
|
576
|
+
#
|
|
577
|
+
# It is the late-run translation of the condition rspec-core evaluates at
|
|
578
|
+
# `formatters.rb:127`, `@formatters.empty?`. That runs *before* RSpec appends
|
|
579
|
+
# its own auxiliaries, so "empty" there means "the user named no formatter";
|
|
580
|
+
# by the time {#seed} can look, the list also holds a `DeprecationFormatter`
|
|
581
|
+
# (and a `ProfileFormatter` under `--profile`). What separates those from
|
|
582
|
+
# every formatter a user can name is that they speak only about deprecations
|
|
583
|
+
# and timings — never about an example, never about the outcome of the run.
|
|
584
|
+
#
|
|
585
|
+
# So the question is asked in exactly those terms. Checked against the
|
|
586
|
+
# formatters rspec-core ships: `progress`, `documentation` and `html` answer
|
|
587
|
+
# on the `example_*` methods, `json` on `dump_summary`, `--format failures`
|
|
588
|
+
# (`FailureListFormatter`) on `example_failed`; `DeprecationFormatter`,
|
|
589
|
+
# `FallbackMessageFormatter` and `ProfileFormatter` answer on none of them.
|
|
590
|
+
#
|
|
591
|
+
# `dump_summary` alone is *not* enough, and shipping it alone was a bug. A
|
|
592
|
+
# suite run with `--format failures` prints one line per failure and no
|
|
593
|
+
# summary, so a `dump_summary`-only question read it as "nobody is reporting"
|
|
594
|
+
# and bolted a whole progress run onto an explicitly chosen formatter.
|
|
595
|
+
# Measured on `formatter_run_spec.rb`'s `MIXED_SUITE`: 61 bytes of failure
|
|
596
|
+
# list became ~963 bytes of dots, backtrace and summary. That
|
|
597
|
+
# is the additive promise broken in the same breath as it is kept.
|
|
598
|
+
#
|
|
599
|
+
# == What `respond_to?` cannot see
|
|
600
|
+
#
|
|
601
|
+
# rspec-core answers the structurally identical question with
|
|
602
|
+
# `@reporter.registered_listeners(notification).any?` (`formatters.rb:207-209`)
|
|
603
|
+
# — *registration*, not method presence. That is the more accurate question
|
|
604
|
+
# and it is deliberately not the one asked here: `registered_listeners` is
|
|
605
|
+
# `@private` (`reporter.rb:51`), and this repair is worth nothing if a minor
|
|
606
|
+
# rspec-core bump can silence a suite with it.
|
|
607
|
+
#
|
|
608
|
+
# The approximation is not free, and it is worth knowing which way it errs. A
|
|
609
|
+
# formatter that *inherits* one of these methods from `BaseTextFormatter`
|
|
610
|
+
# while registering only a subset of notifications reads as an account of the
|
|
611
|
+
# run and never receives one — so the default is not restored and the run is
|
|
612
|
+
# quiet, which is this ticket's own defect one level in. Nothing rspec-core
|
|
613
|
+
# ships is shaped that way (every class that defines one of these registers
|
|
614
|
+
# it), and a third-party formatter would have to inherit `BaseTextFormatter`
|
|
615
|
+
# specifically to avoid printing. The reverse error — a formatter that prints
|
|
616
|
+
# under some other method name, so the default is restored alongside it — is
|
|
617
|
+
# noisy rather than silent, and is the direction to prefer if this ever has
|
|
618
|
+
# to be re-tuned.
|
|
619
|
+
def reports_the_run?(formatter)
|
|
620
|
+
return false if formatter.equal?(self)
|
|
621
|
+
|
|
622
|
+
REPORTS_THE_RUN.any? { |method_name| formatter.respond_to?(method_name) }
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Whether `formatter` is somebody else's `:message` handling — the question
|
|
626
|
+
# {#message} asks before deciding it is the last resort. See {#message}.
|
|
627
|
+
def relays_messages?(formatter)
|
|
628
|
+
!formatter.equal?(self) && formatter.respond_to?(:message)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Sink selection, and the fallback that keeps a failed delivery from being
|
|
632
|
+
# a silent one.
|
|
633
|
+
#
|
|
634
|
+
# Called from inside {#never_fail_the_run}, so nothing below has to guard
|
|
635
|
+
# itself against raising — including the fallback `append`, whose own
|
|
636
|
+
# failure modes (unwritable `log/`, full disk) are already covered there.
|
|
637
|
+
#
|
|
638
|
+
# == Why a failure writes the file rather than shrugging
|
|
639
|
+
#
|
|
640
|
+
# "Never block CI" is a promise about the *exit code*, not a licence to
|
|
641
|
+
# discard the run. A 401 with no file left behind loses the whole run's
|
|
642
|
+
# telemetry for a mistake — a rotated key, a typo'd URL — that is fixed in
|
|
643
|
+
# thirty seconds and cannot be re-run afterwards, because the suite is over.
|
|
644
|
+
# Writing the payload to the local sink turns silent loss into recoverable
|
|
645
|
+
# loss, which is the same thing `output_path` already exists for ("supports
|
|
646
|
+
# a future replay-from-file ingestion path").
|
|
647
|
+
#
|
|
648
|
+
# == And the one case that goes to neither sink
|
|
649
|
+
#
|
|
650
|
+
# A dry run is refused outright — no POST, no line. It is the exception to
|
|
651
|
+
# the paragraph above because there is nothing to recover: the run measured
|
|
652
|
+
# nothing, so keeping it is not preserving telemetry, it is manufacturing
|
|
653
|
+
# it. {#dry_run?} has the argument in full.
|
|
654
|
+
def deliver(data)
|
|
655
|
+
return skip_dry_run if dry_run?
|
|
656
|
+
|
|
657
|
+
configuration = SpecGuard::RSpec.configuration
|
|
658
|
+
return append(data) if blank?(configuration.api_key)
|
|
659
|
+
|
|
660
|
+
obstacle = undeliverable_reason(configuration, data)
|
|
661
|
+
return fall_back(data, obstacle) if obstacle
|
|
662
|
+
|
|
663
|
+
result = transport_for(configuration).deliver(data)
|
|
664
|
+
return if result.success?
|
|
665
|
+
|
|
666
|
+
fall_back(data, result.reason)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# The checks worth doing *before* spending the run's wall clock on a request
|
|
670
|
+
# whose answer is already known.
|
|
671
|
+
#
|
|
672
|
+
# `commit_sha` is the sharp one: `Ingest::Payload#validate_commit_sha`
|
|
673
|
+
# refuses a blank one and the controller renders 400, so the run would be
|
|
674
|
+
# discarded whole — every example, not merely the empty field. Ten seconds
|
|
675
|
+
# of CI time to be told that is pure loss, and the operator learns more from
|
|
676
|
+
# being told *why* than from being shown the platform's rejection.
|
|
677
|
+
#
|
|
678
|
+
# @return [String, nil] nil when there is nothing standing in the way.
|
|
679
|
+
def undeliverable_reason(configuration, data)
|
|
680
|
+
if blank?(configuration.endpoint)
|
|
681
|
+
return "an API key is set but no endpoint is (set SPECGUARD_ENDPOINT)"
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
return unless blank?(data["commit_sha"])
|
|
685
|
+
|
|
686
|
+
"the commit could not be determined, and the endpoint rejects a run without one " \
|
|
687
|
+
"(set SPECGUARD_COMMIT_SHA)"
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Is RSpec executing example bodies at all?
|
|
691
|
+
#
|
|
692
|
+
# == Why a dry run must not be delivered
|
|
693
|
+
#
|
|
694
|
+
# `rspec --dry-run` walks the suite, builds every example and reports each
|
|
695
|
+
# one as `passed`, without running a single body. The two per-example fields
|
|
696
|
+
# this formatter exists to report are therefore both fabrications:
|
|
697
|
+
# `duration` is the cost of constructing an example (single-digit
|
|
698
|
+
# microseconds against the pinned rspec-core 3.13.6 — a `sleep 0.05`
|
|
699
|
+
# example understates its own runtime by three to four orders of magnitude)
|
|
700
|
+
# and `outcome` is `passed` for code that never executed.
|
|
701
|
+
#
|
|
702
|
+
# Nothing downstream can tell. The payload carries no dry-run marker,
|
|
703
|
+
# `Ingest::Payload` validates neither field, and `Repository#latest_test_run`
|
|
704
|
+
# is last-writer-wins — so one lint job that runs `rspec --dry-run` with an
|
|
705
|
+
# environment-level `SPECGUARD_API_KEY` in scope replaces the suite's
|
|
706
|
+
# headline with all-green zeroes that look exactly like a fast, healthy
|
|
707
|
+
# suite.
|
|
708
|
+
#
|
|
709
|
+
# The local sink gets the same refusal for the same reason: a `.jsonl` of
|
|
710
|
+
# zero-duration all-green runs is the identical corruption, deferred until
|
|
711
|
+
# something replays it.
|
|
712
|
+
#
|
|
713
|
+
# == The `::` is load-bearing — do not remove it
|
|
714
|
+
#
|
|
715
|
+
# This is the class-body hazard documented at the top of `module SpecGuard`,
|
|
716
|
+
# in its worst form. Inside this namespace a bare `RSpec.configuration`
|
|
717
|
+
# resolves to `SpecGuard::RSpec.configuration` — *this gem's* Configuration,
|
|
718
|
+
# whose accessors are `commit_sha`, `branch`, `run_id`, `shard_id`,
|
|
719
|
+
# `output_path`, `endpoint`, `api_key` and `timeout`, and which has no
|
|
720
|
+
# `dry_run?`. Note that four methods here — {#payload}, {#deliver},
|
|
721
|
+
# {#append} and {#warn_delivery_failure} — legitimately call
|
|
722
|
+
# `SpecGuard::RSpec.configuration` for this gem's own settings, so both
|
|
723
|
+
# spellings appear in this file and mean different things.
|
|
724
|
+
#
|
|
725
|
+
# The mis-spelling's consequence depends on the `respond_to?` below, and
|
|
726
|
+
# both halves were measured by mutating {#rspec_configuration} and running
|
|
727
|
+
# `bundle exec rspec` (601 examples on the tree this landed on):
|
|
728
|
+
#
|
|
729
|
+
# bare `RSpec`, respond_to? kept this gem's Configuration answers
|
|
730
|
+
# `respond_to?(:dry_run?) => false`, so
|
|
731
|
+
# every run is treated as ordinary and
|
|
732
|
+
# the defect this method closes is
|
|
733
|
+
# silently reinstated. 23 fail
|
|
734
|
+
# suite-wide; the one that names *this*
|
|
735
|
+
# cause rather than a downstream
|
|
736
|
+
# symptom is formatter_spec.rb's
|
|
737
|
+
# "reads the real RSpec's
|
|
738
|
+
# configuration, not this gem's
|
|
739
|
+
# same-named one". The other 22 are
|
|
740
|
+
# symptoms: 4 further dry-run ones, and
|
|
741
|
+
# 18 from the message relay, which
|
|
742
|
+
# reads this same method for
|
|
743
|
+
# `.formatters`.
|
|
744
|
+
# bare `RSpec`, respond_to? removed NoMethodError inside `close`'s
|
|
745
|
+
# {#never_fail_the_run}, swallowed — a
|
|
746
|
+
# formatter that delivers *nothing*, on
|
|
747
|
+
# every run, behind one warning line,
|
|
748
|
+
# while the suite still passes. 110
|
|
749
|
+
# fail suite-wide.
|
|
750
|
+
#
|
|
751
|
+
# Both totals are whole-suite figures and will move with any edit anywhere
|
|
752
|
+
# in the tree; the named example above is the durable half of the claim.
|
|
753
|
+
#
|
|
754
|
+
# So `respond_to?` is not only there for an RSpec build without the
|
|
755
|
+
# predicate: it is what keeps a mis-spelling from escalating into deleting
|
|
756
|
+
# all telemetry. Neither state is acceptable, and {#rspec_configuration}
|
|
757
|
+
# gives the `::` exactly one place to be wrong and one place to be pinned —
|
|
758
|
+
# formatter_spec.rb asserts it is `equal` to `::RSpec.configuration`, which
|
|
759
|
+
# is the only assertion that names the cause rather than a downstream
|
|
760
|
+
# symptom.
|
|
761
|
+
def dry_run?
|
|
762
|
+
configuration = rspec_configuration
|
|
763
|
+
return false unless configuration.respond_to?(:dry_run?)
|
|
764
|
+
|
|
765
|
+
configuration.dry_run?
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Said rather than done silently, and through the same one-shot budget as
|
|
769
|
+
# every other warning so a dry run still emits at most one line. Nothing is
|
|
770
|
+
# appended: this is the one path where the absence of a file is the
|
|
771
|
+
# feature.
|
|
772
|
+
def skip_dry_run
|
|
773
|
+
emit_warning("#{DRY_RUN_WARNING_PREFIX} (rspec --dry-run executes no example bodies, so " \
|
|
774
|
+
"this run's durations and outcomes would not be measurements). " \
|
|
775
|
+
"Nothing was sent or written; the test run is unaffected.")
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def fall_back(data, reason)
|
|
779
|
+
# Warned before the write, not after: if the fallback write *also* fails,
|
|
780
|
+
# the outer guard's one allotted warning has already been spent on the
|
|
781
|
+
# more specific message, which is the one naming the status code.
|
|
782
|
+
warn_delivery_failure(reason)
|
|
783
|
+
append(data)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def transport_for(configuration)
|
|
787
|
+
SpecGuard::RSpec::Transport.new(
|
|
788
|
+
endpoint: configuration.endpoint,
|
|
789
|
+
api_key: configuration.api_key,
|
|
790
|
+
timeout: configuration.timeout
|
|
791
|
+
)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def blank?(value)
|
|
795
|
+
value.nil? || value.to_s.strip.empty?
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def capture(example)
|
|
799
|
+
metadata = example.metadata || {}
|
|
800
|
+
result = example.execution_result
|
|
801
|
+
file_path = relative_path(metadata[:file_path])
|
|
802
|
+
line_number = metadata[:line_number]
|
|
803
|
+
|
|
804
|
+
# Its own envelope, inside the one `example_finished` already provides.
|
|
805
|
+
# The outer one drops the whole example; this one drops only its
|
|
806
|
+
# annotation, so an unreadable spec file or a broken schema costs the run
|
|
807
|
+
# the intent of the examples in it and not the examples themselves. It
|
|
808
|
+
# also routes through {#warn_once}, so the operator hears about it exactly
|
|
809
|
+
# once however many examples are affected.
|
|
810
|
+
intent = never_fail_the_run { @annotations.intent_for(file: file_path, line: line_number) }
|
|
811
|
+
|
|
812
|
+
{
|
|
813
|
+
# Not wrapped in `never_fail_the_run`, unlike the annotation lookup
|
|
814
|
+
# above. That guard is there because the lookup does file I/O; this is a
|
|
815
|
+
# memoized string interpolation over a Hash RSpec has already built, and
|
|
816
|
+
# a guard here could only ever add a way to emit an identity-less row
|
|
817
|
+
# silently. The outer guard at `#example_finished` still covers it.
|
|
818
|
+
"id" => example.id,
|
|
819
|
+
# `|| file_path` rather than a bare read: RSpec defaults
|
|
820
|
+
# `:rerun_file_path` for every example it builds, so the fallback is
|
|
821
|
+
# unreachable in a real run — but if a producer ever hands us metadata
|
|
822
|
+
# without it, the definition site is the best answer available and a
|
|
823
|
+
# null would silently drop a whole file out of any by-file aggregate.
|
|
824
|
+
"spec_file_path" => relative_path(metadata[:rerun_file_path]) || file_path,
|
|
825
|
+
"file_path" => file_path,
|
|
826
|
+
"line_number" => line_number,
|
|
827
|
+
"name" => example.full_description,
|
|
828
|
+
"duration" => result&.run_time,
|
|
829
|
+
# A Symbol here would serialize fine but would compare unequal to the
|
|
830
|
+
# string every consumer reads back out of JSON.
|
|
831
|
+
"outcome" => result&.status&.to_s,
|
|
832
|
+
"status" => intent.nil? ? STATUS_UNANNOTATED : STATUS_ANNOTATED,
|
|
833
|
+
# Written explicitly rather than omitted. `Ingest::Payload` accepts
|
|
834
|
+
# either for an unannotated spec (`payload.rb:138` — nil is nil whether
|
|
835
|
+
# the key was absent or null), and a present null is the difference
|
|
836
|
+
# between "we looked and there was no annotation" and "this producer
|
|
837
|
+
# does not report annotations", which is precisely what slice 1's
|
|
838
|
+
# payload could not say.
|
|
839
|
+
"intent" => intent
|
|
840
|
+
}
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# RSpec reports `./spec/orders_spec.rb` for a file under the working
|
|
844
|
+
# directory and an absolute path for one outside it. Reuse RSpec's own
|
|
845
|
+
# definition of "relative to the project root" rather than inventing a
|
|
846
|
+
# second one, then drop the `./` so the value matches what a linter, a
|
|
847
|
+
# `git diff --name-only` and the platform all use as a file's identity.
|
|
848
|
+
def relative_path(path)
|
|
849
|
+
return nil if path.nil?
|
|
850
|
+
|
|
851
|
+
string = path.to_s
|
|
852
|
+
return nil if string.empty?
|
|
853
|
+
|
|
854
|
+
(::RSpec::Core::Metadata.relative_path(string) || string).sub(%r{\A\./}, "")
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Appends one line. Opened in append mode and written with a single call so
|
|
858
|
+
# that two suites sharing an output path (parallel CI shards, say) interleave
|
|
859
|
+
# whole runs rather than halves of one.
|
|
860
|
+
def append(data)
|
|
861
|
+
path = SpecGuard::RSpec.configuration.output_path
|
|
862
|
+
directory = File.dirname(path)
|
|
863
|
+
FileUtils.mkdir_p(directory) unless directory == "." || File.directory?(directory)
|
|
864
|
+
|
|
865
|
+
File.open(path, "a") { |file| file.write("#{JSON.generate(data)}\n") }
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# `Errno::*` and `IOError` — the sink's failure modes — are both
|
|
869
|
+
# `StandardError` descendants, so they need no separate clause; they are
|
|
870
|
+
# named in the ticket because they are the *reason* this rescue is here, not
|
|
871
|
+
# because Ruby files them somewhere unusual.
|
|
872
|
+
def never_fail_the_run
|
|
873
|
+
yield
|
|
874
|
+
rescue ScriptError, StandardError => e
|
|
875
|
+
warn_once(e)
|
|
876
|
+
nil
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Once per run. A formatter that warned per example would bury the failure
|
|
880
|
+
# output of the suite it is supposed to be reporting on under thousands of
|
|
881
|
+
# identical lines — which is its own way of breaking somebody's CI.
|
|
882
|
+
def warn_once(error)
|
|
883
|
+
emit_warning("#{WARNING_PREFIX} (#{error.class}: #{error.message}). The test run is unaffected.")
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# The delivery half, through the same one-shot budget so a run still emits
|
|
887
|
+
# at most one line however many things went wrong. `reason` names the HTTP
|
|
888
|
+
# status when there was one — a 400 and a 401 call for entirely different
|
|
889
|
+
# actions, and a warning that only said "delivery failed" would leave the
|
|
890
|
+
# reader unable to tell which.
|
|
891
|
+
def warn_delivery_failure(reason)
|
|
892
|
+
path = SpecGuard::RSpec.configuration.output_path
|
|
893
|
+
|
|
894
|
+
emit_warning("#{DELIVERY_WARNING_PREFIX} (#{reason}). " \
|
|
895
|
+
"Falling back to #{path}; the test run is unaffected.")
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def emit_warning(line)
|
|
899
|
+
return if @warned
|
|
900
|
+
|
|
901
|
+
@warned = true
|
|
902
|
+
@error_stream.puts(line)
|
|
903
|
+
rescue ScriptError, StandardError
|
|
904
|
+
# The warning stream itself is gone. There is nothing left to say, and
|
|
905
|
+
# saying it is not worth failing the run over.
|
|
906
|
+
nil
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def elapsed_since_start
|
|
910
|
+
(monotonic_now - @started_at).round(6)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Monotonic: immune to an NTP correction landing mid-suite, which on the
|
|
914
|
+
# wall clock can produce a negative duration.
|
|
915
|
+
def monotonic_now
|
|
916
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
end
|