Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7f7dd72
feat(test-harness): scaffold wavs-test-harness crate (#1147)
JakeHartnell May 16, 2026
0721885
feat(test-harness): chain control layer — anvil, fork, snapshot, impe…
JakeHartnell May 16, 2026
71425d2
feat(test-harness): TOML chain profiles + typed Addresses (#1147)
JakeHartnell May 16, 2026
2b4051e
feat(test-harness): service spec + operator middleware re-exports (#1…
JakeHartnell May 16, 2026
6bad36a
feat(test-harness): in-process runner + lifecycle helpers (#1147)
JakeHartnell May 16, 2026
21bbd1c
feat(test-harness): canonical envelope + ECDSA signing helpers (#1147)
JakeHartnell May 16, 2026
978f201
feat(test-harness): subprocess runner preview shape (#1147)
JakeHartnell May 16, 2026
87207c0
feat(test-harness): TestHarness builder + working examples + justfile…
JakeHartnell May 16, 2026
d416e67
docs(test-harness): comprehensive README with tier matrix + CI guidan…
JakeHartnell May 16, 2026
cd41fd3
chore(test-harness): refresh Cargo.lock with wavs-engine + wasmtime e…
JakeHartnell May 16, 2026
e5e2f91
style(test-harness): apply cargo fmt to satisfy Lint CI (#1147)
JakeHartnell May 16, 2026
7c700f6
feat(test-harness): wire FORK_BLOCK_NUMBER env + ForkOptions::from_pr…
JakeHartnell May 16, 2026
3ce1e1d
feat(test-harness): end-to-end on-chain submission lifecycle (#1147)
JakeHartnell May 16, 2026
1c3f834
feat(test-harness): Chainlink oracle mocking via anvil_setCode (#1147)
JakeHartnell May 16, 2026
ccb318c
feat(test-harness): functional subprocess runner (#1147)
JakeHartnell May 16, 2026
e526954
docs(test-harness): README reflects E2E lifecycle, oracle, subprocess…
JakeHartnell May 16, 2026
e3528de
fix(test-harness): satisfy clippy -D warnings (#1147)
JakeHartnell May 16, 2026
d247c8e
feat(test-harness): wire aggregator stage into end-to-end lifecycle (…
JakeHartnell May 16, 2026
5a1231b
chore(deps): loosen exact alloy pins to caret to allow downstream uni…
JakeHartnell May 17, 2026
7660e24
Merge branch 'main' into feat/test-harness-1147
ueco-jb May 25, 2026
68fa8c0
fix test harness review issues
layertau May 27, 2026
83bf82f
restore alloy workspace exact pins
layertau May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"packages/engine",
"packages/gui/shared",
"packages/layer-tests",
"packages/test-harness",
"packages/types",
"packages/utils",
"packages/version-pins",
Expand Down Expand Up @@ -282,6 +283,7 @@ awsm_web = {version = "0.45.0", default-features = false, features = ["dom", "fi
# local
utils = { path = "packages/utils" }
wavs = { path = "packages/wavs" }
wavs-test-harness = { path = "packages/test-harness" }
wavs-engine = { path = "packages/engine" }
wavs-types = { path = "packages/types" }
wavs-cli = { path = "packages/cli" }
Expand Down
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ cosmwasm-build-inner CONTRACT_PATH:
test-wavs-e2e:
ulimit -n 65536 && RUST_LOG=debug,alloy_rpc=off,alloy_provider=off,wasmtime=off,cranelift=off,hyper_util=off cargo test -p layer-tests

# reusable integration test harness — local Anvil + in-process runner (deterministic, no fork required)
test-harness:
cargo test -p wavs-test-harness

# reusable integration test harness — pinned-fork tier (requires FORK_RPC_URL)
test-harness-fork:
cargo test -p wavs-test-harness --features fork

update-submodules:
git submodule update --init --recursive

Expand Down
63 changes: 63 additions & 0 deletions packages/test-harness/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
[package]
name = "wavs-test-harness"
description = "Reusable integration test harness for WAVS apps — chain control, service lifecycle, and contract assertions"
version.workspace = true
edition.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
license.workspace = true
publish = false

# Features are additive. `fork` and `inproc` are on by default because the harness
# is only useful when at least one runner tier is enabled. `subprocess` is preview
# and stays off by default to keep the dep graph small.
#
# Consumers should add `wavs-test-harness` as a [dev-dependencies] entry only —
# never as a regular dependency — to keep the `utils/test-utils` feature out of
# their production builds.
[features]
default = ["fork", "inproc"]
fork = []
inproc = []
subprocess = []

[dependencies]
utils = { workspace = true, features = ["test-utils"] }
wavs-types = { workspace = true, features = ["full"] }
wavs-engine = { workspace = true }
wasmtime = { workspace = true }
tempfile = { workspace = true }

anyhow = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

alloy-contract = { workspace = true }
alloy-network = { workspace = true }
alloy-node-bindings = { workspace = true }
alloy-primitives = { workspace = true }
alloy-provider = { workspace = true, features = ["anvil-api", "anvil-node"] }
alloy-signer = { workspace = true }
alloy-signer-local = { workspace = true }
alloy-sol-types = { workspace = true }
alloy-sol-macro = { workspace = true }
alloy-rpc-types-eth = { workspace = true }

# Subprocess runner-only deps. Cheap enough to leave outside the feature gate.
reqwest = { workspace = true, features = ["json"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tokio = { workspace = true }
tempfile = { workspace = true }
temp-env = { workspace = true }
252 changes: 252 additions & 0 deletions packages/test-harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
# wavs-test-harness

Reusable integration test harness for WAVS apps.

Tracks issue [Lay3rLabs/WAVS#1147](https://github.com/Lay3rLabs/WAVS/issues/1147).

## What this crate is for

Downstream app repos — `wavs-defi`, `wavs-aave-guardian`, `wavs-prediction-market`,
and the rest — write integration tests that drive the full WAVS path:

```
trigger → operator component → aggregator (sig + quorum) → service handler → app contract
```

`wavs-test-harness` provides chain control, service-lifecycle, envelope
helpers, and waiters so each downstream test only has to specify its
app-specific contract deployment and assertions.

## Quickstart — local Anvil

```rust
use wavs_test_harness::{chain, service::{InProcRunner, ServiceSpec}};

let (provider, anvil) = chain::spawn_local().await?;

let spec = ServiceSpec::new()
.component_wasm("path/to/strategy.wasm")
.aggregator_wasm("path/to/aggregator.wasm")
.config_var("CHAINLINK_ETH_USD", chainlink.to_string());

let runner = InProcRunner::from_spec(&spec)?;
let outputs = runner.run_component(b"hello".to_vec()).await?;
```

Two runnable examples ship with the crate:

```bash
cargo run -p wavs-test-harness --example minimal_local
FORK_RPC_URL=<base-rpc> cargo run -p wavs-test-harness --example fork_with_addresses
```

## Quickstart — pinned fork

```rust
use wavs_test_harness::{chain, fixtures::ChainProfile};

let profile = ChainProfile::load("base")?; // chain_id + addresses + rpc_env
// `from_profile` resolves FORK_RPC_URL via the profile's rpc_env and pins the
// block to FORK_BLOCK_NUMBER (env) or profile.chain.fork_block (fallback).
let opts = chain::ForkOptions::from_profile(&profile)?;
let (provider, _anvil) = chain::spawn_fork(opts).await?;

let usdc = profile.address("usdc")?;
let weth = profile.address("weth")?;
let whale = profile.accounts.address("usdc_whale")?;

chain::impersonate_funded(&provider, whale).await?;
// … your test …
chain::stop_impersonating(&provider, whale).await?;
```

### Fork block pinning precedence

For determinism, the harness pins the fork block in this order (highest wins):

1. `ForkOptions::with_block_number(b)` — explicit override.
2. `FORK_BLOCK_NUMBER` env var — CI override.
3. `ChainProfile.chain.fork_block` — profile default (via `from_profile`).
4. None — Anvil follows upstream `latest`; spawn logs a `warn!` so the
determinism gap is visible.

The RPC URL is never logged verbatim — only via [`chain::redact_url`].

## Tier matrix

| Tier | Stages run | Stages mocked | Use case |
|---|---|---|---|
| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM) + envelope signing + on-chain `handleSignedEnvelope` + assert | dispatcher subsystems | deterministic PR tier on local Anvil or fork |
| **Subprocess** | binary spawn + `/health` probe + `POST /dev/services` + on-chain submission + assert; v1 covers the spawn + service-registration loop, full trigger emission via the harness's `chain::*` primitives is composable | nothing — runs the real `wavs` binary | nightly fork + pre-release |

InProc executes the actual operator and aggregator WASM directly through
`wavs-engine`, then signs an envelope and submits it on-chain via
`handleSignedEnvelope` to `SimpleSubmit` (the canonical reference handler).
Tests assert against the handler's stored state. Everything from trigger
through submission is exercised; only the WAVS dispatcher's trigger /
submission orchestration is bypassed.

Subprocess spawns the actual `wavs` binary against a tempdir HOME/DATA and
a generated `wavs.toml`, polls `/health` until ready, and exposes the
HTTP-API surface for service registration and lifecycle drive. The runner
is feature-gated behind `subprocess` to keep the default dep graph small.

### End-to-end lifecycle path (InProc)

```rust
use wavs_test_harness::{
chain,
envelope::{self, sign_envelope, Envelope},
service::{InProcRunner, MockHandler, MockHandlerConfig, ServiceSpec},
};

// 1. Anvil with a wallet-bound provider.
let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer().await?;

// 2. Deploy the reference SimpleServiceManager + SimpleSubmit pair.
let operator = alloy_signer_local::PrivateKeySigner::random();
let config = MockHandlerConfig::single_operator(operator.address());
let handler = MockHandler::deploy(provider.clone(), &config).await?;

// 3. Run real operator WASM through wavs-engine.
let spec = ServiceSpec::new()
.component_wasm("path/to/strategy.wasm")
.aggregator_wasm("path/to/aggregator.wasm");
let runner = InProcRunner::from_spec(&spec)?;
let outputs = runner.run_component(payload_bytes).await?;

// 4. Sign + submit.
let env = Envelope::new(envelope::event_id_from_nonce(1), outputs[0].clone());
let sigdata = sign_envelope(&env, &[operator], reference_block)?;
handler.submit_envelope(&env, &sigdata).await?;

// 5. Assert handler state.
assert!(handler.is_valid_trigger(1).await?);
```

### Oracle mocking on a fork

```rust
use wavs_test_harness::chain::oracle::{chainlink_usd, install_chainlink_aggregator_v3};

let chainlink_eth_usd = profile.address("chainlink_eth_usd")?;
install_chainlink_aggregator_v3(&provider, chainlink_eth_usd, chainlink_usd(3_000.0)).await?;
// Downstream contract reads now see ETH/USD = $3000 deterministically.
```

## CI tiers

| Tier | Runner | When | Command |
|---|---|---|---|
| **PR deterministic** | local Anvil only | every PR | `just test-harness` |
| **PR labeled fork** | pinned Base / mainnet fork | PRs labeled `fork-tests` | `just test-harness-fork` |
| **Nightly fork matrix** | broader protocol matrix | scheduled | TBD — wired alongside Subprocess landing |
| **Pre-release subprocess** | full `wavs` binary | release branches | TBD — depends on Subprocess landing |

Fork tier requires `FORK_RPC_URL` set in the CI secret store. The harness
never logs the URL; the secret should be scoped to fork-tier jobs only.

## Bundled chain profiles

Three profiles ship via `include_str!`:

- `local.toml` — `chain_id=31337`, no fork, no env vars.
- `base.toml` — `chain_id=8453`, `FORK_RPC_URL`, complete protocol address set
(Aerodrome pool / NFT manager / router, Avantis trading / storage / gov,
Chainlink, Pyth, USDC, WETH, USDC whale, Avantis operator).
- `mainnet.toml` — `chain_id=1`, `FORK_RPC_URL`, tokens + Aave v3 pool.

Consumers can ship their own profiles via `ChainProfile::from_path(...)`
or `ChainProfile::from_toml_str(...)`.

## Determinism boundary

- **Block-time control.** `chain::set_automine(false)` + `chain::mine_blocks(n)`
gives deterministic block production. Pair with `chain::set_next_block_timestamp`
for tests that depend on block timestamps.
- **Snapshot / revert.** `chain::SnapshotGuard::take(&provider)` captures
state; explicit `.revert(&provider)` rolls back. The guard logs a warning
if dropped without explicit revert (async-drop is unstable).
- **Operator signing.** All envelope signatures are deterministic for a given
signer key. Use the same operator set across re-runs to keep signature
bytes stable.

## Tier selection in downstream repos

Add `wavs-test-harness` as a `[dev-dependencies]` entry only, never as a
regular dependency. This keeps `utils/test-utils` out of production builds.

```toml
[dev-dependencies]
wavs-test-harness = { git = "https://github.com/Lay3rLabs/WAVS", rev = "<commit-sha>" }
```

**Branch references are not supported in CI — pin to a commit SHA.**

### Cross-repo alloy version barrier

When the downstream repo uses a different alloy major version than the WAVS
workspace pin (`1.0.42` at the time of writing), cargo workspace feature
unification can produce compile errors in `alloy-rpc-types-eth` (e.g.
`BlobTransactionSidecarVariant` vs `BlobTransactionSidecar` mismatch).
Resolution paths:

1. Bump alloy in WAVS to match the downstream consumer.
2. Pin the downstream test crate to the WAVS alloy version.
3. Publish `wavs-test-harness` as a versioned crate so cargo handles
version-mixing instead of unification.

Tracked as the highest-priority follow-up under #1147.

## Layer-tests relationship

`packages/layer-tests` continues to run as-is. `wavs-test-harness` is
**canonical** for new integration tests; `layer-tests` is treated as legacy
and is expected to migrate onto the harness in a follow-up. Two parallel
test crates drift over time, so migration shouldn't sit forever — but it
also doesn't block this v1.

## What ships in v1

- `chain`: local Anvil (`spawn_local`, `spawn_local_with_deployer`) +
pinned fork (feature `fork`, on by default).
`snapshot` / `revert` with RAII guard. `impersonate_funded`,
`enable_auto_impersonate`, `whale_fund`. `mine_blocks`, `set_automine`,
`increase_time`, `set_next_block_timestamp`. `redact_url` / `redact_key`
for sanitized logs. `oracle::install_chainlink_aggregator_v3` +
`set_chainlink_price` for forked-chain oracle mocking.
- `fixtures`: `ChainProfile` (TOML), `Addresses` typed lookup. Three
bundled profiles. `ForkOptions::from_env` reads `FORK_RPC_URL` +
`FORK_BLOCK_NUMBER`; `ForkOptions::from_profile` adds the profile's
declared `fork_block` as a fallback default.
- `service`: `ServiceSpec` builder, middleware mock re-exports
(`EvmMiddleware`, `MockEvmServiceManager`, `AvsOperator`),
`InProcRunner` (real WASM via `wavs-engine`),
`MockHandler` (`SimpleServiceManager` + `SimpleSubmit` deployable pair
for on-chain envelope validation), `SubprocessRunner`
(functional spawn + health probe + service registration, feature
`subprocess`).
- `lifecycle`: `manual_input_json` / `manual_input_raw`, `wait_for` /
`wait_until` polling helpers, `assert_within` tolerance check.
- `envelope`: canonical `Envelope` / `SignatureData` shape mirroring
`@wavs/solidity@0.6.x`, `sign_envelope`, `submit_envelope` (on-chain
`handleSignedEnvelope` dispatch), `sort_signature_data`,
`event_id_from_nonce` / `event_id_from_seed`,
`Envelope::message_hash` / `signing_hash`.
- `harness::TestHarness<P>` convenience wrapper.

## What's not in v1

- Full trigger-emission + quorum-waiting on the subprocess tier (the
spawn + service-registration loop ships; the rest is composable today
by combining `SubprocessRunner` with the harness's `chain::*`
primitives — bundled helpers land in the next PR).
- Pyth + other oracle shapes (Chainlink AggregatorV3 ships; the same
`anvil_set_code` pattern applies, only the bytecode differs).
- Cosmos fork support.
- A `with_deploy(closure)` builder hook on `TestHarness` (deferred until
async-closure ergonomics stabilize; compose primitives directly today).
- Operator-side oracle mocking — needed before downstream apps can run
their real strategy WASM end-to-end via the InProc runner.
- A version of the harness compatible with newer alloy lines used by
downstream apps — see "Cross-repo alloy version barrier" above.
Loading
Loading