Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-e2e is black-box, end-to-end coverage of the real blockwatcher binary: 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_blockwatcher spawns the built binary with its config, an optional seed directory, and environment, capturing stdout and stderr to files; Scenario is the handle back (wait_for_health, wait_for_exit, sigterm, stdout, stderr), and its Drop impl 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). Receiver is a recording webhook server every delivery-driven scenario points a sink at (harness.rs). write_chain_seed writes the network/spec/sink/monitor JSON every chain-driven scenario shares (harness.rs). until and anvil_available_or_skip are 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: the log sink’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

NameKindRole
spawn_blockwatcherasync fnSpawns the built blockwatcher binary with a config, optional seed, and environment, capturing stdout/stderr to files in the caller’s directory (harness.rs)
ScenariostructThe spawned process’s handle: wait_for_health, wait_for_exit, sigterm, stdout, stderr; Drop kills and reaps an orphan (harness.rs)
blockwatcher_binaryasync fnBuilds --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)
ReceiverstructA 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_seedstruct / fnWhat 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)
untilasync fnThe 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_skipfnWhether anvil is on PATH; prints a skip marker and returns false normally, panics instead when BLOCKWATCHER_E2E_REQUIRE_ANVIL=1 (harness.rs)
serialasync fnA 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_addrfnA 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-portsfakes 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 edge
  • alloy-primitives: keccak256, for harness.rs’s ping_topic0 and the emitter contract’s runtime bytecode
  • axum: the recording webhook server Receiver runs
  • reqwest: 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 on blockwatcher-rpc)
  • serde_json: every config, seed, and JSON-RPC payload this crate builds or parses
  • tempfile: a fresh scenario directory per test
  • tokio (macros/rt-multi-thread/net/sync/time features): 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

  1. lib.rs: the one-paragraph reason this crate’s library target is empty and stays that way.
  2. tests/e2e/main.rs: the mod declarations that root every scenario file, in file order.
  3. tests/e2e/harness.rs: ping_topic0, EMITTER, word (the shared fixture identity every scenario’s Ping event uses); deadline and until (the shared wait); serial; blockwatcher_binary and build_blockwatcher (the nested build); reserve_addr, write_file, ChainSeed, write_chain_seed (the shared seed); Scenario, spawn_blockwatcher, and its methods; Receiver; and anvil_available_or_skip last, since it is the one piece of this file a contributor without foundry installed hits first.
  4. tests/e2e/anvil.rs: the module doc comment, then emitter_runtime, start_anvil, and the shared emit/confirm/emit_and_confirm helpers, before either #[tokio::test] function.
  5. 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.