Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 69 additions & 7 deletions crates/core/tests/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1909,9 +1909,49 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
/// Expected flow:
/// 1. peer-a sends PUT → routes through gateway → stored on peer-c
/// 2. peer-c sends PUT response → routes back through gateway → received by peer-a
const THREE_HOP_TEST_CONTRACT: &str = "test-contract-integration";

static THREE_HOP_CONTRACT: LazyLock<(ContractContainer, freenet::dev_tool::Location)> =
LazyLock::new(|| {
let contract =
test_utils::load_contract(THREE_HOP_TEST_CONTRACT, vec![].into()).expect("contract");
let location = freenet::dev_tool::Location::from(&contract.key());
(contract, location)
});

fn three_hop_contract_location() -> freenet::dev_tool::Location {
let (_, location) = &*THREE_HOP_CONTRACT;
*location
}

fn three_hop_gateway_location() -> f64 {
freenet::dev_tool::Location::new_rounded(three_hop_contract_location().as_f64() + 0.2).as_f64()
}

fn three_hop_peer_a_location() -> f64 {
freenet::dev_tool::Location::new_rounded(three_hop_contract_location().as_f64() + 0.5).as_f64()
}

fn three_hop_peer_c_location() -> f64 {
three_hop_contract_location().as_f64()
}

fn expected_three_hop_locations() -> [f64; 3] {
[
three_hop_gateway_location(),
three_hop_peer_a_location(),
three_hop_peer_c_location(),
]
}

#[freenet_test(
nodes = ["gateway", "peer-a", "peer-c"],
gateways = ["gateway"],
node_configs = {
"gateway": { location: three_hop_gateway_location() },
"peer-a": { location: three_hop_peer_a_location() },
"peer-c": { location: three_hop_peer_c_location() },
},
auto_connect_peers = true,
timeout_secs = 240,
startup_wait_secs = 15,
Expand All @@ -1922,10 +1962,12 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) -> TestResult {
use freenet::dev_tool::Location;

const TEST_CONTRACT: &str = "test-contract-integration";
let contract = test_utils::load_contract(TEST_CONTRACT, vec![].into())?;
let (contract, contract_location) = {
let (contract, location) = &*THREE_HOP_CONTRACT;
(contract.clone(), *location)
};
let contract_key = contract.key();
let contract_location = Location::from(&contract_key);
let node_locations = expected_three_hop_locations();

let initial_state = test_utils::create_empty_todo_list();
let wrapped_state = WrappedState::from(initial_state);
Expand All @@ -1935,16 +1977,36 @@ async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) ->
let peer_a = ctx.node("peer-a")?;
let peer_c = ctx.node("peer-c")?;

// Note: We cannot modify node locations after they're created with the macro,
// so this test will use random locations. The original test had specific location
// requirements to ensure proper three-hop routing. For now, we'll proceed with
// the test and it should still validate PUT response routing.
assert_eq!(gateway.location, node_locations[0]);
assert_eq!(peer_a.location, node_locations[1]);
assert_eq!(peer_c.location, node_locations[2]);

tracing::info!("Node A data dir: {:?}", peer_a.temp_dir_path);
tracing::info!("Gateway node data dir: {:?}", gateway.temp_dir_path);
tracing::info!("Node C data dir: {:?}", peer_c.temp_dir_path);
tracing::info!("Contract location: {}", contract_location.as_f64());

let gateway_distance = Location::new(gateway.location).distance(contract_location);
let peer_a_distance = Location::new(peer_a.location).distance(contract_location);
let peer_c_distance = Location::new(peer_c.location).distance(contract_location);

// Ensure the contract should naturally route to peer-c to create the 3-hop path:
// peer-a (client) -> gateway -> peer-c (closest to contract).
assert!(
peer_c_distance.as_f64() < gateway_distance.as_f64(),
"peer-c must be closer to contract than the gateway for three-hop routing"
);
assert!(
peer_c_distance.as_f64() < peer_a_distance.as_f64(),
"peer-c must be closest node to the contract location"
);
tracing::info!(
"Distances to contract - gateway: {}, peer-a: {}, peer-c: {}",
gateway_distance.as_f64(),
peer_a_distance.as_f64(),
peer_c_distance.as_f64()
);

// Connect to peer A's WebSocket API
let uri_a = format!(
"ws://127.0.0.1:{}/v1/contract/command?encodingProtocol=native",
Expand Down
62 changes: 62 additions & 0 deletions crates/freenet-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,68 @@ async fn test_multi_gateway(ctx: &mut TestContext) -> TestResult {
}
```

#### `node_locations`
Set explicit ring locations for nodes (order must match the `nodes` list). This is useful for deterministic routing topologies in multi-hop tests.

```rust
#[freenet_test(
nodes = ["gateway", "peer-a", "peer-b"],
node_locations = [0.2, 0.6, 0.85], // gateway, peer-a, peer-b
auto_connect_peers = true
)]
async fn test_with_locations(ctx: &mut TestContext) -> TestResult {
// Test logic here...
Ok(())
}
```

**Rules:**
- Provide one numeric value per node.
- Values should be in the range `[0.0, 1.0]`.
- Omit `node_locations` to use random locations (default behavior).

#### `node_locations_fn`
Provide a function that returns `Vec<f64>` (one per node) when locations need to be computed dynamically (for example, based on a contract’s ring location).

```rust
fn my_locations() -> Vec<f64> {
// Must return one entry per node in the same order.
vec![0.1, 0.6, 0.9]
}

#[freenet_test(
nodes = ["gateway", "peer-a", "peer-b"],
node_locations_fn = my_locations,
auto_connect_peers = true
)]
```

- The function must return exactly as many values as there are nodes; otherwise the test fails early.
- `node_locations` and `node_locations_fn` are mutually exclusive.

#### `node_configs`
Override configuration for specific nodes using a map (order must still match the `nodes` list for implicit behavior). Currently supports setting explicit locations per node, with additional fields planned as the macro evolves.

```rust
fn my_gateway_location() -> f64 { 0.25 }

#[freenet_test(
nodes = ["gateway", "peer-a", "peer-b"],
node_configs = {
"gateway": { location: my_gateway_location() },
"peer-a": { location: 0.55 },
"peer-b": { location: 0.90 },
}
)]
async fn test_with_node_configs(ctx: &mut TestContext) -> TestResult {
// Test logic ...
Ok(())
}
```

- Only the fields specified are overridden; unspecified nodes fall back to the default/random configuration.
- Additional fields can be added over time without changing call sites (e.g., `token_ttl`, custom secrets, etc.).

#### `auto_connect_peers`
Automatically configure all peer nodes to connect to all gateway nodes.

Expand Down
86 changes: 83 additions & 3 deletions crates/freenet-macros/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,83 @@
use crate::parser::{AggregateEventsMode, FreenetTestArgs};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{ItemFn, LitInt, Result};
use syn::{ItemFn, LitInt, LitStr, Result};

/// Get the configured location for a node or fall back to randomness.
fn node_location(args: &FreenetTestArgs, idx: usize, label: &str) -> TokenStream {
if let Some(config) = args.node_configs.get(label) {
if let Some(expr) = &config.location_expr {
let label_lit = LitStr::new(label, proc_macro2::Span::call_site());
return quote! {{
let value: f64 = (#expr);
if !(0.0..=1.0).contains(&value) {
panic!(
"node '{}' location {} is out of range [0.0, 1.0]",
#label_lit, value
);
}
value
}};
}
}

if let Some(ref locations) = args.node_locations {
let value = locations[idx];
return quote! { #value };
}

if args.node_locations_fn.is_some() {
let idx_lit = syn::Index::from(idx);
return quote! {{
if let Some(ref locs) = __node_locations {
locs[#idx_lit]
} else {
rand::Rng::random(&mut rand::rng())
}
}};
}

quote! { rand::Rng::random(&mut rand::rng()) }
}

/// Generate node location initialization (literal list or function).
fn generate_node_locations_init(args: &FreenetTestArgs) -> TokenStream {
let node_count = args.nodes.len();

if let Some(ref fn_path) = args.node_locations_fn {
quote! {
let __node_locations: Option<Vec<f64>> = {
let locs = #fn_path();
if locs.len() != #node_count {
return Err(anyhow::anyhow!(
"node_locations_fn returned {} locations, expected {}",
locs.len(),
#node_count
));
}
for (idx, loc) in locs.iter().enumerate() {
if !(0.0..=1.0).contains(loc) {
return Err(anyhow::anyhow!(
"node_locations_fn value at index {} is out of range: {} (must be in [0.0, 1.0])",
idx,
loc
));
}
}
Some(locs)
};
}
} else if let Some(ref locations) = args.node_locations {
let values: Vec<_> = locations.iter().map(|loc| quote! { #loc }).collect();
quote! {
let __node_locations: Option<Vec<f64>> = Some(vec![#(#values),*]);
}
} else {
quote! {
let __node_locations: Option<Vec<f64>> = None;
}
}
}

/// Helper to determine if a node is a gateway
fn is_gateway(args: &FreenetTestArgs, node_label: &str, node_idx: usize) -> bool {
Expand All @@ -27,6 +103,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok

// Generate node setup code
let node_setup = generate_node_setup(&args);
let node_locations_init = generate_node_locations_init(&args);

// Extract values before configs are moved
let value_extraction = generate_value_extraction(&args);
Expand Down Expand Up @@ -71,6 +148,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok
tracing::info!("Starting test: {}", stringify!(#test_fn_name));

// 2. Create node configurations
#node_locations_init
#node_setup

// 3. Extract values before configs are moved
Expand Down Expand Up @@ -117,6 +195,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {

if is_gw {
// Gateway node configuration
let location_expr = node_location(args, idx, node_label);
setup_code.push(quote! {
let (#config_var, #temp_var) = {
let temp_dir = tempfile::tempdir()?;
Expand All @@ -128,7 +207,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
let network_port = freenet::test_utils::reserve_local_port()?;
let ws_port = freenet::test_utils::reserve_local_port()?;

let location: f64 = rand::Rng::random(&mut rand::rng());
let location: f64 = #location_expr;

let config = freenet::config::ConfigArgs {
ws_api: freenet::config::WebsocketApiArgs {
Expand Down Expand Up @@ -193,6 +272,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
let is_gw = is_gateway(args, node_label, idx);

if !is_gw {
let location_expr = node_location(args, idx, node_label);
// Collect gateway info variables to serialize
let gateways_config = if args.auto_connect_peers {
// Collect all gateway_info_X variables
Expand Down Expand Up @@ -238,7 +318,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
let network_port = freenet::test_utils::reserve_local_port()?;
let ws_port = freenet::test_utils::reserve_local_port()?;

let location: f64 = rand::Rng::random(&mut rand::rng());
let location: f64 = #location_expr;

let config = freenet::config::ConfigArgs {
ws_api: freenet::config::WebsocketApiArgs {
Expand Down
Loading
Loading