Arms a registry's invariant_events/0 for the duration of each test and
fails the test if one of them fired.
defmodule Raxol.Payments.SettlementTest do
use ExUnit.Case, async: false
use Raxol.Core.Telemetry.InvariantSentinel, registry: Raxol.Payments.Telemetry
endregistry: is the only option and it is required: it names a module built
with use Raxol.Core.Telemetry.Invariants. An invariant event is one that
can only fire if Raxol itself is wrong. Before this mechanism existed, such
an event was emitted, logged, and ignored -- which is how a turn that
delivered zero content shipped while the library was warning about it on
stderr. A guard that nothing asserts on is not a guard.
Why this module lives in lib/ and not test/support/
Dependent packages use it from THEIR tests, and a dependency's
test/support tree is not on the consumer's load path -- only lib/ is
compiled into the delivered application. ExUnit is therefore touched only
inside functions that this module injects into, or that are called from, a
test module (ExUnit.Callbacks.on_exit/1, ExUnit.Assertions.flunk/1).
Nothing here requires ExUnit at runtime for a normal library consumer, and
nothing here is executed by simply loading raxol_core.
Declaring an invariant on purpose
A test that DELIBERATELY drives a bad path declares it:
@tag expect_invariant: [[:raxol, :payments, :xochi, :unchecked_settlement]]A single event may be given unwrapped (expect_invariant: [:raxol, :payments, :xochi, :unchecked_settlement]). The declaration is an ASSERTION, not a mute
button: such a test also fails when the event does NOT fire. That is the
point -- it converts "this test happens to drive a bad path" into "this test
pins the bad path". Declaring an event the registry does not classify as
:invariant is itself a failure, so the tag cannot drift away from the
registry.
Only sound in async: false modules
:telemetry handlers are global and run in the EMITTING process, so a
handler cannot tell which concurrently-running test caused an event: an
invariant fired by test A would be reported against test B. Every module that
uses this sentinel MUST therefore be async: false. A few hundred
milliseconds of serial runtime is worth a guard that cannot misattribute.
Pid-ancestry filtering is deliberately NOT attempted: $ancestors does not
survive the Task.Supervisor hops these systems use, so it would silently
drop the events most worth catching.
How the events survive the test process
The handler runs in whichever process emitted the event, and the verdict is
rendered from on_exit/1 (the only hook that runs after the test body), by
which time the test process is already gone -- so messaging self() from the
handler cannot work. Fired events are parked in a public ETS table owned by
one long-lived collector process, keyed by the per-test handler id, and
dropped when the verdict is rendered. Table and collector names are derived
from the registry module, so two packages' sentinels cannot collide in one
BEAM.
Summary
Functions
Attach a per-test handler for every invariant event in registry and
register the end-of-test verdict. Called by the setup that __using__/1
injects.