blockwatcher-e2e
blockwatcher-e2e is black-box, end-to-end coverage of the real blockwatcher
binary: every scenario spawns the binary the way an operator would (a
config file, an optional seed directory, environment variables, signals)
and asserts on what an operator can actually observe (stdout, stderr, the
exit status, and HTTP), never on an internal type. Its own lib.rs states
why that library target exists at all while staying empty: “This library
target is empty by design and stays empty. The crate exists so tests/
has a workspace member to hang from, and every dependency it declares is a
dev-dependency: with no production dependencies at all, the crate
structurally cannot ship anything, which is why the dep-graph gate’s
allowlist for it is empty rather than populated”
(crates/blockwatcher-e2e/src/lib.rs:3-7).
Every scenario follows the same shape: spawn the real binary, point it at whichever chain fixture it needs, and assert only on what an operator could observe from outside the process:
flowchart LR
test["scenario<br/>tests/e2e/*.rs"] --> spawn["spawn_blockwatcher<br/>harness.rs"]
spawn --> bin["blockwatcher binary<br/>nested cargo build"]
bin --> chain{{"anvil node,<br/>or MockNode / SimChain"}}
bin --> observable["stdout, stderr,<br/>/health, /status"]
bin -->|"webhook actions"| recv["Receiver<br/>recording webhook server"]
test -->|"asserts on"| observable
test -->|"asserts on"| recv
crates/blockwatcher-e2e/Cargo.toml has an empty [dependencies] table and
populates [dev-dependencies] only: alloy-primitives, axum,
blockwatcher-evm-testkit, reqwest, serde_json, tempfile, and tokio
(macros/rt-multi-thread/net/sync/time features). That empty
[dependencies] table is what scripts/check-dep-graph.sh’s
blockwatcher-e2e allowlist entry names too: an empty string, so any production
dependency at all fails the check by construction, the same rule
Workspace map § Verifying the
rings and Architecture decisions §
How the dependency gate turns rules into a mechanical
check
both describe as one of the two crate-shaped rules the gate checks by name
rather than through the general allowlist mechanism.
Key takeaways
blockwatcher-e2eis black-box, end-to-end coverage of the realblockwatcherbinary: every scenario spawns the built binary and asserts only on what an operator can observe from outside it.- Its library target is deliberately empty, and every dependency is a dev-dependency, so the crate structurally cannot ship anything.
- It declares an empty
[dependencies]table, the same rule the dependency gate checks by name. - Nothing in the workspace depends on it; it drives the binary from outside rather than being driven by anything.
Staying out of the default build
The root Cargo.toml’s [workspace] table lists blockwatcher-e2e as an
ordinary entry in members, and declares no default-members key at all
(the root Cargo.toml’s [workspace] members list): every cargo check --workspace, cargo clippy --workspace, and cargo test --workspace invocation in CI, and every bare
cargo check/cargo build a contributor runs from the workspace root,
includes this crate exactly like any other member. Nothing about
default-members is what keeps it out of an ordinary build.
What actually keeps it out is structural, and follows directly from the
empty [dependencies] table above: lib.rs compiles to nothing but an
empty module, no workspace crate names blockwatcher-e2e in its own
[dependencies] (there would be nothing there to depend on even if one
tried), and the binary an operator actually runs, built via cargo build --bin blockwatcher or cargo run, never touches this crate at all. This
crate’s scenario files only compile and run under cargo test, as
the tests/e2e/*.rs integration binary tests/e2e/main.rs roots
(tests/e2e/main.rs), and that integration binary in turn spawns a
second, independently-built copy of the blockwatcher binary as a subprocess
(see Building the binary it drives
below) rather than linking against it. An ordinary build produces nothing
from this crate worth shipping; only cargo test does anything here at
all.
The scenarios in tests/e2e/anvil.rs additionally need a real EVM node
on PATH. .github/workflows/ci.yml’s check job
provisions anvil via foundry-rs/foundry-toolchain@v1.9.1 immediately
before cargo test --workspace --all-features, and sets
BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1" on that same step
(the check job in .github/workflows/ci.yml), so an absent anvil in that job would
be a hard failure rather than a silent skip. The windows-check job runs
no tests at all, only cargo check --workspace --locked, and its own
comment states why: “e2e needs anvil and stays on the Linux job”
(the windows-check and feature-powerset jobs in .github/workflows/ci.yml). See Testing strategy §
CI for where this crate’s job
appearances fit among the workspace’s other suites.
Responsibilities
tests/e2e/harness.rs: the rig every scenario shares.spawn_blockwatcherspawns the built binary with its config, an optional seed directory, and environment, capturing stdout and stderr to files;Scenariois the handle back (wait_for_health,wait_for_exit,sigterm,stdout,stderr), and itsDropimpl kills and reaps an orphaned process so one scenario’s failure can never blame the next scenario’s port contention or storage file (harness.rs).Receiveris a recording webhook server every delivery-driven scenario points a sink at (harness.rs).write_chain_seedwrites the network/spec/sink/monitor JSON every chain-driven scenario shares (harness.rs).untilandanvil_available_or_skipare described in their own sections below.tests/e2e/anvil.rs: the scenarios whose subject is the real binary against a real node: one emitted event reaching a webhook end to end, and a process killed mid-stream (SIGKILL) coming back without losing anything.tests/e2e/auth.rs: the control plane’s bearer gate and the opacity of the token behind it, asserted from outside the process: an unauthenticated or wrongly-authenticated request is refused, an authenticated one succeeds, and the token’s value reaches no log line, no error, and no response body.tests/e2e/shutdown.rs: graceful shutdown with a sink that will never finish; the subject is which clock decides the exit, the configured drain deadline or the wedge.tests/e2e/stdout_purity.rs: thelogsink’s line-protocol contract, asserted on the real process’s stdout with tracing enabled and talking: every stdout line is one parseable canonical match JSON object, because every diagnostic goes to stderr instead.
Not this crate’s job: implementing anything a deployed instance
loads (this crate is structurally incapable of that; see Staying out of
the default build above); exercising a
module’s own unit-level or module-level behavior (blockwatcher-evm’s and
blockwatcher-storage’s own tests/ do that against a mock node or a real
sqlite file respectively; see Testing strategy); or
substituting for anvil with a mock when a scenario’s whole point is what a
real node does with a transaction, a block, and a confirmation barrier:
tests/e2e/anvil.rs’s own module doc comment states this directly:
“Nothing here can be answered by a mock” (tests/e2e/anvil.rs:1-9).
Key types and functions
| Name | Kind | Role |
|---|---|---|
spawn_blockwatcher | async fn | Spawns the built blockwatcher binary with a config, optional seed, and environment, capturing stdout/stderr to files in the caller’s directory (harness.rs) |
Scenario | struct | The spawned process’s handle: wait_for_health, wait_for_exit, sigterm, stdout, stderr; Drop kills and reaps an orphan (harness.rs) |
blockwatcher_binary | async fn | Builds --bin blockwatcher into a nested target directory on first use, via a blocking cargo build --message-format=json call, and caches the resulting path for the rest of the process (harness.rs) |
Receiver | struct | A recording webhook server on an ephemeral port; bodies() returns every parsed body received so far, panicking if the listener has stopped (harness.rs) |
ChainSeed, write_chain_seed | struct / fn | What a chain-driven scenario varies about the shared seed (node URL, confirmations, poll interval, sink), and the function that writes that seed’s network/spec/sink/monitor JSON (harness.rs) |
until | async fn | The bounded poll every wait in this crate shares, quoting a captured process’s stderr tail into its panic on timeout (harness.rs) |
anvil_available_or_skip | fn | Whether anvil is on PATH; prints a skip marker and returns false normally, panics instead when BLOCKWATCHER_E2E_REQUIRE_ANVIL=1 (harness.rs) |
serial | async fn | A static tokio::sync::Mutex guard serializing every scenario that holds it, so a booting child never competes with another scenario’s child for the same starved CPU (harness.rs) |
reserve_addr | fn | A loopback address nothing is listening on, for a scenario that must know the API’s address before the process binding it exists (harness.rs) |
Building the binary it drives
blockwatcher_binary builds the real binary once per test process and reuses
the cached path afterward (harness.rs). It builds into a
separate, nested target directory rather than the outer cargo test
invocation’s own one, and nested_target_dir‘s doc comment explains why
in cost terms: the two builds “resolve features differently and always
will,” since the gate’s --all-features run turns on blockwatcher-ports’
fakes and testing features for everything downstream of it, while
“the binary under test… is built with default features.” Sharing one
directory would mean “every gate run pays a near-full rebuild of the
workspace — tens of minutes, attributed to a test that appears to hang
because the cost is borne by cargo inside it” (harness.rs:164-186). The
build itself, inside blockwatcher_binary, runs on a blocking thread via
spawn_blocking, specifically so a mock node serving a scenario from the
same async runtime keeps answering the process under test while the
nested cargo build runs to completion in the background (harness.rs).
The anvil-only scenarios
tests/e2e/anvil.rs hand-assembles the one contract every scenario in
this crate that needs a real chain installs via anvil_setCode: runtime
bytecode that copies its calldata into memory and logs it under one topic,
because, as the module comment states, “this workspace carries no
Solidity toolchain” (anvil.rs:46-60):
#![allow(unused)]
fn main() {
fn emitter_runtime(topic: &str) -> String {
format!("0x3660006000377f{topic}366000a100")
}
}
Both headline scenarios mine blocks themselves, deliberately, because
“anvil produces no blocks on its own” (anvil.rs:31-36): emit sends a
transaction and polls until it lands in a block, mining along the way,
never assuming inclusion on the send response alone; confirm mines
until the latest block clears the confirmation barrier a Ping log’s own
block needs to become eligible (anvil.rs). The second scenario,
sigkill_and_restart_loses_nothing, is the one place in this crate that
distinguishes a resumed process from a rescanned one: it kills the first
process with SIGKILL rather than SIGTERM (no drain, no final
checkpoint), then asserts
that the first event delivered before the kill is never delivered a
second time by the successor, since a rescan from
the seed’s start block would repeat it and a resume from the stored
checkpoint cannot (anvil.rs).
Serialization and the shared deadline
Every wait in this crate goes through the same bounded poll,
harness::until, which quotes the tail of a captured process’s stderr
into its panic message on timeout, since “every way one of these waits
fails… is legible only there” (harness.rs:75-99, doc comment). Its
budget is a package-wide BLOCKWATCHER_E2E_DEADLINE_SECS environment override
on top of a 120-second default, because, in the function’s own words, “a
laptop also running a build or a backup can starve a booting child past
any default that is still short enough to fail a real regression
promptly” (harness.rs:42-66).
shutdown.rs’s scenario and auth.rs’s scenario both take the
harness::serial() guard before spawning anything (harness.rs).
shutdown.rs’s own call site is the one that explains why, in a comment
immediately above the call: “Serialized before anything else: the exit
window below is measured in seconds on purpose, so this scenario least
tolerates sharing the machine with another booting child”
(shutdown.rs:78-80). auth.rs’s own call to serial() (auth.rs)
carries no such comment of its own; it takes the same guard, backed by
the same serial() function, without stating its own reason inline.
anvil.rs’s scenarios take neither: nothing about their own assertions
depends on a tight wall-clock margin the way shutdown.rs’s
drain-deadline scenario does.
Neighbours
blockwatcher-e2e declares no [dependencies] at all. In [dev-dependencies]
only:
blockwatcher-evm-testkit:MockNode,SimChain,mock_node, for the scenarios (shutdown.rs,stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-evm-testkit § Neighbours for that crate’s side of the same edgealloy-primitives:keccak256, forharness.rs’sping_topic0and the emitter contract’s runtime bytecodeaxum: the recording webhook serverReceiverrunsreqwest: every JSON-RPC and HTTP call this crate’s scenarios make, including the anvil scenarios’ own hand-rolled JSON-RPC client (this crate does not depend onblockwatcher-rpc)serde_json: every config, seed, and JSON-RPC payload this crate builds or parsestempfile: a fresh scenario directory per testtokio(macros/rt-multi-thread/net/sync/timefeatures): the async runtime every scenario and mock node runs on
No crate in the workspace depends on blockwatcher-e2e (per the dependency
table, where its row has no incoming
edge and an empty outgoing one): it is a leaf in both directions, driving
the binary from outside rather than being driven by anything.
Reading the source
lib.rs: the one-paragraph reason this crate’s library target is empty and stays that way.tests/e2e/main.rs: themoddeclarations that root every scenario file, in file order.tests/e2e/harness.rs:ping_topic0,EMITTER,word(the shared fixture identity every scenario’sPingevent uses);deadlineanduntil(the shared wait);serial;blockwatcher_binaryandbuild_blockwatcher(the nested build);reserve_addr,write_file,ChainSeed,write_chain_seed(the shared seed);Scenario,spawn_blockwatcher, and its methods;Receiver; andanvil_available_or_skiplast, since it is the one piece of this file a contributor withoutfoundryinstalled hits first.tests/e2e/anvil.rs: the module doc comment, thenemitter_runtime,start_anvil, and the sharedemit/confirm/emit_and_confirmhelpers, before either#[tokio::test]function.tests/e2e/auth.rs,tests/e2e/shutdown.rs,tests/e2e/stdout_purity.rs: each is short enough to read in one pass; each states its own headline assertion in its function’s doc comment before the test body.