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.
@@ -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